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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
"ts-migration:guard": "tsx scripts/check-legacy-migrated-paths.ts",
"type-safety:hotspots": "tsx scripts/type-safety-hotspots.ts",
"bump:version": "tsx scripts/bump-version.ts",
"prepare": "if command -v tsc >/dev/null 2>&1 || [ -x node_modules/.bin/tsc ]; then npm run build:cli; fi && (npm install --omit=dev --ignore-scripts 2>/dev/null || true) && if [ -d .git ]; then if [ -z \"${NEMOCLAW_INSTALLING:-}\" ]; then NEMOCLAW_INSTALLING=1 npm link 2>/dev/null || true; fi; if command -v prek >/dev/null 2>&1; then prek install; else echo \"Skipping git hook setup (prek not installed)\"; fi; fi",
"prepare": "if command -v tsc >/dev/null 2>&1 || [ -x node_modules/.bin/tsc ]; then npm run build:cli; fi && (npm install --omit=dev --ignore-scripts 2>/dev/null || true) && if [ -d .git ]; then bash scripts/npm-link-or-shim.sh; if command -v prek >/dev/null 2>&1; then prek install; else echo \"Skipping git hook setup (prek not installed)\"; fi; fi",
"prepublishOnly": "git describe --tags --match 'v*' | sed 's/^v//' > .version && test -s .version && cd nemoclaw && env -u npm_config_global -u npm_config_prefix -u npm_config_omit npm install --ignore-scripts && ./node_modules/.bin/tsc"
},
"dependencies": {
Expand Down
88 changes: 88 additions & 0 deletions scripts/npm-link-or-shim.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
#!/usr/bin/env bash
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
#
# Dev-install helper invoked by `npm install` via the package.json `prepare`
# script. Runs `npm link` so the local checkout exposes the `nemoclaw` CLI,
# and on failure falls back to a user-local wrapper at ~/.local/bin/nemoclaw.
# The wrapper preserves the Node directory that was on PATH at install time
# so the shim still works in shells where Node is provisioned via nvm and
# may not be on a fresh login PATH (matches scripts/install.sh's wrapper).

set -eu

SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd -- "$SCRIPT_DIR/.." && pwd)"
BIN_PATH="$REPO_ROOT/bin/nemoclaw.js"
SHIM_DIR="${HOME}/.local/bin"
SHIM_PATH="$SHIM_DIR/nemoclaw"
SHIM_MARKER="# NemoClaw dev-shim - managed by scripts/npm-link-or-shim.sh"

if [ -n "${NEMOCLAW_INSTALLING:-}" ]; then
exit 0
fi
export NEMOCLAW_INSTALLING=1

if [ ! -x "$BIN_PATH" ]; then
printf '[nemoclaw] cannot expose CLI: %s is missing or not executable\n' "$BIN_PATH" >&2
exit 0
fi

LINK_LOG="$(mktemp -t nemoclaw-link.XXXXXX.log 2>/dev/null || mktemp)"
trap 'rm -f "$LINK_LOG"' EXIT

if (cd "$REPO_ROOT" && npm link >"$LINK_LOG" 2>&1); then
exit 0
fi

printf '[nemoclaw] npm link failed; falling back to user-local shim.\n' >&2
if [ -s "$LINK_LOG" ]; then
sed 's/^/[nemoclaw] /' "$LINK_LOG" >&2
fi

NODE_PATH="$(command -v node 2>/dev/null || true)"
if [ -z "$NODE_PATH" ] || [ ! -x "$NODE_PATH" ]; then
printf '[nemoclaw] cannot create shim: node is not on PATH\n' >&2
exit 1
fi
NODE_DIR="$(cd -- "$(dirname -- "$NODE_PATH")" && pwd)"

if [ -e "$SHIM_PATH" ] || [ -L "$SHIM_PATH" ]; then
if ! grep -qFx "$SHIM_MARKER" "$SHIM_PATH" 2>/dev/null; then
printf '[nemoclaw] %s already exists and is not managed by NemoClaw; not overwriting.\n' "$SHIM_PATH" >&2
printf "[nemoclaw] Move it aside and re-run 'npm install' to install the dev shim.\n" >&2
exit 1
fi
fi

mkdir -p "$SHIM_DIR"

# Write to a sibling tempfile and rename so a mid-write failure (disk full,
# etc.) cannot leave an unrecognisable partial shim that the marker check
# would later refuse to overwrite.
SHIM_TMP="$(mktemp "$SHIM_DIR/nemoclaw.tmp.XXXXXX")"
trap 'rm -f "$LINK_LOG" "$SHIM_TMP"' EXIT

cat >"$SHIM_TMP" <<EOF
#!/usr/bin/env bash
$SHIM_MARKER
export PATH="$NODE_DIR:\$PATH"
exec "$BIN_PATH" "\$@"
EOF
chmod +x "$SHIM_TMP"
mv -f "$SHIM_TMP" "$SHIM_PATH"

if [ ! -x "$SHIM_PATH" ]; then
printf '[nemoclaw] shim creation failed: %s is not executable after write\n' "$SHIM_PATH" >&2
exit 1
fi

