diff --git a/install.sh b/install.sh index 07c386b4..35860cb8 100644 --- a/install.sh +++ b/install.sh @@ -10,10 +10,10 @@ # curl -fsSL https://raw.githubusercontent.com/moshcoder/moshcode/main/install.sh | sh # # What it does — dead simple, no build step: -# 1. Checks for Node.js 18+ (moshcode is zero-dependency ESM — needs a node). +# 1. Checks for Node.js 18+ and npm (for runtime dependencies). # 2. Downloads the latest release tarball of moshcoder/moshcode from GitHub # (falls back to the main branch if no release is published yet). -# 3. Unpacks it to $MOSHCODE_HOME (default: $HOME/.moshcode). +# 3. Installs runtime dependencies and checks startup before replacing the CLI. # 4. Drops a `moshcode` wrapper at $MOSHCODE_BIN (default: $HOME/.local/bin) # that just exec's `node $MOSHCODE_HOME/bin/moshcode.mjs "$@"`. # 5. Ensures that bin dir is on your PATH. @@ -143,6 +143,14 @@ fetch_and_unpack() { # Tarball extracts to a single top-level dir (e.g. moshcode-main/). _src="$(find "$_tmp" -maxdepth 1 -type d -name 'moshcode-*' | head -1)" [ -n "$_src" ] && [ -f "$_src/bin/moshcode.mjs" ] || { rm -rf "$_tmp"; fail "unexpected tarball layout."; } + # GitHub archives contain source, not node_modules. Prepare and check the + # new release before removing a working installation. + if ! install_deps "$_src"; then + rm -rf "$_tmp"; fail "runtime dependency installation failed — existing installation unchanged." + fi + if ! node "$_src/bin/moshcode.mjs" --version >/dev/null; then + rm -rf "$_tmp"; fail "CLI startup check failed — existing installation unchanged." + fi rm -rf "$MOSHCODE_HOME" mkdir -p "$(dirname "$MOSHCODE_HOME")" mv "$_src" "$MOSHCODE_HOME" @@ -161,21 +169,23 @@ fetch_and_unpack() { # # 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" +install_deps() ( + # Run in a subshell so temporary paths cannot change the caller's install + # location. Only the checked staging directory is passed here. + _deps_dir="$1" + _pkg="$_deps_dir/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 + _needs_deps="$(node -e 'const p=require(process.argv[1]);console.log(Object.keys(p.dependencies||{}).length ? "yes" : "no")' "$_pkg")" \ + || fail "cannot read the staged package.json." + [ "$_needs_deps" = yes ] || return 0 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 + if (cd "$_deps_dir" && npm install --omit=dev --ignore-scripts --no-audit --no-fund --package-lock=false); then ok "dependencies installed" else - fail "npm install failed in $MOSHCODE_HOME — moshcode would not start without its dependencies." + fail "npm install failed in staging — the new CLI would not start without its dependencies." fi - unset _pkg -} +) write_wrapper() { mkdir -p "$MOSHCODE_BIN" @@ -276,7 +286,6 @@ run_install() { check_node _ref="$(resolve_ref)" fetch_and_unpack "$_ref" - install_deps write_wrapper ensure_path install_proxy diff --git a/package.json b/package.json index 9427e76c..d0a355d8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "moshcode", - "version": "0.98.0", + "version": "0.98.1", "type": "module", "description": "moshcode — a metal wrapper for coding engines and native UGig/CoinPay workflow CLIs, with OpenPRD and moshscript", "repository": { diff --git a/test/install-dependencies.test.mjs b/test/install-dependencies.test.mjs new file mode 100644 index 00000000..b69599c7 --- /dev/null +++ b/test/install-dependencies.test.mjs @@ -0,0 +1,93 @@ +import assert from "node:assert/strict"; +import { execFileSync, spawnSync } from "node:child_process"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; + +const source = readFileSync(new URL("../install.sh", import.meta.url), "utf8"); +// Run the real payload installer without provisioning runtimes, shell rc files, +// or the optional proxy. Downloads and npm are local fixtures; node/tar are real. +const installer = source.slice(0, source.indexOf('\nCMD=')) + '\nfetch_and_unpack "$MOSHCODE_REF"\n'; +const nestedPackage = false; + +function fixture(t, { existing = false, npmMode = "install" } = {}) { + const dir = mkdtempSync(join(tmpdir(), "moshcode-dependencies-")); + t.after(() => rmSync(dir, { recursive: true, force: true })); + const home = join(dir, "install with spaces"); + const pkg = nestedPackage ? join(home, "pkg") : home; + const bin = join(dir, "fake-bin"); + const release = join(dir, "moshcode-fixture"); + mkdirSync(bin); + mkdirSync(join(release, "bin"), { recursive: true }); + writeFileSync(join(release, "package.json"), JSON.stringify({ + name: "moshcode", version: "0.97.0", type: "module", + dependencies: { "@profullstack/synconfig": "^0.1.1" }, + })); + writeFileSync(join(release, "bin/moshcode.mjs"), + 'import { SNAPSHOT_VERSION } from "@profullstack/synconfig";\nconsole.log(`0.97.0 sync=${SNAPSHOT_VERSION}`);\n'); + const archive = join(dir, "release.tar.gz"); + execFileSync("tar", ["-czf", archive, "-C", dir, "moshcode-fixture"]); + writeFileSync(join(bin, "curl"), '#!/bin/sh\ncat "$TEST_ARCHIVE"\n', { mode: 0o755 }); + writeFileSync(join(bin, "npm"), `#!/bin/sh +printf '%s\n' "$@" > "$TEST_NPM_ARGS" +[ "$TEST_NPM_MODE" != fail ] || exit 42 +[ "$TEST_NPM_MODE" != skip ] || exit 0 +mkdir -p node_modules/@profullstack/synconfig +printf '%s' '{"type":"module","exports":"./index.js"}' > node_modules/@profullstack/synconfig/package.json +printf '%s' 'export const SNAPSHOT_VERSION = 1;' > node_modules/@profullstack/synconfig/index.js +`, { mode: 0o755 }); + if (existing) { + mkdirSync(join(pkg, "bin"), { recursive: true }); + writeFileSync(join(pkg, "bin/moshcode.mjs"), 'console.log("old working CLI");\n'); + writeFileSync(join(pkg, "keep-until-success"), "previous installation"); + } + const npmArgs = join(dir, "npm-args"); + const result = spawnSync("sh", ["-s"], { + input: installer, + encoding: "utf8", + env: { + ...process.env, + PATH: `${bin}:${process.env.PATH}`, + MOSHCODE_HOME: home, + MOSHCODE_BIN: join(dir, "wrappers"), + MOSHCODE_REF: "fixture", + TEST_ARCHIVE: archive, + TEST_NPM_ARGS: npmArgs, + TEST_NPM_MODE: npmMode, + TMPDIR: dir, + NO_COLOR: "1", + }, + timeout: 15000, + }); + return { result, pkg, npmArgs }; +} + +test("a fresh archive installs runtime dependencies before its first startup", (t) => { + const { result, pkg, npmArgs } = fixture(t); + assert.equal(result.status, 0, result.stdout + result.stderr); + assert.equal(execFileSync(process.execPath, [join(pkg, "bin/moshcode.mjs"), "--version"], { encoding: "utf8" }).trim(), "0.97.0 sync=1"); + const args = readFileSync(npmArgs, "utf8").trim().split("\n"); + assert.equal(args[0], "install"); + assert.ok(args.includes("--omit=dev")); + assert.ok(args.includes("--ignore-scripts")); + assert.ok(args.includes("--package-lock=false")); +}); + +test("an update replaces the old payload only after dependencies and startup succeed", (t) => { + const { result, pkg } = fixture(t, { existing: true }); + assert.equal(result.status, 0, result.stdout + result.stderr); + assert.equal(existsSync(join(pkg, "keep-until-success")), false); + assert.match(execFileSync(process.execPath, [join(pkg, "bin/moshcode.mjs")], { encoding: "utf8" }), /0\.97\.0 sync=1/); +}); + +for (const [npmMode, expected] of [["fail", /runtime dependency installation failed/], ["skip", /CLI startup check failed/]]) { + test(`${npmMode === "fail" ? "a registry failure" : "an unresolved dependency after npm succeeds"} preserves the installed CLI`, (t) => { + const { result, pkg } = fixture(t, { existing: true, npmMode }); + assert.notEqual(result.status, 0); + assert.match(result.stderr, expected); + assert.match(result.stderr, /existing installation unchanged/); + assert.equal(readFileSync(join(pkg, "keep-until-success"), "utf8"), "previous installation"); + assert.equal(execFileSync(process.execPath, [join(pkg, "bin/moshcode.mjs")], { encoding: "utf8" }).trim(), "old working CLI"); + }); +} diff --git a/test/install-deps.test.mjs b/test/install-deps.test.mjs index 037adbd1..ebfe533a 100644 --- a/test/install-deps.test.mjs +++ b/test/install-deps.test.mjs @@ -7,7 +7,7 @@ // 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 { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import test from "node:test"; @@ -68,12 +68,13 @@ esac`); return { code, output, home, npm }; } -test("a package.json with dependencies gets `npm install --omit=dev` inside MOSHCODE_HOME", () => { +test("a package.json with dependencies gets `npm install --omit=dev` in staging before replacement", () => { 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[0], /moshcode-v9\.9\.9$/, "dependencies must be prepared in the extracted release"); + assert.notEqual(r.npm[0], join(r.home, ".moshcode"), "npm must not mutate the active install"); assert.match(r.npm[1], /^install --omit=dev\b/); assert.match(r.output, /dependencies installed/); } finally { cleanup(); }