Bug 1 (PRIMARY — unblocks OAuth on MobaXterm): jqf now strips \r from every
jq output. Root cause of the multi-day "OAuth token unavailable" cascade was
CRLF-tainted .oauth.json: `fetched_at=$(jqf ... '.fetched_at')` captured
"1716826990\r", and `$((fetched_at + expires_in))` crashed with the cryptic
"invalid arithmetic operator (error token is \"\"") — bash trying to print
the embedded CR. Single-point fix in jqf covers every caller (ensure, refresh,
status, debug, login). Added belt-and-suspenders `printf '%d'` coercion on
every numeric capture so any future non-CR junk falls back to 0 instead of
crashing arithmetic. /oauth-debug now reports CR/LF byte counts so future
CRLF taint is visible at a glance.
Bug 2 (security): .oauth.json was landing at 0644 on Cygwin/MobaXterm even
though both cmd_login and cmd_refresh called `chmod 600`. Introduced
secure_install (install -m 600 → cp+chmod → mv+chmod fallback chain) so the
mode is set atomically at placement. Also added umask 077 to cmd_refresh
(only cmd_login had it) so the .new sidecar is created tight from the start,
plus a pre-mv chmod 600 on the sidecar for fs-where-install-doesn't-stick.
On a fully POSIX FS this is now triple-redundant; on Cygwin NTFS we get as
close to 0600 as the ACL emulation will allow.
Feature 1: TAB completion for slash commands. New _LARRY_SLASH_CMDS canonical
array near read_user_input, __larry_complete_slash uses `bind -x` to read
$READLINE_LINE / $READLINE_POINT and rewrites the buffer in-place. Prefix
matching is primary; subsequence fuzzy is the fallback. Non-slash lines and
mid-arg TABs fall back to literal-tab insertion so muscle memory isn't
broken. Heredoc continuation lines DO NOT get completion (binding only fires
on the first read). /help section documents the behavior with examples.
Smoke-tested on macOS:
- CRLF-tainted .oauth.json: ensure returns access_token cleanly, status &
debug print real numbers + human timestamps (no bash arith crash).
- secure_install: file ends at 0600 even when source was 0644.
- Completion: /h→/help, /ss→lists ssh-*, /ssh-h→/ssh-hosts, /q→/quit,
/oa→/oauth-debug, /sssp→/ssh-setup (fuzzy), /xyz→silent, non-slash and
"/cmd args"→literal tab.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
513 lines
22 KiB
Bash
Executable File
513 lines
22 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# oauth.sh — OAuth login flow against Claude.ai for Larry-Anywhere.
|
|
#
|
|
# Uses the same OAuth client/flow Anthropic's Claude Code CLI uses, so calls
|
|
# bill against your Claude Max / Pro subscription quota instead of pay-as-you-go
|
|
# API metering. Public client_id; PKCE; out-of-band code paste (no localhost
|
|
# server required, works behind any firewall).
|
|
#
|
|
# Subcommands:
|
|
# login start the auth flow; print URL; prompt for code
|
|
# refresh refresh the access token using the stored refresh token
|
|
# ensure print a valid access token (auto-refreshes if near-expired)
|
|
# status show current auth state + expiry
|
|
# debug dump full OAuth diagnostic state (file, parsed times, jq path,
|
|
# cygpath translation, truncated token previews) — safe to share
|
|
# logout delete the stored tokens
|
|
#
|
|
# Storage: $LARRY_HOME/.oauth.json (mode 0600)
|
|
#
|
|
# Env vars:
|
|
# LARRY_OAUTH_DEBUG=1 emit per-call decision trace from cmd_ensure to stderr.
|
|
# Default off so the chat log stays clean; flip on when
|
|
# diagnosing why ensure returns empty.
|
|
#
|
|
# This is community/unofficial use of Anthropic's OAuth flow. Anthropic could
|
|
# tighten it at any time. If OAuth stops working, Larry transparently falls
|
|
# back to the API-key path stored in $LARRY_HOME/.env.
|
|
set -u
|
|
set -o pipefail
|
|
|
|
LARRY_HOME="${LARRY_HOME:-$HOME/.larry}"
|
|
OAUTH_FILE="$LARRY_HOME/.oauth.json"
|
|
|
|
# Anthropic Claude Code's publicly-visible OAuth client_id. Used by claude-code
|
|
# and several community CLI tools (droidrun/mobilerun, motiful/cc-gateway, ...).
|
|
#
|
|
# Endpoints migrated 2025: claude.ai/oauth/authorize → claude.com/cai/oauth/authorize,
|
|
# console.anthropic.com/v1/oauth/token → platform.claude.com/v1/oauth/token,
|
|
# console.anthropic.com/oauth/code/callback → platform.claude.com/oauth/code/callback.
|
|
# The OLD endpoints return a misleading "rate_limit_error" for any request.
|
|
# Scopes also expanded with user:sessions:claude_code, user:mcp_servers,
|
|
# user:file_upload — required by the new flow.
|
|
CLIENT_ID="${LARRY_OAUTH_CLIENT_ID:-9d1c250a-e61b-44d9-88ed-5944d1962f5e}"
|
|
AUTHORIZE_URL="${LARRY_OAUTH_AUTHORIZE_URL:-https://claude.com/cai/oauth/authorize}"
|
|
TOKEN_URL="${LARRY_OAUTH_TOKEN_URL:-https://platform.claude.com/v1/oauth/token}"
|
|
REDIRECT_URI="${LARRY_OAUTH_REDIRECT_URI:-https://platform.claude.com/oauth/code/callback}"
|
|
SCOPE="${LARRY_OAUTH_SCOPE:-org:create_api_key user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload}"
|
|
|
|
die() { printf 'oauth: %s\n' "$*" >&2; exit 1; }
|
|
|
|
# secure_install SRC DST — atomically move SRC to DST and force mode 0600.
|
|
#
|
|
# Why this exists (v0.6.6): on Cygwin/MobaXterm the .oauth.json was landing
|
|
# at mode 0644 even though both cmd_login and cmd_refresh called `chmod 600`
|
|
# after the `mv`. Two compounding issues:
|
|
# 1) `chmod` order matters less than `umask` — the new file's mode is set
|
|
# at create() time; the chmod afterward only adjusts ACL entries Cygwin
|
|
# may or may not actually honour on NTFS-backed mounts (MobaXterm
|
|
# portable mounts don't set `noacl`, so POSIX perms are emulated via
|
|
# NTFS ACLs that can silently degrade to "Everyone:Read").
|
|
# 2) Prefer `install -m 600 SRC DST` when available: it sets the mode
|
|
# *atomically as part of the placement*, not as a post-hoc chmod.
|
|
#
|
|
# Fallback chain: install(1) → cp+chmod+rm → mv+chmod (legacy). Each branch
|
|
# ends with a redundant `chmod 600` so on a working POSIX FS the perms are
|
|
# always 0600 even if `install` silently no-ops the mode flag.
|
|
secure_install() {
|
|
local src="$1" dst="$2"
|
|
if command -v install >/dev/null 2>&1; then
|
|
install -m 600 "$src" "$dst" 2>/dev/null && rm -f "$src" && chmod 600 "$dst" 2>/dev/null && return 0
|
|
fi
|
|
mv "$src" "$dst" || return 1
|
|
chmod 600 "$dst" 2>/dev/null || true
|
|
}
|
|
|
|
# Dependency check
|
|
command -v curl >/dev/null 2>&1 || die "curl required"
|
|
command -v jq >/dev/null 2>&1 || die "jq required"
|
|
command -v openssl >/dev/null 2>&1 || die "openssl required (for PKCE sha256)"
|
|
|
|
b64url() { base64 | tr '/+' '_-' | tr -d '=' | tr -d '\n'; }
|
|
|
|
# jqf — run jq against a file, but pipe the file via stdin so bash handles
|
|
# the path translation. Needed because on MobaXterm/Cygwin the bundled jq
|
|
# may be a Windows-native binary that doesn't understand Cygwin paths like
|
|
# /home/mobaxterm/... when they come in as argv. Stdin redirection always
|
|
# works because bash does the open() itself.
|
|
#
|
|
# IMPORTANT (v0.6.6): jq output on MobaXterm/Cygwin can carry a trailing \r
|
|
# when the source JSON file was written with CRLF line endings, because jq -r
|
|
# preserves line endings byte-for-byte. A captured value of "1716826990\r"
|
|
# then crashes bash arithmetic with the cryptic:
|
|
# syntax error: invalid arithmetic operator (error token is "")
|
|
# (bash trying to print the embedded CR.) We strip \r at the source so EVERY
|
|
# caller is safe regardless of whether they use the value in arithmetic, string
|
|
# comparison, or printf. This is the central CR-defense; do not remove it.
|
|
# Usage: jqf <file> <jq-args...>
|
|
jqf() {
|
|
local file="$1"; shift
|
|
jq "$@" < "$file" | tr -d '\r'
|
|
}
|
|
|
|
urlenc() {
|
|
# Minimal RFC3986-ish URL encoder for the bits we need (spaces, /, :)
|
|
local s="$1"
|
|
s="${s// /%20}"
|
|
s="${s//:/%3A}"
|
|
s="${s//\//%2F}"
|
|
printf '%s' "$s"
|
|
}
|
|
|
|
gen_pkce() {
|
|
local verifier challenge
|
|
verifier=$(LC_ALL=C tr -dc 'a-zA-Z0-9-._~' </dev/urandom | head -c 64)
|
|
challenge=$(printf '%s' "$verifier" | openssl dgst -sha256 -binary | b64url)
|
|
printf '%s|%s' "$verifier" "$challenge"
|
|
}
|
|
|
|
cmd_login() {
|
|
mkdir -p "$LARRY_HOME"
|
|
local pkce verifier challenge state
|
|
pkce=$(gen_pkce)
|
|
verifier="${pkce%%|*}"
|
|
challenge="${pkce##*|}"
|
|
state=$(LC_ALL=C tr -dc 'a-zA-Z0-9' </dev/urandom | head -c 32)
|
|
|
|
local url
|
|
url="${AUTHORIZE_URL}?code=true"
|
|
url="${url}&client_id=${CLIENT_ID}"
|
|
url="${url}&response_type=code"
|
|
url="${url}&redirect_uri=$(urlenc "$REDIRECT_URI")"
|
|
url="${url}&scope=$(urlenc "$SCOPE")"
|
|
url="${url}&code_challenge=${challenge}"
|
|
url="${url}&code_challenge_method=S256"
|
|
url="${url}&state=${state}"
|
|
|
|
cat <<EOF
|
|
|
|
=== Larry-Anywhere — Claude subscription login ===
|
|
|
|
This binds Larry to your Claude.ai Max/Pro subscription quota (same flow
|
|
Claude Code uses). No API key needed.
|
|
|
|
1. Open this URL in any browser on any device:
|
|
|
|
${url}
|
|
|
|
2. Sign in with your Claude account.
|
|
3. Approve the app. You'll land on a page that displays a string in the form
|
|
<CODE>#<STATE>
|
|
(Anthropic uses a URL fragment, not a query param, to deliver them.)
|
|
Copy the WHOLE string — both halves and the '#' between them.
|
|
|
|
4. Paste it here:
|
|
|
|
EOF
|
|
printf 'authorization code (CODE#STATE): '
|
|
read -r code_input
|
|
[ -z "$code_input" ] && die "no code entered"
|
|
|
|
# Split CODE#STATE. If the user pasted only the code (no '#'), keep the
|
|
# state we generated; otherwise verify the returned state matches.
|
|
local code returned_state
|
|
if [[ "$code_input" == *"#"* ]]; then
|
|
code="${code_input%%#*}"
|
|
returned_state="${code_input#*#}"
|
|
if [ -n "$returned_state" ] && [ "$returned_state" != "$state" ]; then
|
|
die "state mismatch — got '$returned_state', expected '$state' (possible CSRF or stale URL; rerun login)"
|
|
fi
|
|
else
|
|
code="$code_input"
|
|
fi
|
|
|
|
local resp
|
|
resp=$(curl -sS -X POST "$TOKEN_URL" \
|
|
-H "Content-Type: application/json" \
|
|
-H "Accept: application/json" \
|
|
-H "anthropic-beta: oauth-2025-04-20" \
|
|
-H "User-Agent: claude-cli/2.1.85 (larry-anywhere)" \
|
|
-d "$(jq -n \
|
|
--arg cid "$CLIENT_ID" \
|
|
--arg code "$code" \
|
|
--arg verifier "$verifier" \
|
|
--arg redirect "$REDIRECT_URI" \
|
|
--arg state "$state" \
|
|
'{client_id:$cid, grant_type:"authorization_code", code:$code, code_verifier:$verifier, redirect_uri:$redirect, state:$state}')")
|
|
|
|
if ! printf '%s' "$resp" | jq -e '.access_token' >/dev/null 2>&1; then
|
|
printf '\nauth failed. server response:\n' >&2
|
|
printf '%s\n' "$resp" | jq . >&2 2>/dev/null || printf '%s\n' "$resp" >&2
|
|
cat >&2 <<EOF
|
|
|
|
Hints:
|
|
- The callback delivers the code as CODE#STATE (fragment, not query).
|
|
Paste the WHOLE string including '#'. Just CODE alone also works.
|
|
- The code is single-use; if you used it already (even on a failed attempt),
|
|
run 'larry-auth.sh login' again to get a fresh URL.
|
|
- 'rate_limit_error' on a fresh code is the server's misleading mask for
|
|
'malformed/used code' OR 'dead endpoint'. If you JUST upgraded and saw
|
|
that error, double-check TOKEN_URL points at platform.claude.com — old
|
|
console.anthropic.com URLs return rate_limit_error for everything.
|
|
Current (as of 2026-05): https://platform.claude.com/v1/oauth/token
|
|
- If OAuth is genuinely broken, fall back to the API key by deleting any
|
|
oauth file and creating $LARRY_HOME/.env with ANTHROPIC_API_KEY=sk-ant-...
|
|
EOF
|
|
exit 1
|
|
fi
|
|
|
|
local now; now=$(date +%s)
|
|
umask 077
|
|
# Write to a .new sidecar first, validate it parses + has the required keys,
|
|
# then atomically mv into place. If jq fails mid-pipe, $OAUTH_FILE is never
|
|
# half-written — preventing the "ensure returns empty because the file is
|
|
# corrupt" failure mode that's otherwise invisible to the caller.
|
|
printf '%s' "$resp" | jq --arg now "$now" '. + {fetched_at: ($now | tonumber)}' > "$OAUTH_FILE.new" || {
|
|
rm -f "$OAUTH_FILE.new"
|
|
die "failed to write oauth token (jq pipeline error)"
|
|
}
|
|
if ! jq -e '.access_token and .refresh_token and .fetched_at' < "$OAUTH_FILE.new" >/dev/null 2>&1; then
|
|
rm -f "$OAUTH_FILE.new"
|
|
die "wrote oauth file is missing access_token/refresh_token/fetched_at — aborting"
|
|
fi
|
|
# Pre-chmod the tempfile too — belt-and-suspenders in case secure_install's
|
|
# `install` branch silently no-ops the mode flag on a hostile filesystem.
|
|
chmod 600 "$OAUTH_FILE.new" 2>/dev/null || true
|
|
secure_install "$OAUTH_FILE.new" "$OAUTH_FILE" || die "failed to install oauth file"
|
|
printf '\n✓ logged in. Tokens saved to %s (mode 0600).\n' "$OAUTH_FILE"
|
|
cmd_status
|
|
}
|
|
|
|
cmd_refresh() {
|
|
[ -f "$OAUTH_FILE" ] || die "no oauth file at $OAUTH_FILE — run 'larry-auth.sh login' first"
|
|
local refresh_token; refresh_token=$(jqf "$OAUTH_FILE" -r '.refresh_token // empty')
|
|
[ -n "$refresh_token" ] || die "no refresh_token in $OAUTH_FILE — please run login again"
|
|
|
|
local resp
|
|
resp=$(curl -sS -X POST "$TOKEN_URL" \
|
|
-H "Content-Type: application/json" \
|
|
-H "Accept: application/json" \
|
|
-H "anthropic-beta: oauth-2025-04-20" \
|
|
-H "User-Agent: claude-cli/2.1.85 (larry-anywhere)" \
|
|
-d "$(jq -n --arg cid "$CLIENT_ID" --arg rt "$refresh_token" \
|
|
'{client_id:$cid, grant_type:"refresh_token", refresh_token:$rt}')")
|
|
|
|
if ! printf '%s' "$resp" | jq -e '.access_token' >/dev/null 2>&1; then
|
|
printf 'refresh failed:\n%s\n' "$resp" >&2
|
|
return 1
|
|
fi
|
|
|
|
local now; now=$(date +%s)
|
|
# Force tight perms on the sidecar at create() time — cmd_refresh historically
|
|
# didn't set umask before the tempfile write, so the .new file inherited the
|
|
# process default (often 0022 → 0644). On Cygwin where chmod-after-the-fact
|
|
# may degrade to NTFS-ACL approximation, the tempfile mode at creation is the
|
|
# only knob we can trust.
|
|
umask 077
|
|
# Pre-read the old refresh_token so we don't need --slurpfile (which would
|
|
# take a file path argv-style and break on MobaXterm's Windows-native jq).
|
|
local prev_refresh; prev_refresh=$(jqf "$OAUTH_FILE" -r '.refresh_token // empty')
|
|
if ! printf '%s' "$resp" \
|
|
| jq --arg now "$now" --arg prev "$prev_refresh" \
|
|
'. + {fetched_at: ($now|tonumber), refresh_token: (.refresh_token // $prev)}' \
|
|
> "$OAUTH_FILE.new"; then
|
|
rm -f "$OAUTH_FILE.new"
|
|
printf 'refresh: failed to write new oauth file (jq pipeline error)\n' >&2
|
|
return 1
|
|
fi
|
|
# Validate before clobbering the existing file. If validation fails the old
|
|
# token file stays intact and ensure can still serve the soon-to-expire token.
|
|
if ! jq -e '.access_token and .refresh_token and .fetched_at' < "$OAUTH_FILE.new" >/dev/null 2>&1; then
|
|
rm -f "$OAUTH_FILE.new"
|
|
printf 'refresh: new oauth file is missing required keys — keeping previous file intact\n' >&2
|
|
return 1
|
|
fi
|
|
chmod 600 "$OAUTH_FILE.new" 2>/dev/null || true
|
|
if ! secure_install "$OAUTH_FILE.new" "$OAUTH_FILE"; then
|
|
rm -f "$OAUTH_FILE.new"
|
|
printf 'refresh: failed to install new oauth file\n' >&2
|
|
return 1
|
|
fi
|
|
jqf "$OAUTH_FILE" -r '.access_token'
|
|
}
|
|
|
|
# dbg — gated decision-trace logger. Only fires when LARRY_OAUTH_DEBUG=1 is
|
|
# set in the env. Goes to stderr so it doesn't pollute the token printed on
|
|
# stdout. Caller of `oauth.sh ensure` should capture stderr and surface it
|
|
# if the token is empty.
|
|
dbg() {
|
|
[ "${LARRY_OAUTH_DEBUG:-0}" = "1" ] || return 0
|
|
printf 'oauth.dbg: %s\n' "$*" >&2
|
|
}
|
|
|
|
cmd_ensure() {
|
|
if [ ! -f "$OAUTH_FILE" ]; then
|
|
dbg "ensure: $OAUTH_FILE does not exist — returning empty"
|
|
printf 'ensure: no oauth file at %s\n' "$OAUTH_FILE" >&2
|
|
return 1
|
|
fi
|
|
# Sanity-check the file actually parses as JSON. If it doesn't (truncated,
|
|
# half-written, mid-disk-full), every jqf below silently returns empty which
|
|
# propagates as "token unavailable" with zero diagnostic info upstream.
|
|
if ! jq -e . < "$OAUTH_FILE" >/dev/null 2>&1; then
|
|
dbg "ensure: $OAUTH_FILE failed to parse as JSON — corrupted?"
|
|
printf 'ensure: %s is not valid JSON (corrupted? rerun login)\n' "$OAUTH_FILE" >&2
|
|
return 1
|
|
fi
|
|
local fetched_at expires_in
|
|
fetched_at=$(jqf "$OAUTH_FILE" -r '.fetched_at // 0')
|
|
expires_in=$(jqf "$OAUTH_FILE" -r '.expires_in // 3600')
|
|
# Extra paranoia (v0.6.6): the v0.6.5 /oauth-debug surfaced "syntax error:
|
|
# invalid arithmetic operator" — diagnosed as CR contamination from CRLF
|
|
# JSON files (now stripped in jqf). Belt-and-suspenders: coerce via printf
|
|
# to a pure integer so ANY residual junk (whitespace, NULs, alpha glitches)
|
|
# falls back to 0 instead of crashing the arithmetic expression.
|
|
fetched_at=$(printf '%d' "${fetched_at:-0}" 2>/dev/null || echo 0)
|
|
expires_in=$(printf '%d' "${expires_in:-3600}" 2>/dev/null || echo 3600)
|
|
local now; now=$(date +%s)
|
|
local expires_at=$((fetched_at + expires_in))
|
|
local left=$((expires_at - now))
|
|
dbg "ensure: fetched_at=$fetched_at expires_in=$expires_in now=$now expires_at=$expires_at left=${left}s"
|
|
if [ "$now" -ge $((expires_at - 300)) ]; then
|
|
dbg "ensure: within 300s of expiry — refreshing"
|
|
if ! cmd_refresh >/dev/null 2>&1; then
|
|
dbg "ensure: cmd_refresh failed; calling again with stderr exposed for trace"
|
|
# Re-run with stderr visible so the upstream call_api capture sees WHY.
|
|
cmd_refresh >/dev/null || true
|
|
printf 'ensure: refresh failed (see above)\n' >&2
|
|
return 1
|
|
fi
|
|
dbg "ensure: refresh OK — emitting new access_token"
|
|
jqf "$OAUTH_FILE" -r '.access_token'
|
|
else
|
|
dbg "ensure: token still valid — emitting cached access_token"
|
|
local tok; tok=$(jqf "$OAUTH_FILE" -r '.access_token // empty')
|
|
if [ -z "$tok" ]; then
|
|
dbg "ensure: .access_token is empty in the file"
|
|
printf 'ensure: .access_token missing from %s\n' "$OAUTH_FILE" >&2
|
|
return 1
|
|
fi
|
|
printf '%s' "$tok"
|
|
fi
|
|
}
|
|
|
|
cmd_status() {
|
|
if [ ! -f "$OAUTH_FILE" ]; then
|
|
echo "OAuth: not authenticated (no $OAUTH_FILE)"
|
|
return 1
|
|
fi
|
|
local fetched_at expires_in scope
|
|
fetched_at=$(jqf "$OAUTH_FILE" -r '.fetched_at // 0')
|
|
expires_in=$(jqf "$OAUTH_FILE" -r '.expires_in // 3600')
|
|
scope=$(jqf "$OAUTH_FILE" -r '.scope // "(unknown)"')
|
|
fetched_at=$(printf '%d' "${fetched_at:-0}" 2>/dev/null || echo 0)
|
|
expires_in=$(printf '%d' "${expires_in:-3600}" 2>/dev/null || echo 3600)
|
|
local now; now=$(date +%s)
|
|
local expires_at=$((fetched_at + expires_in))
|
|
local left=$((expires_at - now))
|
|
printf 'OAuth status:\n'
|
|
printf ' file: %s\n' "$OAUTH_FILE"
|
|
printf ' scope: %s\n' "$scope"
|
|
printf ' fetched_at: %s\n' "$(date -r "$fetched_at" 2>/dev/null || date -d "@$fetched_at" 2>/dev/null)"
|
|
printf ' expires_in: %d s\n' "$expires_in"
|
|
if [ "$left" -le 0 ]; then
|
|
printf ' state: EXPIRED (%ds ago) — will auto-refresh on next call\n' "$((-left))"
|
|
else
|
|
printf ' state: valid for %d more seconds (~%d min)\n' "$left" "$((left/60))"
|
|
fi
|
|
}
|
|
|
|
# cmd_debug — dump everything Mack would need to figure out why ensure is
|
|
# returning empty. SAFE to share: token previews are truncated to first 20
|
|
# chars only. Goes to stdout (so the user can copy-paste).
|
|
cmd_debug() {
|
|
printf '=== Larry OAuth diagnostic ===\n'
|
|
printf 'LARRY_HOME=%s\n' "$LARRY_HOME"
|
|
printf 'OAUTH_FILE=%s\n' "$OAUTH_FILE"
|
|
|
|
# jq binary identity — helps distinguish Cygwin jq from Windows-native jq
|
|
# when paths look weird on MobaXterm.
|
|
printf '\n[jq]\n'
|
|
local jq_path; jq_path=$(command -v jq 2>/dev/null || true)
|
|
printf ' path: %s\n' "${jq_path:-(not found)}"
|
|
if [ -n "$jq_path" ]; then
|
|
printf ' version: %s\n' "$(jq --version 2>&1 | head -n1)"
|
|
# On Cygwin, `file` may not exist but the path tells us if it's a .exe.
|
|
case "$jq_path" in
|
|
*.exe) printf ' flavor: Windows-native (.exe — uses Windows paths in argv)\n' ;;
|
|
*) printf ' flavor: Unix-style (POSIX paths in argv)\n' ;;
|
|
esac
|
|
fi
|
|
|
|
# cygpath translation — only relevant on Cygwin/MobaXterm. If present, show
|
|
# how the OAUTH_FILE looks to a Windows binary.
|
|
if command -v cygpath >/dev/null 2>&1; then
|
|
printf '\n[cygpath]\n'
|
|
printf ' unix: %s\n' "$OAUTH_FILE"
|
|
printf ' windows: %s\n' "$(cygpath -w "$OAUTH_FILE" 2>&1 || echo '(cygpath failed)')"
|
|
fi
|
|
|
|
# File state
|
|
printf '\n[file]\n'
|
|
if [ ! -f "$OAUTH_FILE" ]; then
|
|
printf ' status: MISSING — run larry-auth.sh login\n'
|
|
return 0
|
|
fi
|
|
# Mode, size, mtime — across mac (BSD stat) and linux (GNU stat)
|
|
local stat_line
|
|
stat_line=$(stat -f 'mode=%Sp size=%z mtime=%Sm' "$OAUTH_FILE" 2>/dev/null \
|
|
|| stat -c 'mode=%A size=%s mtime=%y' "$OAUTH_FILE" 2>/dev/null \
|
|
|| echo '(stat unavailable)')
|
|
printf ' %s\n' "$stat_line"
|
|
if ! jq -e . < "$OAUTH_FILE" >/dev/null 2>&1; then
|
|
printf ' status: CORRUPTED — not valid JSON\n'
|
|
printf ' first 80 bytes (sanitized): '
|
|
head -c 80 "$OAUTH_FILE" | tr -d '\n' | head -c 80
|
|
printf '\n'
|
|
return 0
|
|
fi
|
|
printf ' status: parses as JSON\n'
|
|
|
|
# Line-ending check — Cygwin/MobaXterm tools sometimes write JSON with CRLF
|
|
# line endings (especially if the file gets edited by a Windows editor). The
|
|
# trailing \r on each line propagates through `jq -r` and crashes downstream
|
|
# bash arithmetic. Show byte counts of CR vs LF so we can spot CRLF taint.
|
|
local cr_count lf_count
|
|
cr_count=$(tr -dc '\r' < "$OAUTH_FILE" | wc -c | tr -d ' ')
|
|
lf_count=$(tr -dc '\n' < "$OAUTH_FILE" | wc -c | tr -d ' ')
|
|
printf ' line endings: CR=%s LF=%s' "$cr_count" "$lf_count"
|
|
if [ "$cr_count" -gt 0 ]; then
|
|
printf ' (CRLF detected — v0.6.6 jqf strips these defensively)\n'
|
|
else
|
|
printf ' (clean LF)\n'
|
|
fi
|
|
|
|
# Token math
|
|
printf '\n[token math]\n'
|
|
local fetched_at expires_in scope
|
|
fetched_at=$(jqf "$OAUTH_FILE" -r '.fetched_at // 0')
|
|
expires_in=$(jqf "$OAUTH_FILE" -r '.expires_in // 3600')
|
|
scope=$(jqf "$OAUTH_FILE" -r '.scope // "(missing)"')
|
|
# Belt-and-suspenders integer coercion (see cmd_ensure for the why).
|
|
fetched_at=$(printf '%d' "${fetched_at:-0}" 2>/dev/null || echo 0)
|
|
expires_in=$(printf '%d' "${expires_in:-3600}" 2>/dev/null || echo 3600)
|
|
local now; now=$(date +%s)
|
|
local expires_at=$((fetched_at + expires_in))
|
|
local left=$((expires_at - now))
|
|
local refresh_at=$((expires_at - 300))
|
|
local would_refresh="NO"
|
|
[ "$now" -ge "$refresh_at" ] && would_refresh="YES (within 300s of expiry)"
|
|
# Human-readable timestamps that work on both BSD and GNU date.
|
|
local fetched_human expires_human now_human
|
|
fetched_human=$(date -r "$fetched_at" 2>/dev/null || date -d "@$fetched_at" 2>/dev/null || echo "(date unavailable)")
|
|
expires_human=$(date -r "$expires_at" 2>/dev/null || date -d "@$expires_at" 2>/dev/null || echo "(date unavailable)")
|
|
now_human=$(date 2>/dev/null || echo "(date unavailable)")
|
|
printf ' fetched_at: %s (%s)\n' "$fetched_at" "$fetched_human"
|
|
printf ' expires_in: %s seconds\n' "$expires_in"
|
|
printf ' expires_at: %s (%s)\n' "$expires_at" "$expires_human"
|
|
printf ' now: %s (%s)\n' "$now" "$now_human"
|
|
printf ' seconds_left: %s (~%d min)\n' "$left" "$((left/60))"
|
|
printf ' would_refresh: %s\n' "$would_refresh"
|
|
printf ' scope: %s\n' "$scope"
|
|
|
|
# Token previews — first 20 chars only, safe to share for "is it present"
|
|
printf '\n[tokens (truncated)]\n'
|
|
local at rt
|
|
at=$(jqf "$OAUTH_FILE" -r '.access_token // empty')
|
|
rt=$(jqf "$OAUTH_FILE" -r '.refresh_token // empty')
|
|
if [ -z "$at" ]; then
|
|
printf ' access_token: (EMPTY/MISSING)\n'
|
|
else
|
|
printf ' access_token: %s... (len=%d)\n' "$(printf '%s' "$at" | head -c 20)" "${#at}"
|
|
fi
|
|
if [ -z "$rt" ]; then
|
|
printf ' refresh_token: (EMPTY/MISSING)\n'
|
|
else
|
|
printf ' refresh_token: %s... (len=%d)\n' "$(printf '%s' "$rt" | head -c 20)" "${#rt}"
|
|
fi
|
|
|
|
# Live ensure dry-run with stderr exposed so we see every decision.
|
|
printf '\n[live ensure trace]\n'
|
|
printf ' Running: LARRY_OAUTH_DEBUG=1 cmd_ensure (stderr below, stdout suppressed)\n'
|
|
local trace_token trace_err trace_rc
|
|
trace_err=$(mktemp)
|
|
trace_token=$(LARRY_OAUTH_DEBUG=1 cmd_ensure 2>"$trace_err"); trace_rc=$?
|
|
printf ' exit_code: %d\n' "$trace_rc"
|
|
printf ' token_emitted: %s\n' "$([ -n "$trace_token" ] && echo "YES (len=${#trace_token})" || echo "NO (empty)")"
|
|
printf ' stderr trace:\n'
|
|
sed 's/^/ /' "$trace_err"
|
|
rm -f "$trace_err"
|
|
|
|
printf '\n=== end diagnostic ===\n'
|
|
}
|
|
|
|
cmd_logout() {
|
|
if [ -f "$OAUTH_FILE" ]; then
|
|
rm -f "$OAUTH_FILE"
|
|
echo "logged out (removed $OAUTH_FILE)"
|
|
else
|
|
echo "no token file to remove"
|
|
fi
|
|
}
|
|
|
|
case "${1:-status}" in
|
|
login) cmd_login ;;
|
|
refresh) cmd_refresh ;;
|
|
ensure) cmd_ensure ;;
|
|
status) cmd_status ;;
|
|
debug) cmd_debug ;;
|
|
logout) cmd_logout ;;
|
|
-h|--help|help) sed -n '2,30p' "$0" ;;
|
|
*) die "unknown subcommand: ${1:-} (try 'login|refresh|ensure|status|debug|logout')" ;;
|
|
esac
|