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.
Files changed (52) hide show
  1. package/.env.example +6 -0
  2. package/.prettierrc +6 -0
  3. package/README.md +589 -0
  4. package/agent.json +23 -0
  5. package/index.js +11 -0
  6. package/package.json +42 -0
  7. package/quick-command.txt +92 -0
  8. package/scripts/preflight.js +458 -0
  9. package/scripts/preflight.sh +300 -0
  10. package/src/cli/bid-selector.ts +222 -0
  11. package/src/cli/colors.ts +216 -0
  12. package/src/cli/index.ts +11 -0
  13. package/src/cli/prompts.ts +190 -0
  14. package/src/cli/spinners.ts +165 -0
  15. package/src/commands/deploy-local.ts +475 -0
  16. package/src/commands/deploy.ts +1342 -0
  17. package/src/commands/down.ts +679 -0
  18. package/src/commands/index.ts +10 -0
  19. package/src/commands/lock.ts +571 -0
  20. package/src/config/agent-loader.ts +177 -0
  21. package/src/config/index.ts +9 -0
  22. package/src/display/deployment-info.ts +220 -0
  23. package/src/display/pricing.ts +137 -0
  24. package/src/display/resources.ts +234 -0
  25. package/src/enhanced-registry-manager.ts +892 -0
  26. package/src/index.ts +307 -0
  27. package/src/infrastructure/registry.ts +269 -0
  28. package/src/schemas/profiles.ts +529 -0
  29. package/src/secrets/broker-urls.ts +109 -0
  30. package/src/secrets/handshake.ts +407 -0
  31. package/src/secrets/index.ts +69 -0
  32. package/src/secrets/inject-env.ts +171 -0
  33. package/src/secrets/nonce.ts +31 -0
  34. package/src/secrets/normalize.ts +204 -0
  35. package/src/secrets/prepare.ts +152 -0
  36. package/src/secrets/validate.ts +243 -0
  37. package/src/secrets/vault.ts +80 -0
  38. package/src/types/akash.ts +116 -0
  39. package/src/types/container-registry-ability.d.ts +158 -0
  40. package/src/types/external.ts +49 -0
  41. package/src/types.ts +211 -0
  42. package/src/utils/akt-price.ts +74 -0
  43. package/tests/agent-loader.test.ts +239 -0
  44. package/tests/autonomous.test.ts +244 -0
  45. package/tests/down.test.ts +1143 -0
  46. package/tests/lock.test.ts +1148 -0
  47. package/tests/nonce.test.ts +34 -0
  48. package/tests/normalize.test.ts +270 -0
  49. package/tests/secrets-schema.test.ts +301 -0
  50. package/tests/types.test.ts +198 -0
  51. package/tsconfig.json +18 -0
  52. 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
+ }