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
32 changes: 29 additions & 3 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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")"
Expand Down Expand Up @@ -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
Expand Down
76 changes: 62 additions & 14 deletions tests/installer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
Expand All @@ -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
Expand All @@ -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"
`,
);
Expand Down Expand Up @@ -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",
Expand All @@ -251,23 +289,35 @@ describe("install.sh", () => {
},
});

createArchive(first.assets, "release-two");
createArchive(first.assets, "2.0.0");
const second = runInstaller({
root,
args: ["--all"],
clients: ["claude", "codex", "cursor"],
});

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 <mcp> <add> <--transport> <stdio> <--scope> <user> <asana-command> <--> <${executable}>`,
);
expect(clientCalls).toContain(`codex <mcp> <add> <asana-command> <--> <${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", () => {
Expand Down Expand Up @@ -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", () => {
Expand Down