diff --git a/install.sh b/install.sh index 28fe281e..bfbe9444 100644 --- a/install.sh +++ b/install.sh @@ -131,6 +131,31 @@ fetch_and_unpack() { unset _ref _url _tmp _src } +# ---- runtime dependencies ------------------------------------------------- +# moshcode was dependency-free ESM until 0.96.0, and this installer was built +# on that: the source tarball carries package.json and nothing under +# node_modules. Anything package.json lists has to be fetched here — or the +# CLI dies on its first import, which is exactly what 0.96.0 through 0.98.0 +# did for everyone who installed or upgraded through this script. +# +# Read with node rather than grep: "devDependencies" contains the word too, +# and a dev-only package.json must not drag npm in. +install_deps() { + _pkg="$MOSHCODE_HOME/package.json" + [ -f "$_pkg" ] || return 0 + if ! node -e 'const p=require(process.argv[1]);process.exit(Object.keys(p.dependencies||{}).length?0:1)' "$_pkg" 2>/dev/null; then + unset _pkg; return 0 + fi + command -v npm >/dev/null 2>&1 || fail "npm is required to install moshcode's dependencies (node was found, npm was not)." + info "installing runtime dependencies" + if ( cd "$MOSHCODE_HOME" && npm install --omit=dev --no-audit --no-fund --loglevel=error >/dev/null 2>&1 ); then + ok "dependencies installed" + else + fail "npm install failed in $MOSHCODE_HOME — moshcode would not start without its dependencies." + fi + unset _pkg +} + write_wrapper() { mkdir -p "$MOSHCODE_BIN" cat > "$WRAPPER" < { for (const d of scratch.splice(0)) rmSync(d, { recursive: true, force: true }); }; + +/** A release tarball the way codeload serves one: moshcode-/ on top. */ +function tarball(root, pkg) { + const src = join(root, "moshcode-v9.9.9"); + mkdirSync(join(src, "bin"), { recursive: true }); + writeFileSync(join(src, "bin", "moshcode.mjs"), "console.log('fake moshcode');\n"); + writeFileSync(join(src, "package.json"), JSON.stringify(pkg, null, 2)); + const file = join(root, "moshcode.tgz"); + execFileSync("tar", ["-czf", file, "-C", root, "moshcode-v9.9.9"]); + return file; +} + +function shadow(bin, name, body) { + writeFileSync(join(bin, name), `#!/bin/sh\n${body}\n`); + chmodSync(join(bin, name), 0o755); +} + +/** + * Run `sh install.sh install` in a fresh HOME. `npmExit` is what the shadowed + * npm returns; `pkg` is the package.json the fixture tarball carries. + */ +function runInstall({ pkg, npmExit = 0 }) { + const root = mkdtempSync(join(tmpdir(), "moshcode-deps-")); + scratch.push(root); + const home = join(root, "home"); + const bin = join(root, "bin"); + mkdirSync(home); mkdirSync(bin); + const tgz = tarball(root, pkg); + const record = join(root, "npm-args.txt"); + shadow(bin, "curl", `case "$*" in + *api.github.com*) printf '{"tag_name":"v9.9.9"}' ;; + *codeload.github.com*) cat "${tgz}" ;; + *) exit 22 ;; +esac`); + shadow(bin, "npm", `printf '%s\\n%s\\n' "$PWD" "$*" > "${record}"; exit ${npmExit}`); + // Node itself first, by its real path: a version manager's shim would try to + // resolve a Node for the fresh HOME and fail before install.sh even runs. + const PATH = `${bin}:${dirname(process.execPath)}:${process.env.PATH}`; + let code = 0, output = ""; + try { + output = execFileSync("sh", [INSTALL_SH, "install"], { + env: { PATH, HOME: home, MOSHCODE_HOME: join(home, ".moshcode"), MOSHCODE_NO_PROXY: "1", NO_COLOR: "1" }, + encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], + }); + } catch (error) { + code = error.status; + output = `${error.stdout ?? ""}${error.stderr ?? ""}`; + } + const npm = existsSync(record) ? readFileSync(record, "utf8").split("\n") : null; + return { code, output, home, npm }; +} + +test("a package.json with dependencies gets `npm install --omit=dev` inside MOSHCODE_HOME", () => { + try { + const r = runInstall({ pkg: { name: "moshcode", version: "9.9.9", dependencies: { "@profullstack/synconfig": "^0.1.1" } } }); + assert.equal(r.code, 0, r.output); + assert.ok(r.npm, "npm was never called — the CLI would die on its first import"); + assert.equal(realpathSync(r.npm[0]), realpathSync(join(r.home, ".moshcode")), "dependencies must land in the install dir"); + assert.match(r.npm[1], /^install --omit=dev\b/); + assert.match(r.output, /dependencies installed/); + } finally { cleanup(); } +}); + +test("a dependency-free package.json never reaches for npm", () => { + // devDependencies alone must not count: the word "dependencies" is in it. + try { + const r = runInstall({ pkg: { name: "moshcode", version: "9.9.9", devDependencies: { "some-linter": "1.0.0" } } }); + assert.equal(r.code, 0, r.output); + assert.equal(r.npm, null, "npm was called for a package with nothing to install"); + assert.doesNotMatch(r.output, /runtime dependencies/); + } finally { cleanup(); } +}); + +test("a failed npm install fails the install loudly instead of leaving a CLI that cannot start", () => { + try { + const r = runInstall({ pkg: { name: "moshcode", version: "9.9.9", dependencies: { "@profullstack/synconfig": "^0.1.1" } }, npmExit: 1 }); + assert.notEqual(r.code, 0, "install.sh reported success with the dependencies missing"); + assert.match(r.output, /npm install failed/); + assert.match(r.output, /would not start/); + } finally { cleanup(); } +});