printf '[nemoclaw] Created user-local shim at %s -> %s\n' "$SHIM_PATH" "$BIN_PATH" >&2

case ":${PATH:-}:" in
*":$SHIM_DIR:"*) ;;
*)
printf '[nemoclaw] %s is not on PATH. Add it to your shell profile, e.g.:\n' "$SHIM_DIR" >&2
printf "[nemoclaw] echo 'export PATH=\"%s:\$PATH\"' >> ~/.bashrc\n" "$SHIM_DIR" >&2
;;
esac
173 changes: 173 additions & 0 deletions test/npm-link-or-shim.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { spawnSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, it, expect } from "vitest";

const repoRoot = path.join(import.meta.dirname, "..");
const scriptUnderTest = path.join(repoRoot, "scripts", "npm-link-or-shim.sh");
const SHIM_MARKER = "# NemoClaw dev-shim - managed by scripts/npm-link-or-shim.sh";

function setupFakeRepo(): { tmpDir: string; repoDir: string; homeDir: string; fakeBin: string } {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-link-shim-"));
const repoDir = path.join(tmpDir, "repo");
const homeDir = path.join(tmpDir, "home");
const fakeBin = path.join(tmpDir, "bin");

fs.mkdirSync(path.join(repoDir, "bin"), { recursive: true });
fs.mkdirSync(path.join(repoDir, "scripts"), { recursive: true });
fs.mkdirSync(homeDir, { recursive: true });
fs.mkdirSync(fakeBin, { recursive: true });

fs.writeFileSync(
path.join(repoDir, "bin", "nemoclaw.js"),
"#!/usr/bin/env node\nconsole.log('nemoclaw stub ok');\n",
{ mode: 0o755 },
);
fs.copyFileSync(scriptUnderTest, path.join(repoDir, "scripts", "npm-link-or-shim.sh"));
fs.chmodSync(path.join(repoDir, "scripts", "npm-link-or-shim.sh"), 0o755);

return { tmpDir, repoDir, homeDir, fakeBin };
}

function writeFakeNpm(fakeBin: string, behaviour: "succeed" | "fail"): void {
const failBody =
behaviour === "fail"
? "echo 'npm error code EACCES' >&2\necho 'npm error syscall symlink' >&2\nexit 1\n"
: "exit 0\n";
fs.writeFileSync(
path.join(fakeBin, "npm"),
`#!/usr/bin/env bash\nif [ "\${1:-}" = "link" ]; then\n${failBody}fi\nexit 0\n`,
{ mode: 0o755 },
);
}

function runScript(spec: { repoDir: string; homeDir: string; fakeBin: string }): {
status: number;
stdout: string;
stderr: string;
} {
const result = spawnSync("bash", [path.join(spec.repoDir, "scripts", "npm-link-or-shim.sh")], {
cwd: spec.repoDir,
encoding: "utf-8",
env: {
PATH: `${spec.fakeBin}:${process.env.PATH || ""}`,
HOME: spec.homeDir,
},
});
return {
status: typeof result.status === "number" ? result.status : -1,
stdout: result.stdout ?? "",
stderr: result.stderr ?? "",
};
}

