#!/usr/bin/env bash # # Microclaw Self-Hosted — one-click installer. # # Run from Azure Cloud Shell (https://shell.azure.com) — bash mode: # bash <(curl -sL https://microclaw.app/setup.sh) # # What it does: # 1. Verifies prereqs (Global Admin on tenant, Owner on subscription). # 2. Creates one Entra app registration with the 22 delegated Graph # permissions Microclaw needs, generates a client secret, grants # tenant-wide admin consent. # 3. Provisions a resource group + the full Microclaw stack via the # published Bicep template (Container App, Bot Service, Key Vault, # Postgres, ACR, Storage). Takes ~20 min. # 4. Bakes the bot's app ID + hostname into the Teams app manifest and # uploads it tenant-wide via Microsoft Graph — Microclaw appears in # every user's Teams app catalog automatically. # 5. Prints next steps (open Teams, find Microclaw, sign in). # # Re-running the script is safe: if an app reg / resource group / bot # already exists with the chosen name, the script reuses or skips it. # set -euo pipefail # --------------------------------------------------------------------------- # Style # --------------------------------------------------------------------------- if [[ -t 1 ]]; then C_BOLD=$'\033[1m'; C_DIM=$'\033[2m'; C_RED=$'\033[31m' C_GREEN=$'\033[32m'; C_YELLOW=$'\033[33m'; C_BLUE=$'\033[34m' C_RESET=$'\033[0m' else C_BOLD=''; C_DIM=''; C_RED=''; C_GREEN=''; C_YELLOW=''; C_BLUE=''; C_RESET='' fi step() { printf "\n%s==> %s%s\n" "$C_BOLD$C_BLUE" "$*" "$C_RESET"; } ok() { printf "%s ✓ %s%s\n" "$C_GREEN" "$*" "$C_RESET"; } warn() { printf "%s ! %s%s\n" "$C_YELLOW" "$*" "$C_RESET"; } fail() { printf "%s ✗ %s%s\n" "$C_RED" "$*" "$C_RESET"; exit 1; } info() { printf "%s %s%s\n" "$C_DIM" "$*" "$C_RESET"; } prompt() { printf "%s? %s%s " "$C_BOLD" "$*" "$C_RESET"; } # --------------------------------------------------------------------------- # Config — edit these only if you know what you're doing # --------------------------------------------------------------------------- # Default is "Microclaw Self-Hosted" — explicit + distinguishes from any # existing app reg the customer happens to have named "Microclaw". The # 2026-05-20 smoke discovered the bare "Microclaw" default collided with a # pre-existing internal Marketplace API app in the publisher's tenant; the # reuse path then modified the wrong app. Specific suffix keeps collisions # essentially impossible. Customer can override with MICROCLAW_APP_NAME=… . APP_DISPLAY_NAME="${MICROCLAW_APP_NAME:-Microclaw Self-Hosted}" RESOURCE_GROUP="${MICROCLAW_RESOURCE_GROUP:-microclaw-rg}" NAME_PREFIX="${MICROCLAW_NAME_PREFIX:-microclaw}" # centralus default: eastus / eastus2 are Azure's highest-demand regions # and most commonly quota-restricted for Postgres Flexible Server on new # subscriptions. centralus has broader, more-stable quota in practice. # Customer can always override via MICROCLAW_REGION=... and the script's # error path catches restricted-region failures with a clear recover-and- # retry hint if their sub also doesn't have centralus quota (rare). DEFAULT_REGION="${MICROCLAW_REGION:-centralus}" TEMPLATE_URI="${MICROCLAW_TEMPLATE_URI:-https://microclaw.app/azuredeploy.json}" MANIFEST_ZIP_URI="${MICROCLAW_MANIFEST_URI:-https://microclaw.app/microclaw-selfhosted-manifest.zip}" # Microsoft Graph well-known appId — never changes. MS_GRAPH_APP_ID="00000003-0000-0000-c000-000000000000" # Canonical Microclaw Graph scopes — fetched from microclaw.app at install # time, NOT hardcoded here. The bot publishes its own ALL_SCOPES list as a # build artifact (microclaw.app/graph-scopes.json); single source of truth. # Actual fetch happens in Step 4 (after az + jq prereq checks). Maintaining # a duplicate hardcoded list previously caused a 22-vs-24 drift bug # (2026-05-20) where the bot requested Sites.Read.All + Team.ReadBasic.All # but setup.sh hadn't consented to them — user sign-in hit "admin approval # required" wall every time. GRAPH_SCOPES_URI="${MICROCLAW_SCOPES_URI:-https://microclaw.app/graph-scopes.json}" GRAPH_SCOPES=() # populated by load_graph_scopes() below # --------------------------------------------------------------------------- # Helpers — Azure OpenAI discovery + selection # --------------------------------------------------------------------------- # These set globals so the main script body reads as a sequence of named # steps. Bash doesn't have ergonomic multi-return, and `local -n` namerefs # add more friction than they remove for a single-purpose script. # Prints a JSON array of OpenAI resources visible to the signed-in user # across every subscription they can list. Each entry has: # { name, rg, endpoint, subId, subName } discover_openai_resources() { local subs_json all sub_row sub_id sub_name sub_openai subs_json=$(az account list --query '[].{id:id, name:name}' -o json) all='[]' while IFS= read -r sub_row; do sub_id=$(echo "$sub_row" | jq -r '.id') sub_name=$(echo "$sub_row" | jq -r '.name') sub_openai=$(az cognitiveservices account list --subscription "$sub_id" --query "[?kind=='OpenAI'].{name:name, rg:resourceGroup, endpoint:properties.endpoint}" -o json 2>/dev/null || echo '[]') # Enrich each entry with its sub context via jq (--arg has proper # variable substitution; embedding sub_id/sub_name inside the az # JMESPath query parses them as field references, not literals). sub_openai=$(echo "$sub_openai" | jq --arg sid "$sub_id" --arg sname "$sub_name" 'map(. + {subId: $sid, subName: $sname})') all=$(jq -n --argjson a "$all" --argjson b "$sub_openai" '$a + $b') done < <(echo "$subs_json" | jq -c '.[]') echo "$all" } # Walk the user through picking an OpenAI resource from the discovered list, # or fall through to the manual paste path if zero / none-of-the-above. # Sets globals: OPENAI_NAME, OPENAI_RG, OPENAI_ENDPOINT, OPENAI_SUB, OPENAI_KEY select_openai_resource() { local list="$1" local count sub_name use_openai count=$(echo "$list" | jq 'length') OPENAI_NAME=""; OPENAI_RG=""; OPENAI_ENDPOINT=""; OPENAI_SUB=""; OPENAI_KEY="" if [[ "$count" == "1" ]]; then OPENAI_NAME=$(echo "$list" | jq -r '.[0].name') OPENAI_RG=$(echo "$list" | jq -r '.[0].rg') OPENAI_ENDPOINT=$(echo "$list" | jq -r '.[0].endpoint') OPENAI_SUB=$(echo "$list" | jq -r '.[0].subId') sub_name=$(echo "$list" | jq -r '.[0].subName') info "Found: $OPENAI_NAME in resource group $OPENAI_RG (subscription: $sub_name)" prompt "Use this resource? (Y/n)" read -r use_openai if [[ "$use_openai" =~ ^[Nn]$ ]]; then OPENAI_NAME=""; OPENAI_RG=""; OPENAI_ENDPOINT=""; OPENAI_SUB="" else OPENAI_KEY=$(az cognitiveservices account keys list --name "$OPENAI_NAME" --resource-group "$OPENAI_RG" --subscription "$OPENAI_SUB" --query key1 -o tsv --only-show-errors) fi elif [[ "$count" -gt "1" ]]; then info "Multiple Azure OpenAI resources found:" echo "$list" | jq -r '.[] | " \(.name) in \(.rg) (\(.subName))"' prompt "Azure OpenAI resource name to use:" read -r OPENAI_NAME OPENAI_RG=$(echo "$list" | jq -r --arg n "$OPENAI_NAME" '[.[] | select(.name == $n)] | .[0].rg // empty') OPENAI_ENDPOINT=$(echo "$list" | jq -r --arg n "$OPENAI_NAME" '[.[] | select(.name == $n)] | .[0].endpoint // empty') OPENAI_SUB=$(echo "$list" | jq -r --arg n "$OPENAI_NAME" '[.[] | select(.name == $n)] | .[0].subId // empty') [[ -n "$OPENAI_ENDPOINT" ]] || fail "Resource '$OPENAI_NAME' not found in any subscription." OPENAI_KEY=$(az cognitiveservices account keys list --name "$OPENAI_NAME" --resource-group "$OPENAI_RG" --subscription "$OPENAI_SUB" --query key1 -o tsv --only-show-errors) fi if [[ -z "$OPENAI_ENDPOINT" ]]; then prompt_openai_manual fi } # Manual paste fallback: validates the endpoint URL shape and re-prompts # until it looks right, then takes the key silently. prompt_openai_manual() { warn "No Azure OpenAI resource auto-selected. Paste the endpoint URL and key manually." info "Tip: in Cloud Shell, paste with right-click → Paste, or Ctrl+Shift+V. The key won't show as you paste — that's intentional (it's a secret)." while true; do prompt "Azure OpenAI endpoint URL (e.g. https://my-resource.openai.azure.com/):" read -r OPENAI_ENDPOINT if [[ "$OPENAI_ENDPOINT" =~ ^https://[a-zA-Z0-9.-]+\.(openai\.azure\.com|api\.cognitive\.microsoft\.com)/?$ ]]; then break fi warn "That doesn't look like an Azure OpenAI endpoint. Expected: https://.openai.azure.com/ or https://.api.cognitive.microsoft.com/ — try again." done prompt "Azure OpenAI API key:" read -rs OPENAI_KEY; echo } # Sanity-check that the three required model deployments exist on the # selected resource. Only possible when we auto-detected (we have sub + # name); on the manual-paste path we just warn the user to verify. verify_openai_deployments() { info "Verifying required deployments exist (gpt-4o, gpt-4o-mini, text-embedding-3-small)..." if [[ -z "$OPENAI_NAME" || -z "$OPENAI_RG" || -z "$OPENAI_SUB" ]]; then info "Skipped (you pasted the endpoint manually). Make sure your endpoint has all three deployments before the bot starts up." return fi local deployments d deployments=$(az cognitiveservices account deployment list --name "$OPENAI_NAME" --resource-group "$OPENAI_RG" --subscription "$OPENAI_SUB" --query '[].name' -o tsv 2>/dev/null || echo "") for d in gpt-4o gpt-4o-mini text-embedding-3-small; do if echo "$deployments" | grep -qx "$d"; then ok "Found deployment: $d" else warn "Missing deployment: $d — Microclaw will fail until you create it. See: https://learn.microsoft.com/azure/ai-services/openai/how-to/create-resource" fi done } # --------------------------------------------------------------------------- # Banner # --------------------------------------------------------------------------- cat </dev/null 2>&1 || fail "az CLI not found. Run this script from Azure Cloud Shell (https://shell.azure.com)." command -v jq >/dev/null 2>&1 || fail "jq not found. Cloud Shell should include it; if you're running locally, install jq first." ACCOUNT_JSON=$(az account show 2>/dev/null) || fail "az is not signed in. From Cloud Shell, run 'az login' first." SIGNED_IN_USER=$(echo "$ACCOUNT_JSON" | jq -r '.user.name') TENANT_ID=$(echo "$ACCOUNT_JSON" | jq -r '.tenantId') ok "Signed in as $SIGNED_IN_USER (tenant $TENANT_ID)" # --------------------------------------------------------------------------- # Step 2 — Pick the subscription + region # --------------------------------------------------------------------------- step "Pick the subscription to deploy into" SUB_ID=$(echo "$ACCOUNT_JSON" | jq -r '.id') SUB_NAME=$(echo "$ACCOUNT_JSON" | jq -r '.name') info "Current default subscription: $SUB_NAME ($SUB_ID)" prompt "Use this subscription? (Y/n)" read -r use_sub if [[ "$use_sub" =~ ^[Nn]$ ]]; then az account list --query '[].{name:name, id:id}' --output table while true; do prompt "Subscription ID or name to use:" read -r SUB_ID if [[ -n "$SUB_ID" ]] && az account set --subscription "$SUB_ID" 2>/dev/null; then break fi warn "Could not switch to '$SUB_ID'. Paste an ID or name from the table above." done SUB_NAME=$(az account show --query name -o tsv) ok "Switched to $SUB_NAME" fi while true; do prompt "Azure region for the deployment [default: $DEFAULT_REGION]:" read -r REGION REGION="${REGION:-$DEFAULT_REGION}" if az account list-locations --query "[].name" -o tsv 2>/dev/null | grep -qx "$REGION"; then break fi warn "'$REGION' is not a valid Azure region for your subscription. Try one like 'eastus', 'westeurope', 'centralus', or hit Enter to use '$DEFAULT_REGION'." done ok "Region: $REGION" # --------------------------------------------------------------------------- # Step 3 — Azure OpenAI endpoint + key # --------------------------------------------------------------------------- step "Connect to your Azure OpenAI resource" info "Looking for existing Azure OpenAI resources across all subscriptions you can access…" OPENAI_LIST=$(discover_openai_resources) select_openai_resource "$OPENAI_LIST" [[ -n "$OPENAI_ENDPOINT" && -n "$OPENAI_KEY" ]] || fail "Azure OpenAI endpoint + key are required." ok "Azure OpenAI: $OPENAI_ENDPOINT" verify_openai_deployments # --------------------------------------------------------------------------- # Step 4 — Entra app registration # --------------------------------------------------------------------------- step "Creating Entra app registration (single app for delegated Graph + Bot Framework)" info "Fetching canonical Graph scope list from $GRAPH_SCOPES_URI ..." GRAPH_SCOPES_JSON=$(curl -fsSL "$GRAPH_SCOPES_URI") \ || fail "Could not fetch canonical scope list from $GRAPH_SCOPES_URI. Check internet connectivity or override with MICROCLAW_SCOPES_URI=." readarray -t GRAPH_SCOPES < <(echo "$GRAPH_SCOPES_JSON" | jq -r '.scopes[]') [[ ${#GRAPH_SCOPES[@]} -ge 20 ]] \ || fail "Canonical scope list too short (got ${#GRAPH_SCOPES[@]} entries). The JSON at $GRAPH_SCOPES_URI may be malformed." ok "Canonical scopes loaded (${#GRAPH_SCOPES[@]} permissions)" info "Resolving each scope to its Microsoft Graph permission ID..." GRAPH_SP_PERMS=$(az ad sp show --id "$MS_GRAPH_APP_ID" --query 'oauth2PermissionScopes' -o json --only-show-errors) RESOURCE_ACCESS_JSON='[]' for scope in "${GRAPH_SCOPES[@]}"; do PERM_ID=$(echo "$GRAPH_SP_PERMS" | jq -r --arg v "$scope" '.[] | select(.value == $v) | .id') [[ -n "$PERM_ID" ]] || fail "Could not resolve permission '$scope' — either Microsoft Graph schema changed or the canonical scope list at $GRAPH_SCOPES_URI references an obsolete permission." RESOURCE_ACCESS_JSON=$(echo "$RESOURCE_ACCESS_JSON" | jq --arg id "$PERM_ID" '. += [{id: $id, type: "Scope"}]') done # required-resource-accesses expects an array of objects with resourceAppId # and resourceAccess sub-array. REQUIRED_ACCESS_JSON=$(jq -n --arg appId "$MS_GRAPH_APP_ID" --argjson access "$RESOURCE_ACCESS_JSON" \ '[{ resourceAppId: $appId, resourceAccess: $access }]') REQUIRED_ACCESS_FILE=$(mktemp --suffix=.json) echo "$REQUIRED_ACCESS_JSON" > "$REQUIRED_ACCESS_FILE" EXISTING_APP=$(az ad app list --display-name "$APP_DISPLAY_NAME" --query '[0]' -o json --only-show-errors) if [[ "$EXISTING_APP" != "null" && -n "$EXISTING_APP" ]]; then APP_ID=$(echo "$EXISTING_APP" | jq -r '.appId') APP_OBJECT_ID=$(echo "$EXISTING_APP" | jq -r '.id') # Sanity check: an app reg with our display name exists, but is it actually # a prior Self-Hosted install or an unrelated app that happens to share the # name? Real Self-Hosted installs leave a distinctive web redirect URI # pointing at an *.azurecontainerapps.io host. If we don't see one, the # match is suspicious — bail loudly rather than clobber an unrelated app's # configuration (the 2026-05-20 smoke wiped requiredResourceAccess on a # production Marketplace API app named "Microclaw" by accident; this # check would have caught that). EXISTING_REDIRECTS=$(echo "$EXISTING_APP" | jq -r '.web.redirectUris[]?' 2>/dev/null) if echo "$EXISTING_REDIRECTS" | grep -q 'azurecontainerapps\.io/auth/callback'; then warn "App registration '$APP_DISPLAY_NAME' already exists (appId $APP_ID)." info "Looks like a prior Self-Hosted install (Container Apps redirect URI present). Reusing + syncing required-resource-accesses to canonical scope list." az ad app update --id "$APP_ID" --required-resource-accesses "@$REQUIRED_ACCESS_FILE" --only-show-errors \ || fail "Could not sync required-resource-accesses on existing app reg. Re-run after investigating in the Entra portal." ok "App reg permissions synced to canonical list" else rm -f "$REQUIRED_ACCESS_FILE" warn "An Entra app named '$APP_DISPLAY_NAME' already exists in your tenant (appId $APP_ID)." warn "It does NOT look like a Microclaw Self-Hosted install (no Container Apps redirect URI):" echo "$EXISTING_REDIRECTS" | sed 's/^/ /' info "Refusing to modify an unrelated app. Options:" info " (a) Re-run with a different name: MICROCLAW_APP_NAME='Microclaw Self-Hosted 2' bash <(curl -sL https://microclaw.app/setup.sh)" info " (b) Delete or rename the existing app reg in Entra, then re-run." info " (c) If you're SURE that app IS your prior Self-Hosted install (e.g. you deleted the resource group manually), delete the app reg first ('az ad app delete --id $APP_ID') and re-run to get a clean create." fail "App-reg collision detected — see above." fi else APP_JSON=$(az ad app create \ --display-name "$APP_DISPLAY_NAME" \ --sign-in-audience AzureADMyOrg \ --required-resource-accesses "@$REQUIRED_ACCESS_FILE") APP_ID=$(echo "$APP_JSON" | jq -r '.appId') APP_OBJECT_ID=$(echo "$APP_JSON" | jq -r '.id') ok "Created app registration: $APP_DISPLAY_NAME ($APP_ID)" fi rm -f "$REQUIRED_ACCESS_FILE" info "Ensuring a service principal exists for the app..." SP_OBJECT_ID=$(az ad sp list --filter "appId eq '$APP_ID'" --query '[0].id' -o tsv 2>/dev/null || echo "") if [[ -z "$SP_OBJECT_ID" ]]; then SP_OBJECT_ID=$(az ad sp create --id "$APP_ID" --query id -o tsv) ok "Created service principal ($SP_OBJECT_ID)" else ok "Service principal already exists ($SP_OBJECT_ID)" fi # --------------------------------------------------------------------------- # Step 5 — Generate / rotate client secret # --------------------------------------------------------------------------- step "Generating client secret" info "Adding a new client secret (1-year expiry) — previous secrets are kept for audit." # Capture stdout (JSON) only; let stderr (Azure warnings/preview notes) flow to terminal # so they don't corrupt the JSON parse. SECRET_JSON=$(az ad app credential reset --id "$APP_ID" --display-name "microclaw-installer-$(date +%Y%m%d-%H%M%S)" --years 1 --append --only-show-errors) || { warn "az ad app credential reset returned non-zero." warn "The credential may have been created server-side anyway. Listing all credentials on this app:" az ad app credential list --id "$APP_ID" --query '[].{name:displayName, end:endDateTime}' -o table || true fail "Could not generate client secret. Delete the app reg ('az ad app delete --id $APP_ID') and re-run, or open Entra → App registrations → $APP_DISPLAY_NAME → Certificates & secrets and create one manually." } APP_SECRET=$(echo "$SECRET_JSON" | jq -r '.password // empty' 2>/dev/null) if [[ -z "$APP_SECRET" || "$APP_SECRET" == "null" ]]; then warn "Could not parse a password out of the credential-reset response. Raw response:" echo "$SECRET_JSON" | sed 's/^/ /' fail "Client secret was empty in the response. Delete the app reg ('az ad app delete --id $APP_ID') and re-run the installer to start clean." fi ok "Client secret generated (length ${#APP_SECRET})" # --------------------------------------------------------------------------- # Step 6 — Admin consent # --------------------------------------------------------------------------- step "Granting tenant-wide admin consent for the 22 delegated permissions" # Granting via direct Microsoft Graph POST/PATCH. The convenience command # `az ad app permission admin-consent` is reliably broken from Cloud Shell # (MSI token audience bug — see Azure CLI issue, audience GUID for ARM # isn't accepted as a Graph token audience) and falls back to the same # REST path anyway, so we skip it. info "This needs you to be a Global Administrator. Granting via Microsoft Graph…" SCOPE_STRING="${GRAPH_SCOPES[*]}" GRAPH_SP_OBJECT_ID=$(az ad sp show --id "$MS_GRAPH_APP_ID" --query id -o tsv --only-show-errors) # Idempotent: check whether a grant already exists for this client+resource, # then PATCH (update scope) or POST (create) accordingly. Re-runs after a # successful first install must not blow up. EXISTING_GRANT=$(az rest --method GET \ --uri "https://graph.microsoft.com/v1.0/oauth2PermissionGrants?\$filter=clientId eq '$SP_OBJECT_ID' and resourceId eq '$GRAPH_SP_OBJECT_ID' and consentType eq 'AllPrincipals'" \ --query 'value[0]' -o json --only-show-errors 2>/dev/null || echo 'null') if [[ "$EXISTING_GRANT" != "null" && -n "$EXISTING_GRANT" ]]; then GRANT_ID=$(echo "$EXISTING_GRANT" | jq -r '.id') info "Permission grant already exists ($GRANT_ID). Updating scope to ensure all 22 are present…" PATCH_BODY=$(jq -n --arg scope "$SCOPE_STRING" '{ scope: $scope }') az rest --method PATCH \ --uri "https://graph.microsoft.com/v1.0/oauth2PermissionGrants/$GRANT_ID" \ --headers "Content-Type=application/json" \ --body "$PATCH_BODY" --only-show-errors >/dev/null \ || fail "Could not PATCH the existing permission grant. Open Entra portal → App registrations → $APP_DISPLAY_NAME → API permissions → 'Grant admin consent' manually, then re-run." ok "Admin consent updated (existing grant patched with current scope set)" else GRANT_BODY=$(jq -n --arg client "$SP_OBJECT_ID" --arg resource "$GRAPH_SP_OBJECT_ID" --arg scope "$SCOPE_STRING" \ '{ clientId: $client, consentType: "AllPrincipals", resourceId: $resource, scope: $scope }') GRANT_RESULT=$(az rest --method POST \ --uri "https://graph.microsoft.com/v1.0/oauth2PermissionGrants" \ --headers "Content-Type=application/json" \ --body "$GRANT_BODY" --only-show-errors 2>&1) || { echo "$GRANT_RESULT" | sed 's/^/ /' fail "Could not grant admin consent. Open Entra portal → App registrations → $APP_DISPLAY_NAME → API permissions → 'Grant admin consent' manually, then re-run this script." } ok "Admin consent granted" fi info "Waiting 20 seconds for permission propagation across the tenant..." sleep 20 # --------------------------------------------------------------------------- # Step 7 — Resource group + Bicep deployment # --------------------------------------------------------------------------- step "Provisioning Azure resources via the Microclaw Bicep template" info "Resource group: $RESOURCE_GROUP (region $REGION)" if az group show --name "$RESOURCE_GROUP" >/dev/null 2>&1; then ok "Resource group already exists — reusing it" else az group create --name "$RESOURCE_GROUP" --location "$REGION" >/dev/null ok "Created resource group $RESOURCE_GROUP" fi info "Generating Postgres admin password (32-char random; stored in Key Vault as DATABASE-PASSWORD)…" POSTGRES_PASSWORD=$(openssl rand -base64 32 | tr -d '/+=' | head -c 32) [[ ${#POSTGRES_PASSWORD} -ge 28 ]] || fail "Postgres password generation produced too-short value (length ${#POSTGRES_PASSWORD}); openssl is missing or broken." ok "Postgres password generated (length ${#POSTGRES_PASSWORD})" info "Submitting deployment (this takes ~20 min — Postgres Flexible Server provisioning + bot image import are the slow steps)…" DEPLOY_NAME="microclaw-install-$(date +%Y%m%d-%H%M%S)" DEPLOY_OUTPUT=$(az deployment group create \ --name "$DEPLOY_NAME" \ --resource-group "$RESOURCE_GROUP" \ --template-uri "$TEMPLATE_URI" \ --parameters \ namePrefix="$NAME_PREFIX" \ assistantName="$APP_DISPLAY_NAME" \ azureOpenAiEndpoint="$OPENAI_ENDPOINT" \ azureOpenAiApiKey="$OPENAI_KEY" \ graphTenantId="$TENANT_ID" \ graphClientId="$APP_ID" \ teamsAppId="$APP_ID" \ teamsAppPassword="$APP_SECRET" \ teamsTenantId="$TENANT_ID" \ postgresAdminPassword="$POSTGRES_PASSWORD" \ --output json 2>&1) || { if echo "$DEPLOY_OUTPUT" | grep -q "LocationIsOfferRestricted"; then warn "Azure Database for PostgreSQL Flexible Server is restricted in '$REGION' for your subscription." info "Re-run the installer with a different region:" info " MICROCLAW_REGION=centralus bash <(curl -sL https://microclaw.app/setup.sh)" info "Other commonly available regions: westus3, northeurope, australiaeast." fail "Postgres region restriction — see message above." fi echo "$DEPLOY_OUTPUT" | sed 's/^/ /' fail "Bicep deployment failed. Check the deployment in the Azure Portal for the specific error." } BOT_ENDPOINT=$(echo "$DEPLOY_OUTPUT" | jq -r '.properties.outputs.botEndpoint.value') BOT_HOSTNAME=$(echo "$DEPLOY_OUTPUT" | jq -r '.properties.outputs.botHostname.value') OAUTH_REDIRECT_URI=$(echo "$DEPLOY_OUTPUT" | jq -r '.properties.outputs.oauthRedirectUri.value') ok "Deployment complete" ok "Bot endpoint: $BOT_ENDPOINT" # --------------------------------------------------------------------------- # Step 7b — Register the OAuth redirect URI on the Entra app registration # --------------------------------------------------------------------------- # The bot's OAuth callback URL only became known just now (it's derived # from the Container App's auto-assigned FQDN). Microsoft Entra rejects # any redirect not pre-registered on the app, so register it now before # any user tries to sign in. step "Registering the OAuth redirect URI on the Entra app" info "Adding $OAUTH_REDIRECT_URI to the app's web redirect URIs…" az ad app update --id "$APP_ID" --web-redirect-uris "$OAUTH_REDIRECT_URI" --only-show-errors \ || fail "Could not register OAuth redirect URI on the Entra app. User sign-in will fail until this is added manually via Entra portal → App registrations → $APP_DISPLAY_NAME → Authentication → Add a platform → Web." ok "OAuth redirect URI registered" # --------------------------------------------------------------------------- # Step 8 — Build the Teams manifest with real IDs baked in # --------------------------------------------------------------------------- step "Building the Teams app manifest with your bot's IDs" WORK_DIR=$(mktemp -d) trap "rm -rf $WORK_DIR" EXIT cd "$WORK_DIR" info "Downloading manifest template from microclaw.app…" curl -fsSL "$MANIFEST_ZIP_URI" -o template.zip unzip -q template.zip [[ -f manifest.json ]] || fail "Downloaded zip did not contain manifest.json." info "Substituting BOT_APP_ID and BOT_HOSTNAME placeholders…" sed -i "s|{{BOT_APP_ID}}|$APP_ID|g" manifest.json sed -i "s|{{BOT_HOSTNAME}}|$BOT_HOSTNAME|g" manifest.json # Sanity check — both placeholders should be gone if grep -q '{{BOT_APP_ID}}\|{{BOT_HOSTNAME}}' manifest.json; then fail "Placeholders not fully substituted in manifest.json — please report this." fi zip -q microclaw-teams-app.zip manifest.json color.png outline.png ok "Built microclaw-teams-app.zip ($(wc -c < microclaw-teams-app.zip) bytes)" # --------------------------------------------------------------------------- # Step 9 — Upload the Teams app tenant-wide via Graph # --------------------------------------------------------------------------- step "Publishing the Teams app to your tenant's app catalog" info "Getting a Microsoft Graph access token from your Cloud Shell session…" GRAPH_TOKEN=$(az account get-access-token --resource https://graph.microsoft.com --query accessToken -o tsv) \ || fail "Could not get a Graph access token. Run 'az login' and re-run the installer." info "POSTing the zip to Microsoft Graph /appCatalogs/teamsApps…" HTTP_RESPONSE_FILE=$(mktemp) HTTP_CODE=$(curl -s -o "$HTTP_RESPONSE_FILE" -w '%{http_code}' \ -X POST "https://graph.microsoft.com/v1.0/appCatalogs/teamsApps" \ -H "Authorization: Bearer $GRAPH_TOKEN" \ -H "Content-Type: application/zip" \ --data-binary @microclaw-teams-app.zip) UPLOAD_RESULT=$(cat "$HTTP_RESPONSE_FILE") rm -f "$HTTP_RESPONSE_FILE" case "$HTTP_CODE" in 200|201) TEAMS_APP_ID_IN_CATALOG=$(echo "$UPLOAD_RESULT" | jq -r '.id // empty') if [[ -n "$TEAMS_APP_ID_IN_CATALOG" ]]; then ok "Microclaw is now in your tenant's Teams app catalog (id: $TEAMS_APP_ID_IN_CATALOG)" else warn "Upload returned $HTTP_CODE but no app id in the response body: $UPLOAD_RESULT" fi ;; 409) warn "An app with this manifest id is already in the tenant catalog. Skipping (use Teams Admin Center to update or remove)." ;; 403) warn "Tenant-wide app catalog upload was denied (HTTP 403). This usually means the signed-in account isn't a Global Administrator with AppCatalog.Submit rights, OR your tenant has 'Allow custom apps' disabled." info "Fallback: a Teams admin can upload the manifest manually via the Teams Admin Center → Manage apps → Upload new app → choose 'microclaw-teams-app.zip' (copy it out of $WORK_DIR before this script exits)." ;; *) warn "Tenant-wide app catalog upload failed (HTTP $HTTP_CODE). Response:" echo "$UPLOAD_RESULT" | sed 's/^/ /' info "Fallback: a Teams admin can upload microclaw-teams-app.zip manually via the Teams Admin Center." ;; esac # --------------------------------------------------------------------------- # Done # --------------------------------------------------------------------------- cat <