From bf7ff9fbb4d15a45c039fb7551c93dfd96fb10b6 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Mon, 20 Jul 2026 17:31:39 +0200 Subject: [PATCH] launch: discover smalltalk hooks in a packaged layout (/lib/smalltalk) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `discoverSmalltalkDir` derives a candidate from the `st` binary as `/bin/st` -> grandparent. That holds for a git checkout, but a PACKAGED install puts `st` at `/bin/st` while the repo itself lives at `/lib/smalltalk`. The grandparent is then `` — one level ABOVE `examples/claude-code/hooks`, so `hasHooks` misses. The hooks ship in the package; they are just undiscoverable. On a Nix box with a correct install and nothing misconfigured, that surfaces as a BLOCKING doctor failure: ✗ smalltalk hooks NOT found — set SMALLTALK_DIR or put `st` on PATH ✗ 1 blocking issue which reads as "convoy is broken" on a working setup, and is only worked around by exporting SMALLTALK_DIR — something a new adopter has no way to guess. It also fails the pre-init friendly-first-command UX, since the blocking check keeps `doctor --quick` at rc=1. Try each candidate BOTH as the repo root and as `/lib/smalltalk`. Additive: discovery already tolerates four layouts, this is a fifth real one, and the root is still checked first so a checkout keeps winning. Verified against the real package with SMALLTALK_DIR unset: discovered: /nix/store/...-smalltalk-0.3.0/lib/smalltalk Tests: two cases — hooks found under `/lib/smalltalk`, and the root still preferred when both carry hooks. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01GFGkKcB3iojR4TtFaCgPDF agent-session-id: 7a700216-dccd-4fa8-b12b-9c713ee61e93 agent-tool: Claude Code agent-tool-version: 2.1.215 agent-model: claude-opus-4-8 agent-runtime-profile: /nix/store/0f9xcsqcqq0gaa3pz4pqhlicckigyhmh-coding-agent-runtime-profile/share/coding-agents/profile.json agent-skills-manifest: /nix/store/isfy3lpiqjmxz4r0gwxw38nk2s9469hz-agent-skills-corpus/share/agent-skills/manifest.json tooling-profile: dotfiles@b831303 --- src/launch.test.ts | 37 +++++++++++++++++++++++++++++++++++++ src/launch.ts | 11 ++++++++++- 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/src/launch.test.ts b/src/launch.test.ts index 7be1650..b0c52c3 100644 --- a/src/launch.test.ts +++ b/src/launch.test.ts @@ -222,6 +222,43 @@ describe("discoverSmalltalkDir (fresh-install hook discovery, no SMALLTALK_DIR n }); }); +describe("discoverSmalltalkDir on a PACKAGED layout (repo under /lib/smalltalk)", () => { + const saved = process.env["SMALLTALK_DIR"]; + afterEach(() => { + if (saved === undefined) delete process.env["SMALLTALK_DIR"]; + else process.env["SMALLTALK_DIR"] = saved; + }); + + // Nix (and any packager that installs the repo under lib/) puts `st` at /bin/st while the repo lives + // at /lib/smalltalk — so the bin/st -> grandparent assumption lands one level ABOVE the hooks. + it("finds the hooks under /lib/smalltalk, not just at the candidate root", () => { + const pkg = mkdtempSync(join(tmpdir(), "convoy-sm-pkg-")); + try { + const repo = join(pkg, "lib", "smalltalk"); + mkdirSync(join(repo, "examples", "claude-code", "hooks"), { recursive: true }); + writeFileSync(join(repo, "examples", "claude-code", "hooks", "session-start.sh"), "#!/bin/sh\n"); + process.env["SMALLTALK_DIR"] = pkg; // the package root — hooks are one level down + expect(discoverSmalltalkDir()).toBe(repo); + } finally { + rmSync(pkg, { recursive: true, force: true }); + } + }); + + it("still prefers the candidate root when the hooks are there (checkout layout wins)", () => { + const dir = mkdtempSync(join(tmpdir(), "convoy-sm-both-")); + try { + for (const base of [dir, join(dir, "lib", "smalltalk")]) { + mkdirSync(join(base, "examples", "claude-code", "hooks"), { recursive: true }); + writeFileSync(join(base, "examples", "claude-code", "hooks", "session-start.sh"), "#!/bin/sh\n"); + } + process.env["SMALLTALK_DIR"] = dir; + expect(discoverSmalltalkDir()).toBe(dir); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); + describe("discoverSmalltalkDir via ST_BIN (the off-PATH hooks discovery — Johannes false-negative)", () => { const savedSt = process.env["SMALLTALK_DIR"]; const savedBin = process.env["ST_BIN"]; diff --git a/src/launch.ts b/src/launch.ts index 5616a64..13986c8 100644 --- a/src/launch.ts +++ b/src/launch.ts @@ -86,7 +86,16 @@ export function discoverSmalltalkDir(): string | null { if (fromPath) candidates.push(fromPath); } candidates.push(join(dirname(dirname(fileURLToPath(import.meta.url))), "..", "smalltalk")); - for (const c of candidates) if (hasHooks(c)) return c; + // Each candidate is tried BOTH as the repo root and as `/lib/smalltalk`. A packaged install + // (Nix, and any packager that installs the repo under `lib/`) puts `st` at `/bin/st` while the repo + // itself lives at `/lib/smalltalk` — so the `/bin/st` → grandparent assumption lands on + // ``, one level ABOVE the hooks. Without this the hooks ship in the package but are undiscoverable, + // and `doctor` reports a blocking "smalltalk hooks NOT found" on a correct install. + for (const c of candidates) { + if (hasHooks(c)) return c; + const packaged = join(c, "lib", "smalltalk"); + if (hasHooks(packaged)) return packaged; + } return null; }