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
38 changes: 23 additions & 15 deletions public/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,7 @@
# resolved at run time, so re-running moves them up to newer releases.
# 4. Fetches the CLI straight from the public GitHub repo
# (github.com/moshcoder/moshcode) into $MOSHCODE_HOME/pkg. moshcode
# is ESM with one runtime dependency since 0.96.0; npm installs it
# into the package dir after the tarball is unpacked (install_deps).
# needs its runtime dependencies installed with npm before it can start.
# 5. Drops a wrapper at $HOME/.local/bin/moshcode that runs the CLI
# via node and handles update|upgrade|remove|uninstall.
#
Expand Down Expand Up @@ -214,7 +213,7 @@ resolve_ref() {
}

# ---------------------------------------------------------------------------
# install the CLI from GitHub, then its runtime dependencies (install_deps)
# install the CLI from GitHub and its runtime dependencies from npm
# ---------------------------------------------------------------------------
install_cli() {
for _t in curl tar node; do
Expand All @@ -234,6 +233,14 @@ install_cli() {
if [ -z "$_src" ] || [ ! -f "$_src/bin/moshcode.mjs" ]; then
rm -rf "$_tmp"; fail "bin/moshcode.mjs not found in tarball"
fi
# An archive has no node_modules. Install and verify in staging so a
# registry failure or a broken release leaves the current CLI runnable.
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 "$PKG_DIR.new"; mkdir -p "$PKG_DIR.new"
( cd "$_src" && tar -cf - . ) | ( cd "$PKG_DIR.new" && tar -xf - )
rm -rf "$_tmp"
Expand All @@ -242,34 +249,35 @@ install_cli() {
[ -d "$PKG_DIR" ] && mv "$PKG_DIR" "$PKG_DIR.old"
mv "$PKG_DIR.new" "$PKG_DIR"
rm -rf "$PKG_DIR.old"
install_deps
_ver="$(node -p "require('$PKG_DIR/package.json').version" 2>/dev/null || echo '?')"
ok "moshcode@$_ver installed to $PKG_DIR"
}

# ---------------------------------------------------------------------------
# runtime dependencies (moshcode stopped being dependency-free in 0.96.0)
# ---------------------------------------------------------------------------
# The header above still says "no npm" and it was true until 0.96.0. From then
# Releases before 0.96.0 needed no npm step. From then
# package.json lists a runtime dependency, the source tarball carries nothing
# under node_modules, and every install through this script produced a CLI
# that died on its first import (0.96.0 through 0.98.0). Read package.json
# with node rather than grep: "devDependencies" contains the word too.
install_deps() {
_pkg="$PKG_DIR/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
command -v npm >/dev/null 2>&1 || fail "npm is required to install moshcode's dependencies (node was found, npm was not)"
_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 "$PKG_DIR" && 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 $PKG_DIRmoshcode would not start without its dependencies"
fail "npm install failed in stagingthe new CLI would not start without its dependencies."
fi
unset _pkg
}
)

# ---------------------------------------------------------------------------
# wrapper at $MOSHCODE_BIN/moshcode
Expand Down
93 changes: 93 additions & 0 deletions tests/install-dependencies.test.mjs
Original file line number Diff line number Diff line change
@@ -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("../public/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=')) + '\ninstall_cli\n';
const nestedPackage = true;

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");
});
}
Loading