From f6040437a46e9681fc18ae4de13a5fa9dec45aa8 Mon Sep 17 00:00:00 2001 From: Antoni Sobkowicz Date: Fri, 4 Sep 2026 12:06:21 +0200 Subject: [PATCH 1/2] feat(installer): skip reinstall when already on the latest version install.sh always ran a fresh npm global install on every invocation, even when the installed version already matched the latest release, and never told the user what version it was installing or already had. Read the version from the downloaded release archive's package.json and compare it against the installed package's package.json (when present); print both, and skip the npm install when they match. --- install.sh | 32 ++++++++++++++++++-- tests/installer.test.ts | 65 ++++++++++++++++++++++++++++++++--------- 2 files changed, 80 insertions(+), 17 deletions(-) diff --git a/install.sh b/install.sh index 8c03d99..1a0726e 100755 --- a/install.sh +++ b/install.sh @@ -92,6 +92,7 @@ esac [ "$node_major" -ge 22 ] || die "Node.js 22 or newer is required (found Node.js $node_major)" command -v npm >/dev/null 2>&1 || die "npm is required" +command -v tar >/dev/null 2>&1 || die "tar is required" if command -v curl >/dev/null 2>&1; then download() { @@ -120,6 +121,15 @@ fi install_dir="${ASANA_COMMAND_MCP_INSTALL_DIR:-"$HOME/.asana/mcp"}" release_base_url="${ASANA_COMMAND_MCP_RELEASE_BASE_URL:-"$DEFAULT_RELEASE_BASE_URL"}" executable="$install_dir/bin/asana-command-mcp" +installed_package_json="$install_dir/lib/node_modules/@asana/command-mcp/package.json" + +package_version() { + node -p 'JSON.parse(require("fs").readFileSync(0, "utf8")).version' +} + +archive_version() { + tar -xzOf "$1" package/package.json | package_version +} mkdir -p "$install_dir" work_dir="$(mktemp -d "${TMPDIR:-/tmp}/asana-command-mcp.XXXXXX")" @@ -151,9 +161,25 @@ expected_checksum="$( actual_checksum="$(checksum "$archive_path")" || die "failed to checksum $ARCHIVE_NAME" [ "$actual_checksum" = "$expected_checksum" ] || die "checksum verification failed" -info "Installing into $install_dir..." -npm install --global --prefix "$install_dir" "$archive_path" -[ -x "$executable" ] || die "installation completed without creating $executable" +latest_version="$(archive_version "$archive_path")" || + die "failed to read the release version from $ARCHIVE_NAME" +current_version='' +if [ -f "$installed_package_json" ]; then + current_version="$(package_version <"$installed_package_json" 2>/dev/null || true)" +fi + +if [ -n "$current_version" ]; then + info "Installed version: $current_version" +fi +info "Latest release version: $latest_version" + +if [ "$current_version" = "$latest_version" ]; then + info "Already up to date; skipping reinstall." +else + info "Installing into $install_dir..." + npm install --global --prefix "$install_dir" "$archive_path" + [ -x "$executable" ] || die "installation completed without creating $executable" +fi mv "$archive_path" "$install_dir/$ARCHIVE_NAME" has_claude=false diff --git a/tests/installer.test.ts b/tests/installer.test.ts index 234bb41..0bb1610 100644 --- a/tests/installer.test.ts +++ b/tests/installer.test.ts @@ -52,14 +52,36 @@ function linkCommand(binDirectory: string, command: string, source = commandPath symlinkSync(source, join(binDirectory, command)); } -function createArchive(assetDirectory: string, contents = "release-one"): void { +function createArchive(assetDirectory: string, version = "1.0.0"): void { mkdirSync(assetDirectory, { recursive: true }); const archivePath = join(assetDirectory, "asana-command-mcp.tgz"); - writeFileSync(archivePath, contents); - const digest = createHash("sha256").update(contents).digest("hex"); + const stagingRoot = temporaryDirectory("asana-command-mcp-archive"); + const packageDirectory = join(stagingRoot, "package"); + mkdirSync(packageDirectory, { recursive: true }); + writeFileSync( + join(packageDirectory, "package.json"), + `${JSON.stringify({ name: "@asana/command-mcp", version })}\n`, + ); + const tar = spawnSync("tar", ["-czf", archivePath, "-C", stagingRoot, "package"], { + encoding: "utf8", + }); + if (tar.status !== 0) { + throw new Error(`Failed to build a test archive: ${tar.stderr}`); + } + const digest = createHash("sha256").update(readFileSync(archivePath)).digest("hex"); writeFileSync(join(assetDirectory, "SHA256SUMS"), `${digest} asana-command-mcp.tgz\n`); } +function archiveVersion(archivePath: string): string { + const tar = spawnSync("tar", ["-xzOf", archivePath, "package/package.json"], { + encoding: "utf8", + }); + if (tar.status !== 0) { + throw new Error(`Failed to read the test archive version: ${tar.stderr}`); + } + return JSON.parse(tar.stdout).version; +} + function createFakePath(options: { root: string; downloader?: Downloader; @@ -70,7 +92,7 @@ function createFakePath(options: { rmSync(binDirectory, { recursive: true, force: true }); mkdirSync(binDirectory, { recursive: true }); - for (const command of ["awk", "cat", "chmod", "mkdir", "mktemp", "mv", "rm", "uname"]) { + for (const command of ["awk", "cat", "chmod", "mkdir", "mktemp", "mv", "rm", "tar", "uname"]) { linkCommand(binDirectory, command); } linkCommand(binDirectory, "node", process.execPath); @@ -87,10 +109,13 @@ function createFakePath(options: { `#!/bin/sh set -eu prefix='' +archive='' while [ "$#" -gt 0 ]; do if [ "$1" = "--prefix" ]; then shift prefix="$1" + else + archive="$1" fi shift done @@ -104,6 +129,8 @@ if [ "\${1:-}" = "doctor" ]; then fi EOF chmod +x "$prefix/bin/asana-command-mcp" +mkdir -p "$prefix/lib/node_modules/@asana/command-mcp" +tar -xzOf "$archive" package/package.json >"$prefix/lib/node_modules/@asana/command-mcp/package.json" printf '%s\\n' "$prefix" >>"$TEST_LOG/npm" `, ); @@ -235,9 +262,9 @@ describe("install.sh", () => { expect(first.result.status, first.result.stderr).toBe(0); const executable = join(home, ".asana/mcp/bin/asana-command-mcp"); expect(existsSync(executable)).toBe(true); - expect(readFileSync(join(home, ".asana/mcp/asana-command-mcp.tgz"), "utf8")).toBe( - "release-one", - ); + expect(archiveVersion(join(home, ".asana/mcp/asana-command-mcp.tgz"))).toBe("1.0.0"); + expect(first.result.stdout).toContain("Latest release version: 1.0.0"); + expect(first.result.stdout).not.toContain("Installed version:"); const cursorConfig = JSON.parse(readFileSync(join(home, ".cursor/mcp.json"), "utf8")); expect(cursorConfig).toEqual({ theme: "dark", @@ -251,7 +278,7 @@ describe("install.sh", () => { }, }); - createArchive(first.assets, "release-two"); + createArchive(first.assets, "2.0.0"); const second = runInstaller({ root, args: ["--all"], @@ -259,15 +286,27 @@ describe("install.sh", () => { }); expect(second.result.status, second.result.stderr).toBe(0); - expect(readFileSync(join(home, ".asana/mcp/asana-command-mcp.tgz"), "utf8")).toBe( - "release-two", - ); + expect(archiveVersion(join(home, ".asana/mcp/asana-command-mcp.tgz"))).toBe("2.0.0"); + expect(second.result.stdout).toContain("Installed version: 1.0.0"); + expect(second.result.stdout).toContain("Latest release version: 2.0.0"); + expect(second.result.stdout).not.toContain("Already up to date"); expect(readFileSync(join(first.log, "npm"), "utf8").trim().split("\n")).toHaveLength(2); const clientCalls = readFileSync(join(first.log, "clients"), "utf8"); expect(clientCalls).toContain( `claude <--transport> <--scope> <--> <${executable}>`, ); expect(clientCalls).toContain(`codex <--> <${executable}>`); + + const third = runInstaller({ + root, + args: ["--all"], + clients: ["claude", "codex", "cursor"], + }); + + expect(third.result.status, third.result.stderr).toBe(0); + expect(third.result.stdout).toContain("Installed version: 2.0.0"); + expect(third.result.stdout).toContain("Already up to date; skipping reinstall."); + expect(readFileSync(join(first.log, "npm"), "utf8").trim().split("\n")).toHaveLength(2); }); it("uses wget and can install without configuring clients", () => { @@ -414,9 +453,7 @@ describe("install.sh", () => { expect(result.status, result.stderr).toBe(0); expect(existsSync(oldPackage)).toBe(true); expect(result.stdout).toContain(`Kept old package: ${oldPackage}`); - expect(readFileSync(join(home, ".asana/mcp/asana-command-mcp.tgz"), "utf8")).toBe( - "release-one", - ); + expect(archiveVersion(join(home, ".asana/mcp/asana-command-mcp.tgz"))).toBe("1.0.0"); }); it("rejects an archive whose checksum does not match", () => { From 70ca63e8af1abf0b8cae958eec552f63c1fa0903 Mon Sep 17 00:00:00 2001 From: Antoni Sobkowicz Date: Fri, 4 Sep 2026 12:46:36 +0200 Subject: [PATCH 2/2] fix(installer): add gzip to the installer test's fake PATH CI failed on Linux with "tar (child): gzip: Cannot exec: No such file or directory". GNU tar shells out to a separate gzip binary for -z, unlike macOS's bsdtar which has zlib linked in directly, so this only reproduced on the Ubuntu CI runner, not locally. Reproduced locally with gtar (GNU tar via Homebrew) against a restricted PATH to confirm the root cause and the fix. --- tests/installer.test.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/tests/installer.test.ts b/tests/installer.test.ts index 0bb1610..1845897 100644 --- a/tests/installer.test.ts +++ b/tests/installer.test.ts @@ -92,7 +92,18 @@ function createFakePath(options: { rmSync(binDirectory, { recursive: true, force: true }); mkdirSync(binDirectory, { recursive: true }); - for (const command of ["awk", "cat", "chmod", "mkdir", "mktemp", "mv", "rm", "tar", "uname"]) { + for (const command of [ + "awk", + "cat", + "chmod", + "gzip", + "mkdir", + "mktemp", + "mv", + "rm", + "tar", + "uname", + ]) { linkCommand(binDirectory, command); } linkCommand(binDirectory, "node", process.execPath);