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
26 changes: 26 additions & 0 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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" <<WRAP_EOF
Expand Down Expand Up @@ -230,6 +255,7 @@ run_install() {
check_node
_ref="$(resolve_ref)"
fetch_and_unpack "$_ref"
install_deps
write_wrapper
ensure_path
install_proxy
Expand Down
99 changes: 99 additions & 0 deletions test/install-deps.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
// install.sh and runtime dependencies. moshcode was dependency-free ESM until
// 0.96.0 and the installer was built on that: it unpacks the source tarball and
// stops. From 0.96.0 package.json lists @profullstack/synconfig, the tarball
// carries no node_modules, and every install or upgrade through this script
// produced a CLI that died on its first import. These tests drive the real
// install.sh against a tarball built here, with curl and npm shadowed on PATH:
// curl serves the fixture, npm records how it was called.
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import test from "node:test";
import { fileURLToPath } from "node:url";

const INSTALL_SH = fileURLToPath(new URL("../install.sh", import.meta.url));
const scratch = [];
const cleanup = () => { for (const d of scratch.splice(0)) rmSync(d, { recursive: true, force: true }); };

/** A release tarball the way codeload serves one: moshcode-<ref>/ 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(); }
});
Loading