scripts/drive.sh
scripts/drive.shBrowse 3 files
4,554 tokens
13,830 bytes
Token encoding: o200k_base
Snapshot 24fd22b
← Back to SKILL.md
1#!/usr/bin/env bash2set -euo pipefail3 4BASE_URL="https://here.now"5CREDENTIALS_FILE="$HOME/.herenow/credentials"6API_KEY="${HERENOW_API_KEY:-}"7DRIVE_TOKEN="${HERENOW_DRIVE_TOKEN:-}"8ALLOW_NON_HERENOW_BASE_URL=09MAX_FILE_BYTES=$((500 * 1024 * 1024))10 11usage() {12 cat <<'USAGE'13Usage: drive.sh [global options] <command> [args]14 15Global options:16 --api-key <key> Account API key (or $HERENOW_API_KEY / ~/.herenow/credentials)17 --token <drv_live_...> Drive token (or $HERENOW_DRIVE_TOKEN)18 --base-url <url> API base (default: https://here.now)19 --allow-nonherenow-base-url20 21Commands:22 create [name] [--default]23 default24 ls25 ls <drive> [prefix]26 cat <drive> <path>27 put <drive> <path> --from <local-file>28 import <drive> <prefix> --from <local-folder> [--dry-run]29 export <drive> <prefix> --to <local-folder> [--dry-run]30 rm <drive> <path> [--recursive --confirm <path>]31 share <drive> --perms read|write [--prefix notes/] [--ttl 30d] [--label text] [--manage-tokens]32 tokens <drive>33 revoke <drive> <tokenId>34 delete <drive> --confirm "<drive name>"35USAGE36 exit 137}38 39die() { echo "error: $1" >&2; exit 1; }40 41SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"42SKILL_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"43BUNDLED_JQ="${SKILL_DIR}/bin/jq"44 45if [[ -x "$BUNDLED_JQ" ]]; then46 JQ_BIN="$BUNDLED_JQ"47elif command -v jq >/dev/null 2>&1; then48 JQ_BIN="$(command -v jq)"49else50 die "requires jq"51fi52 53for cmd in curl file; do54 command -v "$cmd" >/dev/null 2>&1 || die "requires $cmd"55done56 57while [[ $# -gt 0 ]]; do58 case "$1" in59 --api-key) API_KEY="$2"; shift 2 ;;60 --token) DRIVE_TOKEN="$2"; shift 2 ;;61 --base-url) BASE_URL="$2"; shift 2 ;;62 --allow-nonherenow-base-url) ALLOW_NON_HERENOW_BASE_URL=1; shift ;;63 --help|-h) usage ;;64 --*) die "unknown global option: $1" ;;65 *) break ;;66 esac67done68 69CMD="${1:-}"70[[ -n "$CMD" ]] || usage71shift || true72 73if [[ -z "$API_KEY" && -z "$DRIVE_TOKEN" && -f "$CREDENTIALS_FILE" ]]; then74 API_KEY=$(tr -d '[:space:]' < "$CREDENTIALS_FILE")75fi76 77BASE_URL="${BASE_URL%/}"78if [[ "$BASE_URL" != "https://here.now" && "$ALLOW_NON_HERENOW_BASE_URL" -ne 1 ]]; then79 if [[ -n "$API_KEY" || -n "$DRIVE_TOKEN" ]]; then80 die "refusing to send credentials to non-default base URL; pass --allow-nonherenow-base-url to override"81 fi82fi83 84auth_header=()85if [[ -n "$DRIVE_TOKEN" ]]; then86 auth_header=(-H "authorization: Bearer $DRIVE_TOKEN")87elif [[ -n "$API_KEY" ]]; then88 auth_header=(-H "authorization: Bearer $API_KEY")89else90 die "missing credentials; set HERENOW_API_KEY, HERENOW_DRIVE_TOKEN, or ~/.herenow/credentials"91fi92 93compute_sha256() {94 local f="$1"95 if command -v sha256sum >/dev/null 2>&1; then96 sha256sum "$f" | cut -d' ' -f197 else98 shasum -a 256 "$f" | cut -d' ' -f199 fi100}101 102guess_content_type() {103 local f="$1"104 case "${f##*.}" in105 html|htm) echo "text/html; charset=utf-8" ;;106 css) echo "text/css; charset=utf-8" ;;107 js|mjs) echo "text/javascript; charset=utf-8" ;;108 json) echo "application/json; charset=utf-8" ;;109 md|txt) echo "text/plain; charset=utf-8" ;;110 svg) echo "image/svg+xml" ;;111 png) echo "image/png" ;;112 jpg|jpeg) echo "image/jpeg" ;;113 gif) echo "image/gif" ;;114 webp) echo "image/webp" ;;115 pdf) echo "application/pdf" ;;116 *) file --brief --mime-type "$f" 2>/dev/null || echo "application/octet-stream" ;;117 esac118}119 120api_json() {121 local method="$1"; shift122 local url="$1"; shift123 local body="${1:-}"124 local tmp125 tmp=$(mktemp)126 local code127 if [[ -n "$body" ]]; then128 code=$(curl -sS -o "$tmp" -w "%{http_code}" -X "$method" "$url" "${auth_header[@]}" -H "content-type: application/json" -d "$body")129 else130 code=$(curl -sS -o "$tmp" -w "%{http_code}" -X "$method" "$url" "${auth_header[@]}")131 fi132 if [[ "$code" -lt 200 || "$code" -ge 300 ]]; then133 local err134 err=$("$JQ_BIN" -r '.error // empty' "$tmp" 2>/dev/null || true)135 [[ -n "$err" ]] || err="$(cat "$tmp")"136 rm -f "$tmp"137 die "HTTP $code: $err"138 fi139 cat "$tmp"140 rm -f "$tmp"141}142 143urlenc() {144 "$JQ_BIN" -nr --arg v "$1" '$v|@uri'145}146 147urlenc_path() {148 local path="$1"149 local out=""150 local part151 IFS='/' read -r -a parts <<< "$path"152 for part in "${parts[@]}"; do153 [[ -n "$out" ]] && out="$out/"154 out="$out$(urlenc "$part")"155 done156 echo "$out"157}158 159resolve_drive() {160 local name="$1"161 if [[ "$name" == drv_* ]]; then162 echo "$name"163 return164 fi165 if [[ -n "$DRIVE_TOKEN" ]]; then166 die "drive tokens must reference drives by drv_ id; use account credentials to resolve drive names"167 fi168 if [[ "$name" == "default" || "$name" == "my-drive" || "$name" == "My Drive" ]]; then169 api_json GET "$BASE_URL/api/v1/drives/default" | "$JQ_BIN" -r '.drive.id'170 return171 fi172 local rows count173 rows=$(api_json GET "$BASE_URL/api/v1/drives" | "$JQ_BIN" --arg n "$name" '[.drives[] | select(.name == $n)]')174 count=$(echo "$rows" | "$JQ_BIN" 'length')175 [[ "$count" -eq 1 ]] || die "drive name '$name' matched $count drives; use a drv_ id"176 echo "$rows" | "$JQ_BIN" -r '.[0].id'177}178 179drive_head() {180 local id="$1"181 api_json GET "$BASE_URL/api/v1/drives/$id" | "$JQ_BIN" -r '.drive.headVersionId // .headVersionId // empty'182}183 184file_meta() {185 local id="$1"186 local path="$2"187 local prefix188 prefix=$(urlenc "$path")189 api_json GET "$BASE_URL/api/v1/drives/$id/files?prefix=$prefix&limit=200" | "$JQ_BIN" -c --arg p "$path" '.files[]? | select(.path == $p)' | head -n 1190}191 192put_file() {193 local drive="$1"; shift194 local path="$1"; shift195 local local_file=""196 while [[ $# -gt 0 ]]; do197 case "$1" in198 --from) local_file="$2"; shift 2 ;;199 *) die "unexpected put argument: $1" ;;200 esac201 done202 [[ -f "$local_file" ]] || die "--from must be a file"203 local id sz ct sha meta body upload upload_url upload_id http_code204 id=$(resolve_drive "$drive")205 sz=$(wc -c < "$local_file" | tr -d ' ')206 [[ "$sz" -le "$MAX_FILE_BYTES" ]] || die "$path exceeds the $MAX_FILE_BYTES byte Drive file limit"207 ct=$(guess_content_type "$local_file")208 sha=$(compute_sha256 "$local_file")209 meta=$(file_meta "$id" "$path" || true)210 body=$("$JQ_BIN" -n --arg p "$path" --argjson s "$sz" --arg c "$ct" --arg sha "$sha" \211 '{path:$p,size:$s,contentType:$c,sha256:$sha}')212 if [[ -n "$meta" ]]; then213 etag=$(echo "$meta" | "$JQ_BIN" -r '.etag')214 body=$(echo "$body" | "$JQ_BIN" --arg e "$etag" '.ifMatch = $e')215 else216 body=$(echo "$body" | "$JQ_BIN" '.ifNoneMatch = "*"')217 fi218 upload=$(api_json POST "$BASE_URL/api/v1/drives/$id/files/uploads" "$body")219 upload_url=$(echo "$upload" | "$JQ_BIN" -r '.uploadUrl')220 upload_id=$(echo "$upload" | "$JQ_BIN" -r '.uploadId')221 http_code=$(curl -sS -o /dev/null -w "%{http_code}" -X PUT "$upload_url" -H "Content-Type: $ct" --data-binary "@$local_file")222 [[ "$http_code" -ge 200 && "$http_code" -lt 300 ]] || die "upload failed for $path (HTTP $http_code)"223 api_json POST "$BASE_URL/api/v1/drives/$id/files/finalize" "$("$JQ_BIN" -n --arg u "$upload_id" '{uploadId:$u}')" | "$JQ_BIN" .224}225 226case "$CMD" in227 create)228 name=""229 is_default="false"230 while [[ $# -gt 0 ]]; do231 case "$1" in232 --default) is_default="true"; shift ;;233 *) [[ -z "$name" ]] && name="$1" || die "unexpected argument: $1"; shift ;;234 esac235 done236 body=$("$JQ_BIN" -n --arg n "$name" --argjson d "$is_default" '{isDefault:$d} + (if $n == "" then {} else {name:$n} end)')237 api_json POST "$BASE_URL/api/v1/drives" "$body" | "$JQ_BIN" .238 ;;239 default)240 api_json GET "$BASE_URL/api/v1/drives/default" | "$JQ_BIN" .241 ;;242 ls)243 if [[ $# -eq 0 ]]; then244 [[ -z "$DRIVE_TOKEN" ]] || die "drive tokens cannot list drives; pass a drv_ id"245 api_json GET "$BASE_URL/api/v1/drives" | "$JQ_BIN" .246 else247 id=$(resolve_drive "$1")248 prefix="${2:-}"249 api_json GET "$BASE_URL/api/v1/drives/$id/files?prefix=$(urlenc "$prefix")" | "$JQ_BIN" .250 fi251 ;;252 cat)253 [[ $# -eq 2 ]] || die "usage: drive.sh cat <drive> <path>"254 id=$(resolve_drive "$1")255 curl -fsS "$BASE_URL/api/v1/drives/$id/files/$(urlenc_path "$2")" "${auth_header[@]}"256 ;;257 put)258 [[ $# -ge 2 ]] || die "usage: drive.sh put <drive> <path> --from <local-file>"259 put_file "$@"260 ;;261 import)262 [[ $# -ge 2 ]] || die "usage: drive.sh import <drive> <prefix> --from <local-folder> [--dry-run]"263 drive="$1"; prefix="${2%/}"; shift 2264 from=""; dry=0265 while [[ $# -gt 0 ]]; do266 case "$1" in267 --from) from="$2"; shift 2 ;;268 --dry-run) dry=1; shift ;;269 *) die "unexpected import argument: $1" ;;270 esac271 done272 [[ -d "$from" ]] || die "--from must be a folder"273 uploaded=0274 skipped=0275 failed=0276 planned=0277 while IFS= read -r -d '' f; do278 rel="${f#$from/}"279 [[ "$rel" == .git/* || "$rel" == node_modules/* || "$rel" == ".DS_Store" || "$rel" == */.DS_Store ]] && continue280 planned=$((planned + 1))281 sz=$(wc -c < "$f" | tr -d ' ')282 if [[ "$sz" -gt "$MAX_FILE_BYTES" ]]; then283 echo "skip oversized $f ($sz bytes > $MAX_FILE_BYTES)" >&2284 skipped=$((skipped + 1))285 continue286 fi287 dest="$rel"288 [[ -n "$prefix" ]] && dest="$prefix/$rel"289 if [[ "$dry" -eq 1 ]]; then290 echo "upload $f -> $dest"291 skipped=$((skipped + 1))292 else293 if (put_file "$drive" "$dest" --from "$f" >/dev/null); then294 uploaded=$((uploaded + 1))295 else296 failed=$((failed + 1))297 fi298 fi299 done < <(find "$from" -type f -print0 | sort -z)300 echo "planned=$planned uploaded=$uploaded skipped=$skipped failed=$failed"301 [[ "$failed" -eq 0 ]] || exit 1302 ;;303 export)304 [[ $# -ge 2 ]] || die "usage: drive.sh export <drive> <prefix> --to <local-folder> [--dry-run]"305 id=$(resolve_drive "$1"); prefix="${2%/}"; shift 2306 to=""; dry=0307 while [[ $# -gt 0 ]]; do308 case "$1" in309 --to) to="$2"; shift 2 ;;310 --dry-run) dry=1; shift ;;311 *) die "unexpected export argument: $1" ;;312 esac313 done314 [[ -n "$to" ]] || die "--to is required"315 cursor=""316 total=0317 while true; do318 url="$BASE_URL/api/v1/drives/$id/files?prefix=$(urlenc "$prefix")&limit=200"319 [[ -n "$cursor" ]] && url="$url&cursor=$(urlenc "$cursor")"320 files=$(api_json GET "$url")321 while IFS= read -r p; do322 [[ -n "$p" ]] || continue323 rel="$p"324 [[ -n "$prefix" ]] && rel="${p#$prefix/}"325 out="$to/$rel"326 if [[ "$dry" -eq 1 ]]; then327 echo "download $p -> $out"328 else329 mkdir -p "$(dirname "$out")"330 curl -fsS "$BASE_URL/api/v1/drives/$id/files/$(urlenc_path "$p")" "${auth_header[@]}" -o "$out"331 fi332 total=$((total + 1))333 done < <(echo "$files" | "$JQ_BIN" -r '.files[].path')334 cursor=$(echo "$files" | "$JQ_BIN" -r '.nextCursor // empty')335 [[ -n "$cursor" ]] || break336 done337 echo "files=$total"338 ;;339 rm)340 [[ $# -ge 2 ]] || die "usage: drive.sh rm <drive> <path> [--recursive --confirm <path>]"341 id=$(resolve_drive "$1"); path="$2"; shift 2342 recursive=0; confirm=""343 while [[ $# -gt 0 ]]; do344 case "$1" in345 --recursive) recursive=1; shift ;;346 --confirm) confirm="$2"; shift 2 ;;347 *) die "unexpected rm argument: $1" ;;348 esac349 done350 if [[ "$recursive" -eq 1 ]]; then351 [[ "$confirm" == "$path" ]] || die "recursive delete requires --confirm '$path'"352 head=$(drive_head "$id")353 api_json DELETE "$BASE_URL/api/v1/drives/$id/files/$(urlenc_path "$path")?recursive=true&baseVersionId=$(urlenc "$head")" | "$JQ_BIN" .354 else355 meta=$(file_meta "$id" "$path")356 etag=$(echo "$meta" | "$JQ_BIN" -r '.etag')357 curl -fsS -X DELETE "$BASE_URL/api/v1/drives/$id/files/$(urlenc_path "$path")" "${auth_header[@]}" -H "If-Match: $etag" | "$JQ_BIN" .358 fi359 ;;360 share)361 [[ $# -ge 1 ]] || die "usage: drive.sh share <drive> --perms read|write [--prefix notes/] [--ttl 30d] [--label text] [--manage-tokens]"362 id=$(resolve_drive "$1"); shift363 perms="write"; prefix=""; ttl=""; label=""; manage_tokens="false"364 while [[ $# -gt 0 ]]; do365 case "$1" in366 --perms) perms="$2"; shift 2 ;;367 --prefix) prefix="$2"; shift 2 ;;368 --ttl) ttl="$2"; shift 2 ;;369 --label) label="$2"; shift 2 ;;370 --manage-tokens) manage_tokens="true"; shift ;;371 *) die "unexpected share argument: $1" ;;372 esac373 done374 body=$("$JQ_BIN" -n --arg p "$perms" --arg pp "$prefix" --arg ttl "$ttl" --arg label "$label" --argjson mt "$manage_tokens" \375 '{perms:$p} + (if $mt then {manageTokens:true} else {} end) + (if $ttl == "" then {} else {ttl:$ttl} end) + (if $pp == "" then {} else {pathPrefix:$pp} end) + (if $label == "" then {} else {label:$label} end)')376 api_json POST "$BASE_URL/api/v1/drives/$id/tokens" "$body" | "$JQ_BIN" -r '.shareBlock'377 ;;378 tokens)379 [[ $# -eq 1 ]] || die "usage: drive.sh tokens <drive>"380 id=$(resolve_drive "$1")381 api_json GET "$BASE_URL/api/v1/drives/$id/tokens" | "$JQ_BIN" .382 ;;383 revoke)384 [[ $# -eq 2 ]] || die "usage: drive.sh revoke <drive> <tokenId>"385 id=$(resolve_drive "$1")386 api_json DELETE "$BASE_URL/api/v1/drives/$id/tokens/$2" | "$JQ_BIN" .387 ;;388 delete)389 [[ $# -ge 1 ]] || die "usage: drive.sh delete <drive> --confirm <drive name>"390 id=$(resolve_drive "$1"); shift391 confirm=""392 while [[ $# -gt 0 ]]; do393 case "$1" in394 --confirm) confirm="$2"; shift 2 ;;395 *) die "unexpected delete argument: $1" ;;396 esac397 done398 drive=$(api_json GET "$BASE_URL/api/v1/drives/$id")399 name=$(echo "$drive" | "$JQ_BIN" -r '.drive.name')400 [[ "$confirm" == "$name" ]] || die "delete requires --confirm '$name'"401 api_json DELETE "$BASE_URL/api/v1/drives/$id" | "$JQ_BIN" .402 ;;403 *)404 die "unknown command: $CMD"405 ;;406esac407