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..1845897 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,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", "uname"]) { + for (const command of [ + "awk", + "cat", + "chmod", + "gzip", + "mkdir", + "mktemp", + "mv", + "rm", + "tar", + "uname", + ]) { linkCommand(binDirectory, command); } linkCommand(binDirectory, "node", process.execPath); @@ -87,10 +120,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 +140,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 +273,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 +289,7 @@ describe("install.sh", () => { }, }); - createArchive(first.assets, "release-two"); + createArchive(first.assets, "2.0.0"); const second = runInstaller({ root, args: ["--all"], @@ -259,15 +297,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 +464,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", () => {