kadi-deploy 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +6 -0
- package/.prettierrc +6 -0
- package/README.md +589 -0
- package/agent.json +23 -0
- package/index.js +11 -0
- package/package.json +42 -0
- package/quick-command.txt +92 -0
- package/scripts/preflight.js +458 -0
- package/scripts/preflight.sh +300 -0
- package/src/cli/bid-selector.ts +222 -0
- package/src/cli/colors.ts +216 -0
- package/src/cli/index.ts +11 -0
- package/src/cli/prompts.ts +190 -0
- package/src/cli/spinners.ts +165 -0
- package/src/commands/deploy-local.ts +475 -0
- package/src/commands/deploy.ts +1342 -0
- package/src/commands/down.ts +679 -0
- package/src/commands/index.ts +10 -0
- package/src/commands/lock.ts +571 -0
- package/src/config/agent-loader.ts +177 -0
- package/src/config/index.ts +9 -0
- package/src/display/deployment-info.ts +220 -0
- package/src/display/pricing.ts +137 -0
- package/src/display/resources.ts +234 -0
- package/src/enhanced-registry-manager.ts +892 -0
- package/src/index.ts +307 -0
- package/src/infrastructure/registry.ts +269 -0
- package/src/schemas/profiles.ts +529 -0
- package/src/secrets/broker-urls.ts +109 -0
- package/src/secrets/handshake.ts +407 -0
- package/src/secrets/index.ts +69 -0
- package/src/secrets/inject-env.ts +171 -0
- package/src/secrets/nonce.ts +31 -0
- package/src/secrets/normalize.ts +204 -0
- package/src/secrets/prepare.ts +152 -0
- package/src/secrets/validate.ts +243 -0
- package/src/secrets/vault.ts +80 -0
- package/src/types/akash.ts +116 -0
- package/src/types/container-registry-ability.d.ts +158 -0
- package/src/types/external.ts +49 -0
- package/src/types.ts +211 -0
- package/src/utils/akt-price.ts +74 -0
- package/tests/agent-loader.test.ts +239 -0
- package/tests/autonomous.test.ts +244 -0
- package/tests/down.test.ts +1143 -0
- package/tests/lock.test.ts +1148 -0
- package/tests/nonce.test.ts +34 -0
- package/tests/normalize.test.ts +270 -0
- package/tests/secrets-schema.test.ts +301 -0
- package/tests/types.test.ts +198 -0
- package/tsconfig.json +18 -0
- package/vitest.config.ts +9 -0
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# ──────────────────────────────────────────────────────────────
|
|
3
|
+
# kadi-deploy preflight script
|
|
4
|
+
#
|
|
5
|
+
# Runs during `kadi install` to validate prerequisites.
|
|
6
|
+
# Checks for required and optional dependencies:
|
|
7
|
+
# - Node.js 18+ (required)
|
|
8
|
+
# - Docker or Podman (required for local deploys)
|
|
9
|
+
# - frpc (required for KADI tunnel — Akash deploys)
|
|
10
|
+
#
|
|
11
|
+
# If frpc is missing, attempts auto-install via Homebrew (macOS)
|
|
12
|
+
# or provides clear instructions for manual installation.
|
|
13
|
+
# ──────────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
set -euo pipefail
|
|
16
|
+
|
|
17
|
+
# ── PATH augmentation ────────────────────────────────────────
|
|
18
|
+
# Non-interactive shells (SSH, lifecycle runners) often have a
|
|
19
|
+
# minimal PATH. Ensure common install locations are included so
|
|
20
|
+
# we can find node, brew, docker, frpc, etc.
|
|
21
|
+
EXTRA_PATHS=(
|
|
22
|
+
/opt/homebrew/bin
|
|
23
|
+
/opt/homebrew/sbin
|
|
24
|
+
/usr/local/bin
|
|
25
|
+
/usr/local/sbin
|
|
26
|
+
"$HOME/.local/bin"
|
|
27
|
+
"$HOME/bin"
|
|
28
|
+
"$HOME/.nvm/versions/node/*/bin" # nvm
|
|
29
|
+
"$HOME/.volta/bin" # volta
|
|
30
|
+
"$HOME/.fnm/aliases/default/bin" # fnm
|
|
31
|
+
)
|
|
32
|
+
for p in "${EXTRA_PATHS[@]}"; do
|
|
33
|
+
# Expand globs (e.g. nvm node versions — pick the latest)
|
|
34
|
+
for resolved in $p; do
|
|
35
|
+
if [[ -d "$resolved" ]] && ! echo "$PATH" | tr ':' '\n' | grep -qF "$resolved"; then
|
|
36
|
+
PATH="$resolved:$PATH"
|
|
37
|
+
fi
|
|
38
|
+
done
|
|
39
|
+
done
|
|
40
|
+
export PATH
|
|
41
|
+
|
|
42
|
+
# If nvm is installed, source it to get the correct node
|
|
43
|
+
if [[ -s "$HOME/.nvm/nvm.sh" ]]; then
|
|
44
|
+
# shellcheck disable=SC1091
|
|
45
|
+
source "$HOME/.nvm/nvm.sh" 2>/dev/null || true
|
|
46
|
+
fi
|
|
47
|
+
|
|
48
|
+
# Colors (if terminal supports them)
|
|
49
|
+
if [[ -t 1 ]] && command -v tput &>/dev/null; then
|
|
50
|
+
GREEN=$(tput setaf 2)
|
|
51
|
+
YELLOW=$(tput setaf 3)
|
|
52
|
+
RED=$(tput setaf 1)
|
|
53
|
+
BOLD=$(tput bold)
|
|
54
|
+
RESET=$(tput sgr0)
|
|
55
|
+
else
|
|
56
|
+
GREEN="" YELLOW="" RED="" BOLD="" RESET=""
|
|
57
|
+
fi
|
|
58
|
+
|
|
59
|
+
ok() { echo "${GREEN} ✓${RESET} $*"; }
|
|
60
|
+
warn() { echo "${YELLOW} ⚠${RESET} $*"; }
|
|
61
|
+
fail() { echo "${RED} ✗${RESET} $*"; }
|
|
62
|
+
info() { echo " $*"; }
|
|
63
|
+
|
|
64
|
+
echo "${BOLD}kadi-deploy preflight${RESET}"
|
|
65
|
+
echo ""
|
|
66
|
+
|
|
67
|
+
ERRORS=0
|
|
68
|
+
WARNINGS=0
|
|
69
|
+
|
|
70
|
+
# ── Node.js ──────────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
echo "Checking Node.js..."
|
|
73
|
+
if command -v node &>/dev/null; then
|
|
74
|
+
NODE_VERSION=$(node --version 2>/dev/null | sed 's/v//')
|
|
75
|
+
NODE_MAJOR=$(echo "$NODE_VERSION" | cut -d. -f1)
|
|
76
|
+
if [[ "$NODE_MAJOR" -ge 18 ]]; then
|
|
77
|
+
ok "Node.js $NODE_VERSION"
|
|
78
|
+
else
|
|
79
|
+
fail "Node.js $NODE_VERSION (18+ required)"
|
|
80
|
+
ERRORS=$((ERRORS + 1))
|
|
81
|
+
fi
|
|
82
|
+
else
|
|
83
|
+
fail "Node.js not found"
|
|
84
|
+
info "Install from https://nodejs.org/"
|
|
85
|
+
ERRORS=$((ERRORS + 1))
|
|
86
|
+
fi
|
|
87
|
+
|
|
88
|
+
# ── Container engine (Docker / Podman) ───────────────────────
|
|
89
|
+
|
|
90
|
+
echo "Checking container engine..."
|
|
91
|
+
if command -v docker &>/dev/null; then
|
|
92
|
+
DOCKER_VERSION=$(docker --version 2>/dev/null | head -1 || echo "unknown")
|
|
93
|
+
ok "Docker: $DOCKER_VERSION"
|
|
94
|
+
elif command -v podman &>/dev/null; then
|
|
95
|
+
PODMAN_VERSION=$(podman --version 2>/dev/null | head -1 || echo "unknown")
|
|
96
|
+
ok "Podman: $PODMAN_VERSION"
|
|
97
|
+
else
|
|
98
|
+
warn "No container engine found (Docker or Podman)"
|
|
99
|
+
info "Required for local deploys. Not needed for Akash-only deploys."
|
|
100
|
+
info "Install Docker: https://docs.docker.com/get-docker/"
|
|
101
|
+
WARNINGS=$((WARNINGS + 1))
|
|
102
|
+
fi
|
|
103
|
+
|
|
104
|
+
# ── frpc (KADI tunnel client) ────────────────────────────────
|
|
105
|
+
|
|
106
|
+
echo "Checking frpc (tunnel client)..."
|
|
107
|
+
|
|
108
|
+
# Check if frpc is available in PATH
|
|
109
|
+
FRPC_FOUND=0
|
|
110
|
+
FRPC_PATH=""
|
|
111
|
+
|
|
112
|
+
if command -v frpc &>/dev/null; then
|
|
113
|
+
FRPC_PATH=$(command -v frpc)
|
|
114
|
+
FRPC_FOUND=1
|
|
115
|
+
else
|
|
116
|
+
# Check common install locations not in PATH
|
|
117
|
+
for candidate in \
|
|
118
|
+
/usr/local/bin/frpc \
|
|
119
|
+
/opt/homebrew/bin/frpc \
|
|
120
|
+
"$HOME/.local/bin/frpc" \
|
|
121
|
+
"$HOME/bin/frpc"; do
|
|
122
|
+
if [[ -x "$candidate" ]]; then
|
|
123
|
+
FRPC_PATH="$candidate"
|
|
124
|
+
FRPC_FOUND=1
|
|
125
|
+
break
|
|
126
|
+
fi
|
|
127
|
+
done
|
|
128
|
+
fi
|
|
129
|
+
|
|
130
|
+
if [[ "$FRPC_FOUND" -eq 1 ]]; then
|
|
131
|
+
FRPC_VERSION=$("$FRPC_PATH" --version 2>/dev/null || echo "unknown")
|
|
132
|
+
ok "frpc $FRPC_VERSION ($FRPC_PATH)"
|
|
133
|
+
else
|
|
134
|
+
# frpc not found — try to install it
|
|
135
|
+
INSTALLED=0
|
|
136
|
+
|
|
137
|
+
# macOS: try Homebrew first, then direct download
|
|
138
|
+
if [[ "$(uname -s)" == "Darwin" ]]; then
|
|
139
|
+
if command -v brew &>/dev/null; then
|
|
140
|
+
warn "frpc not found — attempting install via Homebrew..."
|
|
141
|
+
if brew install frpc 2>/dev/null; then
|
|
142
|
+
# Verify the binary actually exists (Homebrew sometimes installs the formula
|
|
143
|
+
# but macOS XProtect may quarantine/delete the binary)
|
|
144
|
+
if command -v frpc &>/dev/null; then
|
|
145
|
+
FRPC_VERSION=$(frpc --version 2>/dev/null || echo "unknown")
|
|
146
|
+
ok "frpc $FRPC_VERSION (installed via Homebrew)"
|
|
147
|
+
INSTALLED=1
|
|
148
|
+
elif [[ -x /opt/homebrew/bin/frpc ]]; then
|
|
149
|
+
FRPC_VERSION=$(/opt/homebrew/bin/frpc --version 2>/dev/null || echo "unknown")
|
|
150
|
+
ok "frpc $FRPC_VERSION (installed at /opt/homebrew/bin/frpc)"
|
|
151
|
+
INSTALLED=1
|
|
152
|
+
else
|
|
153
|
+
warn "Homebrew installed frpc formula but binary is missing (XProtect may have removed it)"
|
|
154
|
+
fi
|
|
155
|
+
else
|
|
156
|
+
warn "Homebrew install failed — trying direct download..."
|
|
157
|
+
fi
|
|
158
|
+
fi
|
|
159
|
+
|
|
160
|
+
# macOS direct download fallback
|
|
161
|
+
if [[ "$INSTALLED" -eq 0 ]]; then
|
|
162
|
+
ARCH=$(uname -m)
|
|
163
|
+
case "$ARCH" in
|
|
164
|
+
arm64) FRP_ARCH="darwin_arm64" ;;
|
|
165
|
+
x86_64) FRP_ARCH="darwin_amd64" ;;
|
|
166
|
+
*) FRP_ARCH="" ;;
|
|
167
|
+
esac
|
|
168
|
+
|
|
169
|
+
if [[ -n "$FRP_ARCH" ]] && command -v curl &>/dev/null; then
|
|
170
|
+
warn "frpc not found — attempting direct download..."
|
|
171
|
+
FRP_VERSION="0.67.0"
|
|
172
|
+
FRP_URL="https://github.com/fatedier/frp/releases/download/v${FRP_VERSION}/frp_${FRP_VERSION}_${FRP_ARCH}.tar.gz"
|
|
173
|
+
INSTALL_DIR="$HOME/.local/bin"
|
|
174
|
+
mkdir -p "$INSTALL_DIR"
|
|
175
|
+
|
|
176
|
+
TMPFILE=$(mktemp /tmp/frpc-XXXXXX.tar.gz)
|
|
177
|
+
if curl -fsSL "$FRP_URL" -o "$TMPFILE" 2>/dev/null; then
|
|
178
|
+
EXTRACT_DIR="frp_${FRP_VERSION}_${FRP_ARCH}"
|
|
179
|
+
if tar -xzf "$TMPFILE" -C /tmp "${EXTRACT_DIR}/frpc" 2>/dev/null; then
|
|
180
|
+
mv "/tmp/${EXTRACT_DIR}/frpc" "$INSTALL_DIR/frpc"
|
|
181
|
+
chmod +x "$INSTALL_DIR/frpc"
|
|
182
|
+
rm -rf "/tmp/${EXTRACT_DIR}" "$TMPFILE"
|
|
183
|
+
|
|
184
|
+
# Remove quarantine attribute so macOS doesn't block it
|
|
185
|
+
xattr -d com.apple.quarantine "$INSTALL_DIR/frpc" 2>/dev/null || true
|
|
186
|
+
|
|
187
|
+
if "$INSTALL_DIR/frpc" --version &>/dev/null; then
|
|
188
|
+
FRPC_VERSION=$("$INSTALL_DIR/frpc" --version 2>/dev/null)
|
|
189
|
+
ok "frpc $FRPC_VERSION (installed to $INSTALL_DIR/frpc)"
|
|
190
|
+
INSTALLED=1
|
|
191
|
+
|
|
192
|
+
# Ensure ~/.local/bin is noted for PATH
|
|
193
|
+
if ! echo "$PATH" | tr ':' '\n' | grep -qF "$INSTALL_DIR"; then
|
|
194
|
+
PATH="$INSTALL_DIR:$PATH"
|
|
195
|
+
warn "$INSTALL_DIR added to PATH for this session"
|
|
196
|
+
info "Add permanently to your shell profile:"
|
|
197
|
+
info " echo 'export PATH=\"\$HOME/.local/bin:\$PATH\"' >> ~/.zshrc"
|
|
198
|
+
fi
|
|
199
|
+
else
|
|
200
|
+
fail "frpc downloaded but binary won't execute (XProtect may be blocking)"
|
|
201
|
+
info "Try manually: xattr -d com.apple.quarantine $INSTALL_DIR/frpc"
|
|
202
|
+
fi
|
|
203
|
+
fi
|
|
204
|
+
fi
|
|
205
|
+
rm -f "$TMPFILE"
|
|
206
|
+
fi
|
|
207
|
+
fi
|
|
208
|
+
fi
|
|
209
|
+
|
|
210
|
+
# Linux: try apt or provide download instructions
|
|
211
|
+
if [[ "$INSTALLED" -eq 0 ]] && [[ "$(uname -s)" == "Linux" ]]; then
|
|
212
|
+
# Detect architecture
|
|
213
|
+
ARCH=$(uname -m)
|
|
214
|
+
case "$ARCH" in
|
|
215
|
+
x86_64) FRP_ARCH="amd64" ;;
|
|
216
|
+
aarch64) FRP_ARCH="arm64" ;;
|
|
217
|
+
armv7l) FRP_ARCH="arm" ;;
|
|
218
|
+
*) FRP_ARCH="" ;;
|
|
219
|
+
esac
|
|
220
|
+
|
|
221
|
+
if [[ -n "$FRP_ARCH" ]]; then
|
|
222
|
+
warn "frpc not found — attempting download..."
|
|
223
|
+
FRP_VERSION="0.67.0"
|
|
224
|
+
FRP_URL="https://github.com/fatedier/frp/releases/download/v${FRP_VERSION}/frp_${FRP_VERSION}_linux_${FRP_ARCH}.tar.gz"
|
|
225
|
+
INSTALL_DIR="$HOME/.local/bin"
|
|
226
|
+
mkdir -p "$INSTALL_DIR"
|
|
227
|
+
|
|
228
|
+
if command -v curl &>/dev/null; then
|
|
229
|
+
TMPFILE=$(mktemp /tmp/frpc-XXXXXX.tar.gz)
|
|
230
|
+
if curl -fsSL "$FRP_URL" -o "$TMPFILE" 2>/dev/null; then
|
|
231
|
+
if tar -xzf "$TMPFILE" -C /tmp "frp_${FRP_VERSION}_linux_${FRP_ARCH}/frpc" 2>/dev/null; then
|
|
232
|
+
mv "/tmp/frp_${FRP_VERSION}_linux_${FRP_ARCH}/frpc" "$INSTALL_DIR/frpc"
|
|
233
|
+
chmod +x "$INSTALL_DIR/frpc"
|
|
234
|
+
rm -rf "/tmp/frp_${FRP_VERSION}_linux_${FRP_ARCH}" "$TMPFILE"
|
|
235
|
+
|
|
236
|
+
if "$INSTALL_DIR/frpc" --version &>/dev/null; then
|
|
237
|
+
FRPC_VERSION=$("$INSTALL_DIR/frpc" --version 2>/dev/null)
|
|
238
|
+
ok "frpc $FRPC_VERSION (installed to $INSTALL_DIR/frpc)"
|
|
239
|
+
INSTALLED=1
|
|
240
|
+
|
|
241
|
+
# Check if ~/.local/bin is in PATH
|
|
242
|
+
if ! echo "$PATH" | tr ':' '\n' | grep -q "$INSTALL_DIR"; then
|
|
243
|
+
warn "$INSTALL_DIR is not in your PATH"
|
|
244
|
+
info "Add to your shell profile:"
|
|
245
|
+
info " echo 'export PATH=\"\$HOME/.local/bin:\$PATH\"' >> ~/.bashrc"
|
|
246
|
+
info " source ~/.bashrc"
|
|
247
|
+
fi
|
|
248
|
+
fi
|
|
249
|
+
fi
|
|
250
|
+
fi
|
|
251
|
+
rm -f "$TMPFILE"
|
|
252
|
+
fi
|
|
253
|
+
fi
|
|
254
|
+
fi
|
|
255
|
+
|
|
256
|
+
if [[ "$INSTALLED" -eq 0 ]]; then
|
|
257
|
+
warn "frpc not installed — KADI tunnel will not work"
|
|
258
|
+
echo ""
|
|
259
|
+
info "${BOLD}frpc is required for Akash deployments with local images.${RESET}"
|
|
260
|
+
info "Without it, kadi-deploy cannot create a tunnel for providers"
|
|
261
|
+
info "to pull your container images."
|
|
262
|
+
echo ""
|
|
263
|
+
info "${BOLD}Install frpc:${RESET}"
|
|
264
|
+
if [[ "$(uname -s)" == "Darwin" ]]; then
|
|
265
|
+
info " brew install frpc"
|
|
266
|
+
info ""
|
|
267
|
+
info "If Homebrew install fails (macOS XProtect may block it):"
|
|
268
|
+
info " 1. Download from https://github.com/fatedier/frp/releases"
|
|
269
|
+
info " (choose frp_*_darwin_arm64.tar.gz for Apple Silicon)"
|
|
270
|
+
info " 2. Extract: tar xzf frp_*.tar.gz"
|
|
271
|
+
info " 3. Move: sudo mv frp_*/frpc /usr/local/bin/"
|
|
272
|
+
info " 4. Allow: xattr -d com.apple.quarantine /usr/local/bin/frpc"
|
|
273
|
+
elif [[ "$(uname -s)" == "Linux" ]]; then
|
|
274
|
+
info " Download from https://github.com/fatedier/frp/releases"
|
|
275
|
+
info " Extract and move frpc to ~/.local/bin/ or /usr/local/bin/"
|
|
276
|
+
else
|
|
277
|
+
info " Download from https://github.com/fatedier/frp/releases"
|
|
278
|
+
fi
|
|
279
|
+
echo ""
|
|
280
|
+
info "After installing, re-run: kadi install"
|
|
281
|
+
echo ""
|
|
282
|
+
WARNINGS=$((WARNINGS + 1))
|
|
283
|
+
fi
|
|
284
|
+
fi
|
|
285
|
+
|
|
286
|
+
# ── Summary ──────────────────────────────────────────────────
|
|
287
|
+
|
|
288
|
+
echo ""
|
|
289
|
+
if [[ "$ERRORS" -gt 0 ]]; then
|
|
290
|
+
fail "${BOLD}Preflight failed with $ERRORS error(s)${RESET}"
|
|
291
|
+
exit 1
|
|
292
|
+
elif [[ "$WARNINGS" -gt 0 ]]; then
|
|
293
|
+
warn "${BOLD}Preflight passed with $WARNINGS warning(s)${RESET}"
|
|
294
|
+
info "kadi-deploy will work but some features may be limited."
|
|
295
|
+
# Don't exit 1 for warnings — allow install to continue
|
|
296
|
+
exit 0
|
|
297
|
+
else
|
|
298
|
+
ok "${BOLD}All checks passed${RESET}"
|
|
299
|
+
exit 0
|
|
300
|
+
fi
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Interactive Bid Selector
|
|
3
|
+
*
|
|
4
|
+
* Provides an interactive CLI interface for selecting provider bids during
|
|
5
|
+
* Akash deployments. Displays bids with color-coded reliability, pricing,
|
|
6
|
+
* location, and audit status.
|
|
7
|
+
*
|
|
8
|
+
* @module cli/bid-selector
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import enquirer from 'enquirer';
|
|
12
|
+
import type { EnhancedBid } from '@kadi.build/deploy-ability/akash';
|
|
13
|
+
import { success, error, warning, dim, chalk } from './colors.js';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Formats uptime percentage with color coding
|
|
17
|
+
*
|
|
18
|
+
* Color scheme:
|
|
19
|
+
* - Green (≥95%): Excellent reliability
|
|
20
|
+
* - Yellow/Orange (85-94%): Concerning but acceptable
|
|
21
|
+
* - Red (<85%): Poor reliability
|
|
22
|
+
* - Gray (no data): Unknown
|
|
23
|
+
*
|
|
24
|
+
* @param uptime - Uptime value (0.0 to 1.0)
|
|
25
|
+
* @returns Colored uptime string
|
|
26
|
+
*/
|
|
27
|
+
function formatUptimeWithColor(uptime: number | undefined): string {
|
|
28
|
+
if (uptime === undefined) {
|
|
29
|
+
return dim('No reliability data');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const percentage = uptime * 100;
|
|
33
|
+
const uptimeStr = `${percentage.toFixed(1)}% uptime`;
|
|
34
|
+
|
|
35
|
+
if (percentage >= 95) {
|
|
36
|
+
return success(uptimeStr); // Green
|
|
37
|
+
} else if (percentage >= 85) {
|
|
38
|
+
return warning(uptimeStr); // Yellow/Orange
|
|
39
|
+
} else {
|
|
40
|
+
return error(uptimeStr); // Red
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Formats a provider bid for display in the selector
|
|
46
|
+
*
|
|
47
|
+
* Creates a compact single-line display showing available information:
|
|
48
|
+
* - Provider name/address (truncated if needed)
|
|
49
|
+
* - Price in AKT and USD (if AKT price available)
|
|
50
|
+
* - Additional metadata when available (uptime, location, audit status)
|
|
51
|
+
*
|
|
52
|
+
* **Graceful Degradation:**
|
|
53
|
+
* When provider metadata isn't available (e.g., API returns 403), we show:
|
|
54
|
+
* - Provider address (always available from blockchain)
|
|
55
|
+
* - Price in AKT (always available from bid)
|
|
56
|
+
* - Price in USD (if aktPriceUsd parameter provided)
|
|
57
|
+
*
|
|
58
|
+
* @param bid - Enhanced bid to format
|
|
59
|
+
* @param aktPriceUsd - Optional AKT price in USD for conversion
|
|
60
|
+
* @returns Formatted display string
|
|
61
|
+
*
|
|
62
|
+
* @example With full metadata
|
|
63
|
+
* ```
|
|
64
|
+
* Cloudmos | 99.5% | US | 12.5 AKT/mo ($6) | ✓
|
|
65
|
+
* ```
|
|
66
|
+
*
|
|
67
|
+
* @example Without metadata (graceful degradation)
|
|
68
|
+
* ```
|
|
69
|
+
* akash1xyz...abcd | 12.5 AKT/mo ($6)
|
|
70
|
+
* ```
|
|
71
|
+
*/
|
|
72
|
+
function formatBidDisplay(bid: EnhancedBid, aktPriceUsd?: number): string {
|
|
73
|
+
const provider = bid.provider;
|
|
74
|
+
|
|
75
|
+
// Provider name or address (truncate long addresses)
|
|
76
|
+
let providerName = provider.name || provider.owner;
|
|
77
|
+
if (!provider.name && provider.owner.length > 20) {
|
|
78
|
+
// Truncate long addresses to first 10 and last 4 chars
|
|
79
|
+
providerName = `${provider.owner.slice(0, 10)}...${provider.owner.slice(-4)}`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Price in AKT (always available)
|
|
83
|
+
const aktPerMonth = bid.pricing.akt.perMonth.toFixed(1);
|
|
84
|
+
let priceDisplay = `${aktPerMonth} AKT/mo`;
|
|
85
|
+
|
|
86
|
+
// Add USD price if available
|
|
87
|
+
if (aktPriceUsd) {
|
|
88
|
+
const usd = bid.pricing.toUSD(aktPriceUsd);
|
|
89
|
+
// Use 2 decimal places for better precision on small amounts
|
|
90
|
+
priceDisplay += dim(` ($${usd.perMonth.toFixed(2)})`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Check if we have metadata available
|
|
94
|
+
const hasMetadata = provider.reliability || provider.location;
|
|
95
|
+
|
|
96
|
+
if (hasMetadata) {
|
|
97
|
+
// Full display with metadata
|
|
98
|
+
const uptime = provider.reliability?.uptime7d;
|
|
99
|
+
const uptimeDisplay = uptime !== undefined
|
|
100
|
+
? formatUptimeWithColor(uptime)
|
|
101
|
+
: dim('No data');
|
|
102
|
+
|
|
103
|
+
const location = provider.location?.countryCode || dim('??');
|
|
104
|
+
|
|
105
|
+
const auditStatus = provider.isAudited
|
|
106
|
+
? success('✓')
|
|
107
|
+
: dim('✗');
|
|
108
|
+
|
|
109
|
+
return `${providerName} ${dim('|')} ${uptimeDisplay} ${dim('|')} ${location} ${dim('|')} ${priceDisplay} ${dim('|')} ${auditStatus}`;
|
|
110
|
+
} else {
|
|
111
|
+
// Simplified display when metadata unavailable
|
|
112
|
+
// Show just provider and price
|
|
113
|
+
return `${providerName} ${dim('|')} ${priceDisplay}`;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Prompts user to select a provider bid interactively
|
|
119
|
+
*
|
|
120
|
+
* Displays all available bids with color-coded reliability, pricing,
|
|
121
|
+
* location, and audit status. Uses enquirer's select prompt for
|
|
122
|
+
* keyboard navigation (arrow keys + Enter).
|
|
123
|
+
*
|
|
124
|
+
* **User Experience:**
|
|
125
|
+
* - Arrow keys to navigate
|
|
126
|
+
* - Enter to select
|
|
127
|
+
* - Ctrl+C to cancel
|
|
128
|
+
* - Clear visual hierarchy with colors
|
|
129
|
+
* - All relevant information at a glance
|
|
130
|
+
*
|
|
131
|
+
* @param bids - Array of enhanced bids to choose from
|
|
132
|
+
* @param aktPriceUsd - Optional AKT price in USD for price display
|
|
133
|
+
* @returns Promise resolving to selected bid or null if cancelled/no bids
|
|
134
|
+
*
|
|
135
|
+
* @example
|
|
136
|
+
* ```typescript
|
|
137
|
+
* const selected = await selectBidInteractively(bids, 0.45);
|
|
138
|
+
* if (selected) {
|
|
139
|
+
* console.log('Selected provider:', selected.provider.name);
|
|
140
|
+
* } else {
|
|
141
|
+
* console.log('No provider selected');
|
|
142
|
+
* }
|
|
143
|
+
* ```
|
|
144
|
+
*/
|
|
145
|
+
export async function selectBidInteractively(
|
|
146
|
+
bids: EnhancedBid[],
|
|
147
|
+
aktPriceUsd?: number
|
|
148
|
+
): Promise<EnhancedBid | null> {
|
|
149
|
+
// Handle edge case: no bids available
|
|
150
|
+
if (bids.length === 0) {
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Handle edge case: only one bid (still show prompt for visibility)
|
|
155
|
+
if (bids.length === 1) {
|
|
156
|
+
console.log(warning('\nOnly one provider bid available:'));
|
|
157
|
+
console.log('');
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
try {
|
|
161
|
+
// Create choices for enquirer
|
|
162
|
+
const choices = bids.map((bid, index) => ({
|
|
163
|
+
// Use the formatted display as the name (what user sees)
|
|
164
|
+
name: formatBidDisplay(bid, aktPriceUsd),
|
|
165
|
+
// Use the index as the value (what gets returned)
|
|
166
|
+
value: index
|
|
167
|
+
}));
|
|
168
|
+
|
|
169
|
+
// Show interactive prompt
|
|
170
|
+
// Note: enquirer's select prompt returns the NAME of the selected choice, not the value!
|
|
171
|
+
// This is a quirk of enquirer - it returns the display text, not the value field
|
|
172
|
+
const response = await enquirer.prompt<{ selected: string }>({
|
|
173
|
+
type: 'select',
|
|
174
|
+
name: 'selected',
|
|
175
|
+
message: chalk.bold.cyan('Select a provider:'),
|
|
176
|
+
choices: choices as any, // Type assertion for enquirer compatibility
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
// Find the bid by matching the display text
|
|
180
|
+
const selectedIndex = choices.findIndex(c => c.name === response.selected);
|
|
181
|
+
return selectedIndex >= 0 ? bids[selectedIndex] : null;
|
|
182
|
+
} catch (err) {
|
|
183
|
+
// User cancelled (Ctrl+C) or other error
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Sort bids by quality score for better UX
|
|
190
|
+
*
|
|
191
|
+
* Sorts bids to show best options first:
|
|
192
|
+
* 1. Audited providers first
|
|
193
|
+
* 2. Higher uptime first
|
|
194
|
+
* 3. Lower price first (tie-breaker)
|
|
195
|
+
*
|
|
196
|
+
* This ensures users see the most reliable providers at the top,
|
|
197
|
+
* making selection easier.
|
|
198
|
+
*
|
|
199
|
+
* @param bids - Array of enhanced bids
|
|
200
|
+
* @returns Sorted array (does not mutate original)
|
|
201
|
+
*
|
|
202
|
+
* @example
|
|
203
|
+
* ```typescript
|
|
204
|
+
* const sortedBids = sortBidsByQuality(bids);
|
|
205
|
+
* const selected = await selectBidInteractively(sortedBids);
|
|
206
|
+
* ```
|
|
207
|
+
*/
|
|
208
|
+
export function sortBidsByQuality(bids: EnhancedBid[]): EnhancedBid[] {
|
|
209
|
+
return [...bids].sort((a, b) => {
|
|
210
|
+
// 1. Audited providers first
|
|
211
|
+
if (a.provider.isAudited && !b.provider.isAudited) return -1;
|
|
212
|
+
if (!a.provider.isAudited && b.provider.isAudited) return 1;
|
|
213
|
+
|
|
214
|
+
// 2. Higher uptime first (use 7d uptime)
|
|
215
|
+
const uptimeA = a.provider.reliability?.uptime7d ?? 0;
|
|
216
|
+
const uptimeB = b.provider.reliability?.uptime7d ?? 0;
|
|
217
|
+
if (uptimeA !== uptimeB) return uptimeB - uptimeA;
|
|
218
|
+
|
|
219
|
+
// 3. Lower price as tie-breaker
|
|
220
|
+
return a.pricing.uakt.perMonth - b.pricing.uakt.perMonth;
|
|
221
|
+
});
|
|
222
|
+
}
|