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
28 changes: 28 additions & 0 deletions scripts/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -1658,9 +1658,37 @@ main() {
NON_INTERACTIVE="${NON_INTERACTIVE:-${NEMOCLAW_NON_INTERACTIVE:-}}"
ACCEPT_THIRD_PARTY_SOFTWARE="${ACCEPT_THIRD_PARTY_SOFTWARE:-${NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE:-}}"
FRESH="${FRESH:-${NEMOCLAW_FRESH:-}}"

# If the user explicitly accepted the third-party-software notice, treat
# that as non-interactive intent for the rest of the run too — show_usage_notice
# is only one of several phase-3 steps that need a TTY or --non-interactive
# (run_onboard has the same gate). Without this, ACCEPT_THIRD_PARTY_SOFTWARE=1
# alone clears the preflight below but the install can still partial-fail at
# run_onboard with the same TTY error, leaving phases 1/2 on disk anyway.
if [ "${ACCEPT_THIRD_PARTY_SOFTWARE:-}" = "1" ] && [ "${NON_INTERACTIVE:-}" != "1" ]; then
NON_INTERACTIVE=1
fi

export NEMOCLAW_NON_INTERACTIVE="${NON_INTERACTIVE}"
export NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE="${ACCEPT_THIRD_PARTY_SOFTWARE}"

# Fail-fast license-acceptance check (#2671). If we already know phase 3
# (show_usage_notice + run_onboard) will hit the "requires a TTY" branch,
# surface that error NOW — before phases 1/2 install Node.js and put the
# nemoclaw CLI on PATH. Otherwise the user is left in a partial install
# that they have to manually `rm -rf` before retry, while their license
# has not actually been accepted.
#
# Skipped (and the install proceeds) when any of:
# - NON_INTERACTIVE=1 (also implied by ACCEPT_THIRD_PARTY_SOFTWARE=1 above)
# - stdin is a TTY — license helper prompts the user directly
# - /dev/tty is openable — show_usage_notice falls back to /dev/tty input
if [ "${NON_INTERACTIVE:-}" != "1" ] \
&& [ ! -t 0 ] \
&& ! (: </dev/tty) 2>/dev/null; then
error "Interactive third-party software acceptance requires a TTY. Re-run in a terminal or pass --yes-i-accept-third-party-software (or set NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE=1)."
fi

_INSTALL_START=$SECONDS
print_banner
bash "${SCRIPT_DIR}/setup-jetson.sh"
Expand Down
97 changes: 97 additions & 0 deletions test/install-preflight.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,9 @@ exit 1
...process.env,
HOME: tmp,
PATH: `${fakeBin}:${TEST_SYSTEM_PATH}`,
// Bypass the #2671 fail-fast license gate — this test exercises the
// Node-version-detection / nvm-upgrade path, not the license path.
NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE: "1",
},
});

Expand Down Expand Up @@ -2905,3 +2908,97 @@ exit 0`,
expect(`${result.stdout}${result.stderr}`).not.toMatch(/Cannot find module .*usage-notice\.js/);
});
});

describe("installer atomicity (#2671)", () => {
/**
* Run scripts/install.sh main() with stubbed phase-1 and phase-2 binaries
* that record invocation to a marker file. Tests assert whether install
* reaches phase 1/2 or short-circuits at the fail-fast license gate.
*/
function runInstaller(env: Record<string, string | undefined>, options: { stdinIsTty?: boolean } = {}) {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-install-2671-"));
const fakeBin = path.join(tmp, "bin");
const phaseLog = path.join(tmp, "phases.log");
fs.mkdirSync(fakeBin);

// Stub node + npm — both record their own invocation so we can detect
// whether phase 1 (install_nodejs) or phase 2 (install_nemoclaw) ran.
writeExecutable(
path.join(fakeBin, "node"),
`#!/usr/bin/env bash
echo "node $*" >> ${JSON.stringify(phaseLog)}
if [ "$1" = "-v" ] || [ "$1" = "--version" ]; then echo "v22.16.0"; exit 0; fi
if [ -n "\${1:-}" ] && [ -f "$1" ]; then exit 0; fi
exit 0`,
);
writeExecutable(
path.join(fakeBin, "npm"),
`#!/usr/bin/env bash
echo "npm $*" >> ${JSON.stringify(phaseLog)}
if [ "$1" = "--version" ]; then echo "10.9.2"; exit 0; fi
if [ "$1" = "config" ] && [ "$2" = "get" ] && [ "$3" = "prefix" ]; then echo "${path.join(tmp, "prefix")}"; exit 0; fi
exit 0`,
);
writeExecutable(
path.join(fakeBin, "docker"),
`#!/usr/bin/env bash
echo "docker $*" >> ${JSON.stringify(phaseLog)}
exit 0`,
);

// Run main() directly via the bash entrypoint check. We force stdin to
// /dev/null when stdinIsTty is false (default — simulates curl|bash).
const result = spawnSync(
"bash",
[INSTALLER_PAYLOAD],
{
cwd: tmp,
encoding: "utf-8",
// input: "" makes spawnSync attach a non-TTY stdin pipe — equivalent
// to curl|bash for the purposes of [ -t 0 ] and /dev/tty in CI.
input: options.stdinIsTty ? undefined : "",
env: {
HOME: tmp,
PATH: `${fakeBin}:${TEST_SYSTEM_PATH}`,
...env,
},
},
);
const phases = fs.existsSync(phaseLog) ? fs.readFileSync(phaseLog, "utf-8") : "";
return { result, phases, tmp };
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

it("#2671: curl|bash with no flags exits 1 BEFORE phase 1 (atomic — no Node/CLI install)", () => {
const { result, phases } = runInstaller({});
expect(result.status).not.toBe(0);
const output = `${result.stdout}${result.stderr}`;
expect(output).toMatch(/Interactive third-party software acceptance requires a TTY/);
expect(output).toMatch(/--yes-i-accept-third-party-software/);
// Phase 1 (Node.js install) and phase 2 (CLI install) must NOT have run —
// the whole point of the fix is that a license-fail leaves no half-install behind.
expect(output).not.toMatch(/\[1\/3\] Node\.js/);
expect(output).not.toMatch(/\[2\/3\] NemoClaw CLI/);
// Stub binaries record every invocation; if phase 1 or 2 ran, node and/or
// npm would have been called. The fail-fast check runs before either.
expect(phases).toBe("");
});

it("--yes-i-accept-third-party-software alone is sufficient to clear the fail-fast gate", () => {
// The flag implies non-interactive intent (set by main() before the
// preflight check), so it must clear the gate AND let the install
// progress past preflight into phase 1 — assert phases is non-empty
// so the test doesn't false-pass if the install bailed for some other
// reason while the TTY error happened to be absent from output.
const { result, phases } = runInstaller({ NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE: "1" });
const output = `${result.stdout}${result.stderr}`;
expect(output).not.toMatch(/Interactive third-party software acceptance requires a TTY/);
expect(phases).not.toBe("");
});

it("--non-interactive alone is sufficient to clear the fail-fast gate", () => {
const { result, phases } = runInstaller({ NEMOCLAW_NON_INTERACTIVE: "1" });
const output = `${result.stdout}${result.stderr}`;
expect(output).not.toMatch(/Interactive third-party software acceptance requires a TTY/);
expect(phases).not.toBe("");
});
});
Loading