#!/usr/bin/env bash # drive-map.sh — create/repoint Windows network drive maps + share shortcuts on a # remote endpoint via GuruRMM, run in the user's session so they actually appear. # See SKILL.md for the why. Verbs: verify | cred | map | shortcut | unmap | migrate. set -uo pipefail REPO="$(git rev-parse --show-toplevel 2>/dev/null || echo .)" VAULT="$REPO/.claude/scripts/vault.sh" logerr(){ bash "$REPO/.claude/scripts/log-skill-error.sh" "drive-map" "$1" --context "${2:-}" >/dev/null 2>&1 || true; } die(){ echo "[ERROR] $1" >&2; exit "${2:-2}"; } VERB="${1:-}"; shift || true [ -n "$VERB" ] || die "usage: drive-map.sh --host [opts]" HOST=""; SERVER=""; TARGET=""; LETTER=""; NAME=""; CRED_USER=""; CRED_VAULT="" CRED_FIELD="credentials.password"; REMOVE_OLD=""; QUICK=0; PERSIST="yes" PROFILE_HINT=""; DRYRUN=0; TIMEOUT=90 while [ $# -gt 0 ]; do case "$1" in --host) HOST="${2:?}"; shift 2;; --server) SERVER="${2:?}"; shift 2;; --target) TARGET="${2:?}"; shift 2;; --letter) LETTER="${2:?}"; LETTER="${LETTER%%:}"; shift 2;; --name) NAME="${2:?}"; shift 2;; --cred-user) CRED_USER="${2:?}"; shift 2;; --cred-vault) CRED_VAULT="${2:?}"; shift 2;; --cred-field) CRED_FIELD="${2:?}"; shift 2;; --remove-old) REMOVE_OLD="${2:?}"; shift 2;; --quick-access) QUICK=1; shift;; --no-persistent) PERSIST="no"; shift;; --profile-hint) PROFILE_HINT="${2:?}"; shift 2;; --dry-run) DRYRUN=1; shift;; --timeout) TIMEOUT="${2:?}"; shift 2;; *) die "unknown option: $1";; esac done [ -n "$HOST" ] || die "--host is required" # Normalize a UNC: strip any/all leading backslashes, force exactly two. Shell-arg # layers (Git-bash on Windows) collapse a leading "\\" to "\", which breaks the host # parse; this makes the tool robust to 0/1/2 leading backslashes on input. norm_unc(){ [ -n "$1" ] || return 0; printf '\\\\%s' "$(printf '%s' "$1" | sed -E 's#^\\+##')"; } # host portion of a UNC: \\HOST\share\... -> HOST unc_host(){ printf '%s' "$1" | sed -E 's#^\\+##; s#\\.*##'; } # leaf of a UNC path: \\h\share\a\b -> b unc_leaf(){ printf '%s' "$1" | sed 's#\\*$##; s#.*\\##'; } SERVER="$(norm_unc "$SERVER")"; TARGET="$(norm_unc "$TARGET")"; REMOVE_OLD="$(norm_unc "$REMOVE_OLD")" # ---- resolve the credential password (vault only; never plaintext on CLI) ---- CRED_PASS="" if [ -n "$CRED_USER" ]; then [ -n "$CRED_VAULT" ] || die "--cred-user given but no --cred-vault to read the password from" CRED_PASS="$(bash "$VAULT" get-field "$CRED_VAULT" "$CRED_FIELD" 2>/dev/null)" [ -n "$CRED_PASS" ] || { logerr "vault read empty for $CRED_VAULT:$CRED_FIELD" "host=$HOST"; die "could not read password at vault:$CRED_VAULT field:$CRED_FIELD"; } fi # ---- generate the endpoint PowerShell for the verb (all user_session) ---- # PS variable values are injected via single-quoted PS literals; we escape any # embedded single quote by doubling it (PS rule). Password is masked in all output. psq(){ printf "'%s'" "$(printf '%s' "$1" | sed "s/'/''/g")"; } build_ps(){ local ps="" # append one PS line with a REAL newline. Never use printf %b here: UNC paths # contain backslashes (\c, \S) that %b would eat as escapes and truncate output. add(){ ps+="$1"$'\n'; } add "\$ErrorActionPreference='Continue'" add "\$who = (Get-CimInstance Win32_ComputerSystem).UserName; if(-not \$who){ Write-Host '[WARN] no interactive user resolved; user-session ops may no-op' }" add "Write-Host (\"[INFO] running as: \$env:USERNAME active: \$who\")" # remove-old: strip stale shortcuts/letter/cred pointing at the old prefix if [ -n "$REMOVE_OLD" ] && { [ "$VERB" = "migrate" ] || [ "$VERB" = "unmap" ]; }; then local oldhost; oldhost="$(unc_host "$REMOVE_OLD")" add "\$old=$(psq "$REMOVE_OLD")" add "\$desk=[Environment]::GetFolderPath('Desktop')" add "\$wsh=New-Object -ComObject WScript.Shell" add "Get-ChildItem \$desk -Filter *.lnk -EA SilentlyContinue | ForEach-Object { \$t=\$wsh.CreateShortcut(\$_.FullName).TargetPath; if(\$t -like (\$old+'*')){ Remove-Item \$_.FullName -Force -EA SilentlyContinue; Write-Host (\"[OK] removed stale shortcut: \"+\$_.Name+\" -> \"+\$t) } }" add "cmdkey /delete:$oldhost *>\$null; Write-Host '[OK] cleared old cmdkey: $oldhost'" [ -n "$LETTER" ] && add "net use ${LETTER}: /delete /y *>\$null" fi # cred: store per-host credential if [ -n "$CRED_USER" ] && { [ "$VERB" = "cred" ] || [ "$VERB" = "map" ] || [ "$VERB" = "migrate" ]; }; then local credhost="" [ -n "$SERVER" ] && credhost="$(unc_host "$SERVER")" [ -z "$credhost" ] && [ -n "$TARGET" ] && credhost="$(unc_host "$TARGET")" [ -n "$credhost" ] || die "cred needs --server or --target to derive the server host" add "cmdkey /add:$credhost /user:$(psq "$CRED_USER") /pass:$(psq "$CRED_PASS") | Out-Null" add "Write-Host '[OK] stored credential for $credhost as $CRED_USER'" fi # map: persistent drive letter if [ "$VERB" = "map" ]; then [ -n "$SERVER" ] || die "map needs --server" [ -n "$LETTER" ] || die "map needs --letter" add "net use ${LETTER}: /delete /y *>\$null" add "\$r = net use ${LETTER}: $(psq "$SERVER") /persistent:$PERSIST 2>&1; Write-Host (\"\$r\")" add "if(Test-Path ${LETTER}:\\){ Write-Host '[OK] mapped ${LETTER}: -> $SERVER' } else { Write-Host '[FAIL] map ${LETTER}: failed'; exit 1 }" fi # migrate may also map a letter if both server+letter given if [ "$VERB" = "migrate" ] && [ -n "$SERVER" ] && [ -n "$LETTER" ]; then add "net use ${LETTER}: /delete /y *>\$null" add "\$r = net use ${LETTER}: $(psq "$SERVER") /persistent:$PERSIST 2>&1; Write-Host (\"\$r\")" add "if(Test-Path ${LETTER}:\\){ Write-Host '[OK] mapped ${LETTER}: -> $SERVER' } else { Write-Host '[WARN] map ${LETTER}: did not verify' }" fi # shortcut: desktop .lnk (+ optional quick access) if [ -n "$TARGET" ] && { [ "$VERB" = "shortcut" ] || [ "$VERB" = "migrate" ]; }; then local lname; lname="${NAME:-$(unc_leaf "$TARGET")}" add "\$desk2=[Environment]::GetFolderPath('Desktop')" add "\$wsh2=New-Object -ComObject WScript.Shell" add "\$lp=Join-Path \$desk2 ($(psq "$lname")+'.lnk')" add "\$lnk=\$wsh2.CreateShortcut(\$lp); \$lnk.TargetPath=$(psq "$TARGET"); \$lnk.Save()" add "Write-Host ('[OK] shortcut: '+\$lp+' -> $TARGET')" if [ "$QUICK" = "1" ]; then add "try { \$sa=New-Object -ComObject Shell.Application; \$sa.Namespace($(psq "$TARGET")).Self.InvokeVerb('pintohome'); Write-Host '[OK] pinned to Quick Access' } catch { Write-Host '[WARN] Quick Access pin failed (non-fatal)' }" fi fi # verify: target reachable from this user if [ -n "$TARGET" ] && { [ "$VERB" = "verify" ] || [ "$VERB" = "migrate" ]; }; then add "if(Test-Path -LiteralPath $(psq "$TARGET")){ Write-Host '[OK] reachable: $TARGET' } else { Write-Host '[FAIL] NOT reachable: $TARGET'; exit 1 }" elif [ "$VERB" = "verify" ] && [ -n "$LETTER" ]; then add "if(Test-Path ${LETTER}:\\){ Write-Host '[OK] reachable: ${LETTER}:' } else { Write-Host '[FAIL] NOT reachable: ${LETTER}:'; exit 1 }" fi printf '%s' "$ps" } PS_SCRIPT="$(build_ps)" [ -n "$PS_SCRIPT" ] || die "verb '$VERB' produced no actions — check options" if [ "$DRYRUN" = "1" ]; then echo "[DRY-RUN] would dispatch to $HOST (user_session), timeout ${TIMEOUT}s:" echo "------------------------------------------------------------" # mask the password in the preview if [ -n "$CRED_PASS" ]; then printf '%s\n' "$PS_SCRIPT" | sed "s/$(printf '%s' "$CRED_PASS" | sed 's/[.[\*^$()+?{|]/\\&/g')/********/g"; else printf '%s\n' "$PS_SCRIPT"; fi echo "------------------------------------------------------------" exit 0 fi # ---- RMM auth + agent resolution ---- eval "$(bash "$REPO/.claude/scripts/rmm-auth.sh")" >/dev/null 2>&1 || { logerr "rmm auth failed"; die "RMM auth failed"; } AGENTS="$(curl -s "$RMM/api/agents" -H "Authorization: Bearer $TOKEN")" AGENT="$(echo "$AGENTS" | jq --arg h "$HOST" '[.[] | select(.hostname|ascii_downcase|contains($h|ascii_downcase))] | .[0]')" AID="$(echo "$AGENT" | jq -r '.id // empty')" AHOST="$(echo "$AGENT" | jq -r '.hostname // empty')" AOS="$(echo "$AGENT" | jq -r '.os_type // empty')" ACONN="$(echo "$AGENT" | jq -r '.is_connected // false')" [ -n "$AID" ] || { logerr "agent not found: $HOST"; die "no RMM agent matching '$HOST'"; } [ "$AOS" = "windows" ] || die "agent $AHOST is os_type=$AOS — drive-map is Windows-only" echo "[INFO] target: $AHOST (id=$AID, connected=$ACONN) verb=$VERB" [ "$ACONN" = "true" ] || echo "[WARN] agent offline — command will queue until it reconnects" PAYLOAD="$(jq -n --arg ct powershell --arg cmd "$PS_SCRIPT" --argjson to "$TIMEOUT" \ '{command_type:$ct, command:$cmd, timeout_seconds:$to, context:"user_session"}')" CMD_ID="$(curl -s -X POST "$RMM/api/agents/$AID/command" -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d "$PAYLOAD" | jq -r '.command_id // empty')" [ -n "$CMD_ID" ] || { logerr "dispatch failed" "host=$AHOST verb=$VERB"; die "dispatch failed"; } echo "[INFO] dispatched cmd=$CMD_ID (user_session)" S=""; R="" for i in $(seq 1 30); do R="$(curl -s "$RMM/api/commands/$CMD_ID" -H "Authorization: Bearer $TOKEN")" S="$(echo "$R" | jq -r '.status')" { [ "$S" = "completed" ] || [ "$S" = "failed" ] || [ "$S" = "cancelled" ] || [ "$S" = "interrupted" ]; } && break sleep 4 done echo "[INFO] status=$S exit=$(echo "$R" | jq -r '.exit_code // "—"')" echo "--- stdout ---"; echo "$R" | jq -r '.stdout // ""' STDERR="$(echo "$R" | jq -r '.stderr // ""')" [ -n "$STDERR" ] && { echo "--- stderr ---"; echo "$STDERR"; } # alert per RMM convention (write op only) if [ "$VERB" != "verify" ]; then bash "$REPO/.claude/scripts/post-bot-alert.sh" "[RMM] drive-map $VERB on $AHOST -> ${TARGET:-$SERVER} (status=$S)" >/dev/null 2>&1 || true fi case "$S" in completed) exit 0;; failed) logerr "drive-map $VERB failed on $AHOST" "cmd=$CMD_ID"; exit 1;; *) logerr "drive-map $VERB ended status=$S on $AHOST" "cmd=$CMD_ID"; exit 1;; esac