describe("npm-link-or-shim.sh", () => {
it("falls back to a wrapper script on npm link failure, preserving the Node directory", () => {
const { repoDir, homeDir, fakeBin } = setupFakeRepo();
writeFakeNpm(fakeBin, "fail");

const result = runScript({ repoDir, homeDir, fakeBin });

expect(result.status).toBe(0);
const shimPath = path.join(homeDir, ".local", "bin", "nemoclaw");
expect(fs.existsSync(shimPath)).toBe(true);
const stat = fs.lstatSync(shimPath);
expect(stat.isSymbolicLink()).toBe(false);
expect(stat.isFile()).toBe(true);
expect((stat.mode & 0o111) !== 0).toBe(true);

const contents = fs.readFileSync(shimPath, "utf-8");
expect(contents).toContain(SHIM_MARKER);
expect(contents).toContain(`exec "${path.join(repoDir, "bin", "nemoclaw.js")}"`);
expect(contents).toMatch(/export PATH="[^"]+:\$PATH"/);

expect(result.stderr).toContain("npm link failed");
expect(result.stderr).toContain("Created user-local shim");
expect(result.stderr).toContain("EACCES");
});

it("does not create a shim when npm link succeeds", () => {
const { repoDir, homeDir, fakeBin } = setupFakeRepo();
writeFakeNpm(fakeBin, "succeed");

const result = runScript({ repoDir, homeDir, fakeBin });

expect(result.status).toBe(0);
expect(fs.existsSync(path.join(homeDir, ".local", "bin", "nemoclaw"))).toBe(false);
expect(result.stderr).toBe("");
});

it("is a no-op when NEMOCLAW_INSTALLING is already set (avoids prepare-script recursion)", () => {
const { repoDir, homeDir, fakeBin } = setupFakeRepo();
writeFakeNpm(fakeBin, "fail");

const result = spawnSync("bash", [path.join(repoDir, "scripts", "npm-link-or-shim.sh")], {
cwd: repoDir,
encoding: "utf-8",
env: {
PATH: `${fakeBin}:${process.env.PATH || ""}`,
HOME: homeDir,
NEMOCLAW_INSTALLING: "1",
},
});

expect(result.status).toBe(0);
expect(fs.existsSync(path.join(homeDir, ".local", "bin", "nemoclaw"))).toBe(false);
expect(result.stderr ?? "").toBe("");
});

it("refuses to overwrite a foreign file at the shim path and exits non-zero", () => {
const { repoDir, homeDir, fakeBin } = setupFakeRepo();
writeFakeNpm(fakeBin, "fail");

const shimDir = path.join(homeDir, ".local", "bin");
fs.mkdirSync(shimDir, { recursive: true });
const shimPath = path.join(shimDir, "nemoclaw");
fs.writeFileSync(shimPath, "user-script\n", { mode: 0o755 });

const result = runScript({ repoDir, homeDir, fakeBin });

expect(result.status).not.toBe(0);
expect(fs.readFileSync(shimPath, "utf-8")).toBe("user-script\n");
expect(result.stderr).toContain("not managed by NemoClaw");
});

it("refreshes an existing NemoClaw-managed shim (idempotent re-run)", () => {
const { repoDir, homeDir, fakeBin } = setupFakeRepo();
writeFakeNpm(fakeBin, "fail");

const shimDir = path.join(homeDir, ".local", "bin");
fs.mkdirSync(shimDir, { recursive: true });
const shimPath = path.join(shimDir, "nemoclaw");
fs.writeFileSync(shimPath, `#!/usr/bin/env bash\n${SHIM_MARKER}\nexec /old/stale/path "$@"\n`, {
mode: 0o755,
});

const result = runScript({ repoDir, homeDir, fakeBin });

expect(result.status).toBe(0);
const refreshed = fs.readFileSync(shimPath, "utf-8");
expect(refreshed).toContain(SHIM_MARKER);
expect(refreshed).toContain(`exec "${path.join(repoDir, "bin", "nemoclaw.js")}"`);
expect(refreshed).not.toContain("/old/stale/path");
expect(result.stderr).toContain("Created user-local shim");
});

it("fails clearly without claiming success when ~/.local exists as a regular file", () => {
const { repoDir, homeDir, fakeBin } = setupFakeRepo();
writeFakeNpm(fakeBin, "fail");

fs.writeFileSync(path.join(homeDir, ".local"), "not-a-directory\n");

const result = runScript({ repoDir, homeDir, fakeBin });

expect(result.status).not.toBe(0);
expect(fs.existsSync(path.join(homeDir, ".local", "bin", "nemoclaw"))).toBe(false);
expect(result.stderr).not.toContain("Created user-local shim");
expect(fs.readFileSync(path.join(homeDir, ".local"), "utf-8")).toBe("not-a-directory\n");
});
});
28 changes: 28 additions & 0 deletions test/uninstall.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,34 @@ describe("uninstall helpers", () => {
expect(fs.existsSync(shimPath)).toBe(false);
});

it("removes a dev-install shim written by scripts/npm-link-or-shim.sh", () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-uninstall-dev-shim-"));
const shimDir = path.join(tmp, ".local", "bin");
const shimPath = path.join(shimDir, "nemoclaw");

fs.mkdirSync(shimDir, { recursive: true });
fs.writeFileSync(
shimPath,
[
"#!/usr/bin/env bash",
"# NemoClaw dev-shim - managed by scripts/npm-link-or-shim.sh",
'export PATH="/tmp/node-bin:$PATH"',
'exec "/tmp/checkout/bin/nemoclaw.js" "$@"',
"",
].join("\n"),
{ mode: 0o755 },
);

const result = spawnSync("bash", ["-c", `source "${UNINSTALL_SCRIPT}"; remove_nemoclaw_cli`], {
cwd: path.join(import.meta.dirname, ".."),
encoding: "utf-8",
env: createFakeNpmEnv(tmp),
});

expect(result.status).toBe(0);
expect(fs.existsSync(shimPath)).toBe(false);
});

it("preserves a wrapper-like shim when extra content is appended", () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-uninstall-wrapper-extra-"));
const shimDir = path.join(tmp, ".local", "bin");
Expand Down
13 changes: 13 additions & 0 deletions uninstall.sh
Original file line number Diff line number Diff line change
Expand Up @@ -416,6 +416,19 @@ is_installer_managed_nemoclaw_shim() {
return 0
;;
esac

# Dev-install shim from scripts/npm-link-or-shim.sh — wraps bin/nemoclaw.js
# in the source checkout instead of an npm-linked binary. Same wrapper
# shape as above plus a marker line so we can distinguish it from
# user-managed files.
local dev_marker_line="# NemoClaw dev-shim - managed by scripts/npm-link-or-shim.sh"
local dev_exec_suffix="\" \"\$@\""
case "$contents" in
'#!/usr/bin/env bash'$'\n'"$dev_marker_line"$'\n'"$path_line"*"$path_suffix"$'\n'"$exec_line"*"$dev_exec_suffix")
return 0
;;
esac

return 1
}

Expand Down
Loading