diff --git a/README.md b/README.md index 13b171e..5724cb7 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,13 @@ The server runs on your machine over stdio. By default, it authenticates with an > Behavior may change without notice, and there is no guarantee of continued availability or support. > Use of this software is entirely at your own risk. +## Quick setup + +1. Run the [installer](#install-or-update). +2. If you aren't already signed in, the installer checks for stored Asana credentials and + offers to [sign you in](#sign-in-to-asana) with a personal access token on the spot. +3. Verify the connection any time with [`doctor`](#check-the-connection). + ## Requirements - Node.js 22 or newer @@ -38,7 +45,9 @@ The installer: - installs it under `~/.asana/mcp`; - detects Claude Code (`claude`), Claude Desktop (its installed application), Codex (`codex`), Cursor (`cursor` or Cursor Agent's `agent`), and OpenCode (`opencode`); -- automatically configures every detected client as a user-level stdio MCP server. +- automatically configures every detected client as a user-level stdio MCP server; +- checks whether Asana authentication is already configured and, if not, offers to sign you in + interactively. Run the same command again to update the existing installation. The executable path remains `~/.asana/mcp/bin/asana-command-mcp`, so configured clients do not need a version-specific path. @@ -48,8 +57,9 @@ having the `codex` command installed also covers ChatGPT Desktop; there is no se With no flags (equivalent to `--all`), the installer configures every client it detects and silently skips the rest — nothing is installed or configured for a client that isn't present, and -nothing prompts. To require one specific client and fail loudly if it's missing, select it -explicitly instead: +nothing prompts about which clients to configure. (The installer may still ask you to sign in to +Asana if you aren't already authenticated — see [Quick setup](#quick-setup).) To require one +specific client and fail loudly if it's missing, select it explicitly instead: ```sh curl -fsSL https://github.com/Asana/command-mcp/releases/latest/download/install.sh \ diff --git a/install.sh b/install.sh index 2df298c..a0b235f 100755 --- a/install.sh +++ b/install.sh @@ -523,7 +523,40 @@ else info "No MCP clients were configured." fi info "" -info "Next, sign in to Asana:" -info " \"$executable\" auth login" + +auth_configured=false +if "$executable" auth status >/dev/null 2>&1; then + auth_configured=true +fi + +if [ "$auth_configured" = true ]; then + info "Asana sign-in: already configured." +elif [ -t 1 ] && [ -r /dev/tty ]; then + printf 'Asana is not signed in yet. Sign in now with a personal access token? [Y/n] ' >/dev/tty + answer='' + IFS= read -r answer /dev/null 2>&1; then + info "Signed in to Asana." + auth_configured=true + else + info "Sign-in did not complete." + fi + else + info "Sign-in did not complete." + fi + ;; + esac +fi + +if [ "$auth_configured" = false ]; then + info "" + info "Next, sign in to Asana:" + info " \"$executable\" auth login" + info " (or \"$executable\" auth login --oauth to use OAuth instead)" +fi info "" info "Run this installer again at any time to update to the latest release." diff --git a/src/cli.ts b/src/cli.ts index 7e05546..a173d7c 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -25,7 +25,7 @@ import { buildServices, type CommandServices } from "./services.js"; import type { UpdateChecker } from "./update_check.js"; export const CLI_USAGE = - "Usage: asana-command-mcp [doctor [TEAMSPACE_ID_OR_URL] | auth login [--oauth]]"; + "Usage: asana-command-mcp [doctor [TEAMSPACE_ID_OR_URL] | auth login [--oauth] | auth status]"; type OutputWriter = { write(data: string): unknown; @@ -69,9 +69,23 @@ export async function runCli(options: RunCliOptions = {}): Promise { subcommandArgs.length === 2 && subcommandArgs[0] === "login" && subcommandArgs[1] === "--oauth"; - if (!isPersonalAccessTokenLogin && !isOAuthLogin) { + const isStatus = subcommandArgs.length === 1 && subcommandArgs[0] === "status"; + if (!isPersonalAccessTokenLogin && !isOAuthLogin && !isStatus) { throw invalidCliUsage(); } + if (isStatus) { + const env = options.env ?? process.env; + const personalAccessTokenStore = + options.personalAccessTokenStore ?? createDefaultPersonalAccessTokenStore(); + const personalAccessToken = await personalAccessTokenStore.load(); + const oauthCredentialStore = + options.oauthCredentialStore ?? createDefaultOAuthCredentialStore(); + const oauthCredentials = + personalAccessToken === null ? await oauthCredentialStore.load() : null; + const config = loadConfig(env, { personalAccessToken, oauthCredentials }); + stdout.write(`Asana authentication is configured (${config.authentication.type}).\n`); + return; + } if (isOAuthLogin) { const env = options.env ?? process.env; const credentialStore = options.oauthCredentialStore ?? createDefaultOAuthCredentialStore(); diff --git a/tests/cli.test.ts b/tests/cli.test.ts index be1a825..78e183f 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -238,7 +238,8 @@ describe("CLI", () => { it("rejects incomplete auth subcommands before loading configuration", async () => { await expect(runCli({ args: ["auth"], env: {} })).rejects.toMatchObject({ code: "invalid_input", - message: "Usage: asana-command-mcp [doctor [TEAMSPACE_ID_OR_URL] | auth login [--oauth]]", + message: + "Usage: asana-command-mcp [doctor [TEAMSPACE_ID_OR_URL] | auth login [--oauth] | auth status]", }); }); @@ -310,7 +311,8 @@ describe("CLI", () => { it("rejects unknown subcommands before loading configuration", async () => { await expect(runCli({ args: ["serve"], env: {} })).rejects.toMatchObject({ code: "invalid_input", - message: "Usage: asana-command-mcp [doctor [TEAMSPACE_ID_OR_URL] | auth login [--oauth]]", + message: + "Usage: asana-command-mcp [doctor [TEAMSPACE_ID_OR_URL] | auth login [--oauth] | auth status]", }); }); diff --git a/tests/installer.test.ts b/tests/installer.test.ts index 56a6db2..1859236 100644 --- a/tests/installer.test.ts +++ b/tests/installer.test.ts @@ -138,6 +138,14 @@ if [ "\${1:-}" = "doctor" ]; then printf '%s\\n' '{"error":{"code":"invalid_configuration","message":"Asana login is missing; run asana-command-mcp auth login"}}' exit 1 fi +if [ "\${1:-}" = "auth" ] && [ "\${2:-}" = "status" ]; then + [ -f "$TEST_LOG/auth-configured" ] + exit $? +fi +if [ "\${1:-}" = "auth" ] && [ "\${2:-}" = "login" ]; then + : >"$TEST_LOG/auth-configured" + exit 0 +fi EOF chmod +x "$prefix/bin/asana-command-mcp" mkdir -p "$prefix/lib/node_modules/@asana/command-mcp" @@ -219,12 +227,16 @@ function runInstaller(options: { clients?: Client[]; includeNpm?: boolean; claudeDesktopInstalled?: boolean; + authConfigured?: boolean; }) { const home = join(options.root, "home with spaces"); const assets = join(options.root, "assets"); const log = join(options.root, "log"); mkdirSync(home, { recursive: true }); mkdirSync(log, { recursive: true }); + if (options.authConfigured === true) { + writeFileSync(join(log, "auth-configured"), ""); + } if (!existsSync(join(assets, "asana-command-mcp.tgz"))) { createArchive(assets); } @@ -344,6 +356,34 @@ describe("install.sh", () => { expect(result.stdout).toContain("No supported MCP clients were detected"); }); + // The installer only prompts to sign in when stdin is a TTY (`[ -t 1 ] && [ -r /dev/tty ]`), + // matching the pre-existing `remove_old_package` prompt. spawnSync's piped stdio is never a + // TTY, so that interactive branch is not exercised here and is verified manually instead. + it("skips the sign-in prompt when Asana auth is already configured", () => { + const root = temporaryDirectory("command-installer-auth-configured"); + const { result } = runInstaller({ + root, + args: ["--no-config"], + authConfigured: true, + }); + + expect(result.status, result.stderr).toBe(0); + expect(result.stdout).toContain("Asana sign-in: already configured."); + expect(result.stdout).not.toContain("Next, sign in to Asana"); + }); + + it("prints a non-interactive sign-in hint when Asana auth is not configured", () => { + const root = temporaryDirectory("command-installer-auth-missing"); + const { result } = runInstaller({ + root, + args: ["--no-config"], + }); + + expect(result.status, result.stderr).toBe(0); + expect(result.stdout).toContain("Next, sign in to Asana"); + expect(result.stdout).toContain("auth login --oauth"); + }); + it("configures every detected client by default and skips undetected ones without prompting", () => { const root = temporaryDirectory("command-installer-defaults"); const { home, log, result } = runInstaller({