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
33 changes: 21 additions & 12 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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"
Expand All @@ -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_HOMEmoshcode would not start without its dependencies."
fail "npm install failed in stagingthe new CLI would not start without its dependencies."
fi
unset _pkg
}
)

write_wrapper() {
mkdir -p "$MOSHCODE_BIN"
Expand Down Expand Up @@ -276,7 +286,6 @@ run_install() {
check_node
_ref="$(resolve_ref)"
fetch_and_unpack "$_ref"
install_deps
write_wrapper
ensure_path
install_proxy
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
93 changes: 93 additions & 0 deletions test/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("../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");
});
}
7 changes: 4 additions & 3 deletions test/install-deps.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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(); }
Expand Down
Loading