Skip to content

Commit aba9421

Browse files
committed
Fix #51: pass dir to dev when activating from subdir
The `chpwd` hook walks up from `$PWD` looking for an activation marker, but when it found one in a parent dir it invoked `dev` with no arguments, making `dev` sniff `$PWD` (the subdir, with no keyfiles) instead of the activated dir. `dev` exited with "no devenv detected" and empty stdout, and the dir intended for `dev` got passed to eval as a positional arg instead, which the shell tried to execute, producing "permission denied: <dir>" on `cd`. This change moves "$dir" inside the command substitution, so `dev` sniffs the activated directory. This also adds an integration test that drives `zsh` end-to-end with a fake `dev` on PATH to verify the hook invokes `dev` with the activated dir as argv[1] and produces no permission-denied error.
1 parent 7818a53 commit aba9421

2 files changed

Lines changed: 135 additions & 1 deletion

File tree

src/shellcode().test.ts

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import { assert, assertEquals } from "jsr:@std/assert";
2+
import { Path } from "libpkgx";
3+
import shellcode, { datadir } from "./shellcode().ts";
4+
5+
// Exercises `_pkgx_chpwd_hook` end-to-end via a zsh subprocess with a fake
6+
// `dev` binary on PATH. Reproduces the bug from issue #51 where cd-ing
7+
// directly into a subdir of an activated devenv fails to activate and emits
8+
// `permission denied`.
9+
10+
async function run_hook_in_subdir(): Promise<
11+
{ stdout: string; stderr: string; recorded_args: string[] }
12+
> {
13+
const tmp = Path.mktemp();
14+
const proj = tmp.join("proj").mkdir();
15+
const sub = proj.join("sub").mkdir();
16+
17+
// Override XDG_DATA_HOME so datadir() lives in the temp tree.
18+
const xdg = tmp.join("xdg").mkdir();
19+
20+
// Pre-create the activation marker for `proj`.
21+
const marker_dir = xdg.join(
22+
"pkgx",
23+
"dev",
24+
proj.string.slice(1),
25+
).mkdir("p");
26+
marker_dir.join("dev.pkgx.activated").touch();
27+
28+
// Fake `dev` that records its argv and emits a sentinel shell command we
29+
// can detect in stdout. Crucially: the real `dev` (with no args) would
30+
// sniff $PWD, find nothing, and exit 1 — the bug we're testing.
31+
const fake_bin = tmp.join("bin").mkdir();
32+
const fake_dev = fake_bin.join("dev");
33+
const log = tmp.join("dev-args.log");
34+
Deno.writeTextFileSync(
35+
fake_dev.string,
36+
`#!/bin/sh\nprintf '%s\\n' "$@" >> "${log.string}"\necho "echo HOOK_OK"\n`,
37+
);
38+
Deno.chmodSync(fake_dev.string, 0o755);
39+
40+
const env = {
41+
...Deno.env.toObject(),
42+
XDG_DATA_HOME: xdg.string,
43+
PATH: `${fake_bin.string}:${Deno.env.get("PATH") ?? ""}`,
44+
};
45+
46+
// Generate shellcode using a PATH that resolves `dev` to our fake and an
47+
// XDG_DATA_HOME that points at our temp datadir (both are baked in at
48+
// codegen time).
49+
const original_path = Deno.env.get("PATH");
50+
const original_xdg = Deno.env.get("XDG_DATA_HOME");
51+
Deno.env.set("PATH", env.PATH);
52+
Deno.env.set("XDG_DATA_HOME", xdg.string);
53+
let code: string;
54+
try {
55+
code = shellcode();
56+
} finally {
57+
if (original_path !== undefined) Deno.env.set("PATH", original_path);
58+
if (original_xdg === undefined) Deno.env.delete("XDG_DATA_HOME");
59+
else Deno.env.set("XDG_DATA_HOME", original_xdg);
60+
}
61+
62+
// Simulate the user: shell starts in HOME, then cd's directly into the
63+
// subdirectory of the already-activated project.
64+
const script = `
65+
${code}
66+
cd "${sub.string}"
67+
`;
68+
69+
const script_path = tmp.join("script.zsh");
70+
Deno.writeTextFileSync(script_path.string, script);
71+
72+
const proc = await new Deno.Command("zsh", {
73+
args: [script_path.string],
74+
env,
75+
stdout: "piped",
76+
stderr: "piped",
77+
}).output();
78+
79+
const recorded_args = log.isFile()
80+
? Deno.readTextFileSync(log.string).split("\n").filter((x) => x.length > 0)
81+
: [];
82+
83+
return {
84+
stdout: new TextDecoder().decode(proc.stdout),
85+
stderr: new TextDecoder().decode(proc.stderr),
86+
recorded_args,
87+
};
88+
}
89+
90+
Deno.test("chpwd hook activates when cd-ing directly into subdir of devenv", async () => {
91+
const { stdout, stderr, recorded_args } = await run_hook_in_subdir();
92+
93+
// The hook must invoke our fake dev and run its emitted shellcode.
94+
assert(
95+
stdout.includes("HOOK_OK"),
96+
`expected hook to eval dev's stdout (HOOK_OK), got stdout=${
97+
JSON.stringify(stdout)
98+
} stderr=${JSON.stringify(stderr)}`,
99+
);
100+
101+
// Crucially: no "permission denied" from the shell trying to execute a
102+
// directory path as a command (the bug from issue #51).
103+
assert(
104+
!/permission denied/i.test(stderr),
105+
`unexpected 'permission denied' in stderr: ${stderr}`,
106+
);
107+
108+
// dev must have been invoked with the activated dir so it sniffs the
109+
// right place — not invoked bare while $PWD points at the subdir.
110+
assertEquals(
111+
recorded_args.length,
112+
1,
113+
`expected dev to be called with exactly one argument, got: ${
114+
JSON.stringify(recorded_args)
115+
}`,
116+
);
117+
assert(
118+
recorded_args[0].endsWith("/proj"),
119+
`expected dev to be called with the activated dir, got: ${
120+
recorded_args[0]
121+
}`,
122+
);
123+
});
124+
125+
Deno.test("datadir respects XDG_DATA_HOME", () => {
126+
const original = Deno.env.get("XDG_DATA_HOME");
127+
try {
128+
Deno.env.set("XDG_DATA_HOME", "/tmp/xdg-test");
129+
assertEquals(datadir().string, "/tmp/xdg-test/pkgx/dev");
130+
} finally {
131+
if (original === undefined) Deno.env.delete("XDG_DATA_HOME");
132+
else Deno.env.set("XDG_DATA_HOME", original);
133+
}
134+
});

src/shellcode().ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ _pkgx_chpwd_hook() {
1515
dir="$PWD"
1616
while [ "$dir" != / -a "$dir" != . ]; do
1717
if [ -f "${datadir()}/$dir/dev.pkgx.activated" ]; then
18-
eval "$(${dev_cmd})" "$dir"
18+
eval "$(${dev_cmd} "$dir")"
1919
break
2020
fi
2121
dir="$(dirname "$dir")"

0 commit comments

Comments
 (0)