Skip to content

Commit 457be47

Browse files
authored
Install moshcode runtime dependencies before replacing the CLI (#117)
1 parent d41d62d commit 457be47

2 files changed

Lines changed: 116 additions & 15 deletions

File tree

‎public/install.sh‎

Lines changed: 23 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,7 @@
1818
# resolved at run time, so re-running moves them up to newer releases.
1919
# 4. Fetches the CLI straight from the public GitHub repo
2020
# (github.com/moshcoder/moshcode) into $MOSHCODE_HOME/pkg. moshcode
21-
# is ESM with one runtime dependency since 0.96.0; npm installs it
22-
# into the package dir after the tarball is unpacked (install_deps).
21+
# needs its runtime dependencies installed with npm before it can start.
2322
# 5. Drops a wrapper at $HOME/.local/bin/moshcode that runs the CLI
2423
# via node and handles update|upgrade|remove|uninstall.
2524
#
@@ -214,7 +213,7 @@ resolve_ref() {
214213
}
215214

216215
# ---------------------------------------------------------------------------
217-
# install the CLI from GitHub, then its runtime dependencies (install_deps)
216+
# install the CLI from GitHub and its runtime dependencies from npm
218217
# ---------------------------------------------------------------------------
219218
install_cli() {
220219
for _t in curl tar node; do
@@ -234,6 +233,14 @@ install_cli() {
234233
if [ -z "$_src" ] || [ ! -f "$_src/bin/moshcode.mjs" ]; then
235234
rm -rf "$_tmp"; fail "bin/moshcode.mjs not found in tarball"
236235
fi
236+
# An archive has no node_modules. Install and verify in staging so a
237+
# registry failure or a broken release leaves the current CLI runnable.
238+
if ! install_deps "$_src"; then
239+
rm -rf "$_tmp"; fail "runtime dependency installation failed — existing installation unchanged."
240+
fi
241+
if ! node "$_src/bin/moshcode.mjs" --version >/dev/null; then
242+
rm -rf "$_tmp"; fail "CLI startup check failed — existing installation unchanged."
243+
fi
237244
rm -rf "$PKG_DIR.new"; mkdir -p "$PKG_DIR.new"
238245
( cd "$_src" && tar -cf - . ) | ( cd "$PKG_DIR.new" && tar -xf - )
239246
rm -rf "$_tmp"
@@ -242,34 +249,35 @@ install_cli() {
242249
[ -d "$PKG_DIR" ] && mv "$PKG_DIR" "$PKG_DIR.old"
243250
mv "$PKG_DIR.new" "$PKG_DIR"
244251
rm -rf "$PKG_DIR.old"
245-
install_deps
246252
_ver="$(node -p "require('$PKG_DIR/package.json').version" 2>/dev/null || echo '?')"
247253
ok "moshcode@$_ver installed to $PKG_DIR"
248254
}
249255

250256
# ---------------------------------------------------------------------------
251257
# runtime dependencies (moshcode stopped being dependency-free in 0.96.0)
252258
# ---------------------------------------------------------------------------
253-
# The header above still says "no npm" and it was true until 0.96.0. From then
259+
# Releases before 0.96.0 needed no npm step. From then
254260
# package.json lists a runtime dependency, the source tarball carries nothing
255261
# under node_modules, and every install through this script produced a CLI
256262
# that died on its first import (0.96.0 through 0.98.0). Read package.json
257263
# with node rather than grep: "devDependencies" contains the word too.
258-
install_deps() {
259-
_pkg="$PKG_DIR/package.json"
264+
install_deps() (
265+
# Run in a subshell so temporary paths cannot change the caller's install
266+
# location. Only the checked staging directory is passed here.
267+
_deps_dir="$1"
268+
_pkg="$_deps_dir/package.json"
260269
[ -f "$_pkg" ] || return 0
261-
if ! node -e 'const p=require(process.argv[1]);process.exit(Object.keys(p.dependencies||{}).length?0:1)' "$_pkg" 2>/dev/null; then
262-
unset _pkg; return 0
263-
fi
264-
command -v npm >/dev/null 2>&1 || fail "npm is required to install moshcode's dependencies (node was found, npm was not)"
270+
_needs_deps="$(node -e 'const p=require(process.argv[1]);console.log(Object.keys(p.dependencies||{}).length ? "yes" : "no")' "$_pkg")" \
271+
|| fail "cannot read the staged package.json."
272+
[ "$_needs_deps" = yes ] || return 0
273+
command -v npm >/dev/null 2>&1 || fail "npm is required to install moshcode's dependencies (node was found, npm was not)."
265274
info "installing runtime dependencies"
266-
if ( cd "$PKG_DIR" && npm install --omit=dev --no-audit --no-fund --loglevel=error >/dev/null 2>&1 ); then
275+
if (cd "$_deps_dir" && npm install --omit=dev --ignore-scripts --no-audit --no-fund --package-lock=false); then
267276
ok "dependencies installed"
268277
else
269-
fail "npm install failed in $PKG_DIR — moshcode would not start without its dependencies"
278+
fail "npm install failed in staging — the new CLI would not start without its dependencies."
270279
fi
271-
unset _pkg
272-
}
280+
)
273281

274282
# ---------------------------------------------------------------------------
275283
# wrapper at $MOSHCODE_BIN/moshcode
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import assert from "node:assert/strict";
2+
import { execFileSync, spawnSync } from "node:child_process";
3+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
4+
import { tmpdir } from "node:os";
5+
import { join } from "node:path";
6+
import test from "node:test";
7+
8+
const source = readFileSync(new URL("../public/install.sh", import.meta.url), "utf8");
9+
// Run the real payload installer without provisioning runtimes, shell rc files,
10+
// or the optional proxy. Downloads and npm are local fixtures; node/tar are real.
11+
const installer = source.slice(0, source.indexOf('\nCMD=')) + '\ninstall_cli\n';
12+
const nestedPackage = true;
13+
14+
function fixture(t, { existing = false, npmMode = "install" } = {}) {
15+
const dir = mkdtempSync(join(tmpdir(), "moshcode-dependencies-"));
16+
t.after(() => rmSync(dir, { recursive: true, force: true }));
17+
const home = join(dir, "install with spaces");
18+
const pkg = nestedPackage ? join(home, "pkg") : home;
19+
const bin = join(dir, "fake-bin");
20+
const release = join(dir, "moshcode-fixture");
21+
mkdirSync(bin);
22+
mkdirSync(join(release, "bin"), { recursive: true });
23+
writeFileSync(join(release, "package.json"), JSON.stringify({
24+
name: "moshcode", version: "0.97.0", type: "module",
25+
dependencies: { "@profullstack/synconfig": "^0.1.1" },
26+
}));
27+
writeFileSync(join(release, "bin/moshcode.mjs"),
28+
'import { SNAPSHOT_VERSION } from "@profullstack/synconfig";\nconsole.log(`0.97.0 sync=${SNAPSHOT_VERSION}`);\n');
29+
const archive = join(dir, "release.tar.gz");
30+
execFileSync("tar", ["-czf", archive, "-C", dir, "moshcode-fixture"]);
31+
writeFileSync(join(bin, "curl"), '#!/bin/sh\ncat "$TEST_ARCHIVE"\n', { mode: 0o755 });
32+
writeFileSync(join(bin, "npm"), `#!/bin/sh
33+
printf '%s\n' "$@" > "$TEST_NPM_ARGS"
34+
[ "$TEST_NPM_MODE" != fail ] || exit 42
35+
[ "$TEST_NPM_MODE" != skip ] || exit 0
36+
mkdir -p node_modules/@profullstack/synconfig
37+
printf '%s' '{"type":"module","exports":"./index.js"}' > node_modules/@profullstack/synconfig/package.json
38+
printf '%s' 'export const SNAPSHOT_VERSION = 1;' > node_modules/@profullstack/synconfig/index.js
39+
`, { mode: 0o755 });
40+
if (existing) {
41+
mkdirSync(join(pkg, "bin"), { recursive: true });
42+
writeFileSync(join(pkg, "bin/moshcode.mjs"), 'console.log("old working CLI");\n');
43+
writeFileSync(join(pkg, "keep-until-success"), "previous installation");
44+
}
45+
const npmArgs = join(dir, "npm-args");
46+
const result = spawnSync("sh", ["-s"], {
47+
input: installer,
48+
encoding: "utf8",
49+
env: {
50+
...process.env,
51+
PATH: `${bin}:${process.env.PATH}`,
52+
MOSHCODE_HOME: home,
53+
MOSHCODE_BIN: join(dir, "wrappers"),
54+
MOSHCODE_REF: "fixture",
55+
TEST_ARCHIVE: archive,
56+
TEST_NPM_ARGS: npmArgs,
57+
TEST_NPM_MODE: npmMode,
58+
TMPDIR: dir,
59+
NO_COLOR: "1",
60+
},
61+
timeout: 15000,
62+
});
63+
return { result, pkg, npmArgs };
64+
}
65+
66+
test("a fresh archive installs runtime dependencies before its first startup", (t) => {
67+
const { result, pkg, npmArgs } = fixture(t);
68+
assert.equal(result.status, 0, result.stdout + result.stderr);
69+
assert.equal(execFileSync(process.execPath, [join(pkg, "bin/moshcode.mjs"), "--version"], { encoding: "utf8" }).trim(), "0.97.0 sync=1");
70+
const args = readFileSync(npmArgs, "utf8").trim().split("\n");
71+
assert.equal(args[0], "install");
72+
assert.ok(args.includes("--omit=dev"));
73+
assert.ok(args.includes("--ignore-scripts"));
74+
assert.ok(args.includes("--package-lock=false"));
75+
});
76+
77+
test("an update replaces the old payload only after dependencies and startup succeed", (t) => {
78+
const { result, pkg } = fixture(t, { existing: true });
79+
assert.equal(result.status, 0, result.stdout + result.stderr);
80+
assert.equal(existsSync(join(pkg, "keep-until-success")), false);
81+
assert.match(execFileSync(process.execPath, [join(pkg, "bin/moshcode.mjs")], { encoding: "utf8" }), /0\.97\.0 sync=1/);
82+
});
83+
84+
for (const [npmMode, expected] of [["fail", /runtime dependency installation failed/], ["skip", /CLI startup check failed/]]) {
85+
test(`${npmMode === "fail" ? "a registry failure" : "an unresolved dependency after npm succeeds"} preserves the installed CLI`, (t) => {
86+
const { result, pkg } = fixture(t, { existing: true, npmMode });
87+
assert.notEqual(result.status, 0);
88+
assert.match(result.stderr, expected);
89+
assert.match(result.stderr, /existing installation unchanged/);
90+
assert.equal(readFileSync(join(pkg, "keep-until-success"), "utf8"), "previous installation");
91+
assert.equal(execFileSync(process.execPath, [join(pkg, "bin/moshcode.mjs")], { encoding: "utf8" }).trim(), "old working CLI");
92+
});
93+
}

0 commit comments

Comments
 (0)