diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8cf0d77..5ea120d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,4 +40,4 @@ jobs: - uses: denolib/setup-deno@v2 with: deno-version: v2.x - - run: deno test --allow-read --allow-write --allow-env + - run: deno test --allow-read --allow-write --allow-env --allow-run=bash diff --git a/AGENTS.md b/AGENTS.md index 6b7eaa4..229b42b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,7 +7,7 @@ Public repository for developer environment activation tooling. - `deno fmt --check .` - `deno lint .` - `deno check ./app.ts` -- `deno test --allow-read --allow-write --allow-env` +- `deno test --allow-read --allow-write --allow-env --allow-run=bash` ## Always Do diff --git a/src/shellcode().test.ts b/src/shellcode().test.ts new file mode 100644 index 0000000..f57e048 --- /dev/null +++ b/src/shellcode().test.ts @@ -0,0 +1,65 @@ +import { assert, assertEquals } from "jsr:@std/assert"; +import { Path } from "libpkgx"; +import shellcode, { datadir } from "./shellcode().ts"; + +// Issue #51: cd-ing directly into a subdir of an already-activated devenv +// must activate the devenv (and not emit `permission denied`). This drives +// the generated shellcode through a real bash subprocess with a fake `dev` +// on PATH so we can assert how the chpwd hook invokes it. +Deno.test("chpwd hook activates when cd-ing into subdir of devenv (#51)", async () => { + const tmp = Path.mktemp(); + const proj = tmp.join("proj").mkdir(); + const sub = proj.join("sub").mkdir(); + const xdg = tmp.join("xdg").mkdir(); + const bin = tmp.join("bin").mkdir(); + const log = tmp.join("dev-args.log"); + + // pre-activate `proj` (not `sub`) — that's the case from the bug report + xdg.join("pkgx", "dev").join(proj.string.slice(1)).mkdir("p") + .join("dev.pkgx.activated").touch(); + + // fake `dev` records its argv and emits a sentinel for eval to run + const fake_dev = bin.join("dev"); + Deno.writeTextFileSync( + fake_dev.string, + `#!/bin/sh\nprintf '%s\\n' "$@" >> "${log}"\necho 'echo HOOK_OK'\n`, + ); + Deno.chmodSync(fake_dev.string, 0o755); + + const env = { + ...Deno.env.toObject(), + PATH: `${bin}:${Deno.env.get("PATH") ?? ""}`, + XDG_DATA_HOME: xdg.string, + }; + + const proc = await new Deno.Command("bash", { + args: ["-c", `${shellcode(env)}\ncd "${sub}"`], + env, + stdout: "piped", + stderr: "piped", + }).output(); + + const stdout = new TextDecoder().decode(proc.stdout); + const stderr = new TextDecoder().decode(proc.stderr); + const dev_args = log.isFile() + ? Deno.readTextFileSync(log.string).split("\n").filter(Boolean) + : []; + + assert( + stdout.includes("HOOK_OK"), + `hook should eval dev's stdout. stdout=${stdout} stderr=${stderr}`, + ); + assertEquals(stderr, "", "hook should produce no stderr"); + assertEquals( + dev_args, + [proj.string], + "dev must be invoked with the activated dir, not bare", + ); +}); + +Deno.test("datadir respects XDG_DATA_HOME", () => { + assertEquals( + datadir({ XDG_DATA_HOME: "/tmp/xdg-test" }).string, + "/tmp/xdg-test/pkgx/dev", + ); +}); diff --git a/src/shellcode().ts b/src/shellcode().ts index 0fb82e0..6848fb7 100644 --- a/src/shellcode().ts +++ b/src/shellcode().ts @@ -1,21 +1,25 @@ import { Path } from "libpkgx"; -export default function shellcode() { +type Env = Record; + +export default function shellcode(env: Env = Deno.env.toObject()) { // find self - const dev_cmd = Deno.env.get("PATH")?.split(":").map((path) => + const dev_cmd = env.PATH?.split(":").map((path) => Path.abs(path)?.join("dev") ) .filter((x) => x?.isExecutableFile())[0]; if (!dev_cmd) throw new Error("couldn’t find `dev`"); + const dd = datadir(env); + return ` _pkgx_chpwd_hook() { if ! type _pkgx_dev_try_bye >/dev/null 2>&1 || _pkgx_dev_try_bye; then dir="$PWD" while [ "$dir" != / -a "$dir" != . ]; do - if [ -f "${datadir()}/$dir/dev.pkgx.activated" ]; then - eval "$(${dev_cmd})" "$dir" + if [ -f "${dd}/$dir/dev.pkgx.activated" ]; then + eval "$(${dev_cmd} "$dir")" break fi dir="$(dirname "$dir")" @@ -29,8 +33,8 @@ dev() { if type -f _pkgx_dev_try_bye >/dev/null 2>&1; then dir="$PWD" while [ "$dir" != / -a "$dir" != . ]; do - if [ -f "${datadir()}/$dir/dev.pkgx.activated" ]; then - rm "${datadir()}/$dir/dev.pkgx.activated" + if [ -f "${dd}/$dir/dev.pkgx.activated" ]; then + rm "${dd}/$dir/dev.pkgx.activated" break fi dir="$(dirname "$dir")" @@ -43,8 +47,8 @@ dev() { if [ "$2" ]; then "${dev_cmd}" "$@" elif ! type -f _pkgx_dev_try_bye >/dev/null 2>&1; then - mkdir -p "${datadir()}$PWD" - touch "${datadir()}$PWD/dev.pkgx.activated" + mkdir -p "${dd}$PWD" + touch "${dd}$PWD/dev.pkgx.activated" eval "$(${dev_cmd})" else echo "devenv already active" >&2 @@ -77,19 +81,19 @@ fi `.trim(); } -export function datadir() { +export function datadir(env: Env = Deno.env.toObject()) { return new Path( - Deno.env.get("XDG_DATA_HOME")?.trim() || platform_data_home_default(), + env.XDG_DATA_HOME?.trim() || platform_data_home_default(env), ).join("pkgx", "dev"); } -function platform_data_home_default() { +function platform_data_home_default(env: Env) { const home = Path.home(); switch (Deno.build.os) { case "darwin": return home.join("Library/Application Support"); case "windows": { - const LOCALAPPDATA = Deno.env.get("LOCALAPPDATA"); + const LOCALAPPDATA = env.LOCALAPPDATA; if (LOCALAPPDATA) { return new Path(LOCALAPPDATA); } else {