shipwright-cli 1.7.1 → 1.9.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 (105) hide show
  1. package/.claude/agents/code-reviewer.md +90 -0
  2. package/.claude/agents/devops-engineer.md +142 -0
  3. package/.claude/agents/pipeline-agent.md +80 -0
  4. package/.claude/agents/shell-script-specialist.md +150 -0
  5. package/.claude/agents/test-specialist.md +196 -0
  6. package/.claude/hooks/post-tool-use.sh +38 -0
  7. package/.claude/hooks/pre-tool-use.sh +25 -0
  8. package/.claude/hooks/session-started.sh +37 -0
  9. package/README.md +212 -814
  10. package/claude-code/CLAUDE.md.shipwright +54 -0
  11. package/claude-code/hooks/notify-idle.sh +2 -2
  12. package/claude-code/hooks/session-start.sh +24 -0
  13. package/claude-code/hooks/task-completed.sh +6 -2
  14. package/claude-code/settings.json.template +12 -0
  15. package/dashboard/public/app.js +4422 -0
  16. package/dashboard/public/index.html +816 -0
  17. package/dashboard/public/styles.css +4755 -0
  18. package/dashboard/server.ts +4315 -0
  19. package/docs/KNOWN-ISSUES.md +18 -10
  20. package/docs/TIPS.md +38 -26
  21. package/docs/patterns/README.md +33 -23
  22. package/package.json +9 -5
  23. package/scripts/adapters/iterm2-adapter.sh +1 -1
  24. package/scripts/adapters/tmux-adapter.sh +52 -23
  25. package/scripts/adapters/wezterm-adapter.sh +26 -14
  26. package/scripts/lib/compat.sh +200 -0
  27. package/scripts/lib/helpers.sh +72 -0
  28. package/scripts/postinstall.mjs +72 -13
  29. package/scripts/{cct → sw} +109 -21
  30. package/scripts/sw-adversarial.sh +274 -0
  31. package/scripts/sw-architecture-enforcer.sh +330 -0
  32. package/scripts/sw-checkpoint.sh +390 -0
  33. package/scripts/{cct-cleanup.sh → sw-cleanup.sh} +3 -1
  34. package/scripts/sw-connect.sh +619 -0
  35. package/scripts/{cct-cost.sh → sw-cost.sh} +368 -34
  36. package/scripts/{cct-daemon.sh → sw-daemon.sh} +2217 -204
  37. package/scripts/sw-dashboard.sh +477 -0
  38. package/scripts/sw-developer-simulation.sh +252 -0
  39. package/scripts/sw-docs.sh +635 -0
  40. package/scripts/sw-doctor.sh +907 -0
  41. package/scripts/{cct-fix.sh → sw-fix.sh} +10 -6
  42. package/scripts/{cct-fleet.sh → sw-fleet.sh} +498 -22
  43. package/scripts/sw-github-checks.sh +521 -0
  44. package/scripts/sw-github-deploy.sh +533 -0
  45. package/scripts/sw-github-graphql.sh +972 -0
  46. package/scripts/sw-heartbeat.sh +293 -0
  47. package/scripts/{cct-init.sh → sw-init.sh} +144 -11
  48. package/scripts/sw-intelligence.sh +1196 -0
  49. package/scripts/sw-jira.sh +643 -0
  50. package/scripts/sw-launchd.sh +364 -0
  51. package/scripts/sw-linear.sh +648 -0
  52. package/scripts/{cct-logs.sh → sw-logs.sh} +72 -2
  53. package/scripts/{cct-loop.sh → sw-loop.sh} +534 -44
  54. package/scripts/{cct-memory.sh → sw-memory.sh} +321 -38
  55. package/scripts/sw-patrol-meta.sh +417 -0
  56. package/scripts/sw-pipeline-composer.sh +455 -0
  57. package/scripts/{cct-pipeline.sh → sw-pipeline.sh} +2319 -178
  58. package/scripts/sw-predictive.sh +820 -0
  59. package/scripts/{cct-prep.sh → sw-prep.sh} +339 -49
  60. package/scripts/{cct-ps.sh → sw-ps.sh} +6 -4
  61. package/scripts/{cct-reaper.sh → sw-reaper.sh} +6 -4
  62. package/scripts/sw-remote.sh +687 -0
  63. package/scripts/sw-self-optimize.sh +947 -0
  64. package/scripts/sw-session.sh +519 -0
  65. package/scripts/sw-setup.sh +234 -0
  66. package/scripts/sw-status.sh +605 -0
  67. package/scripts/{cct-templates.sh → sw-templates.sh} +9 -4
  68. package/scripts/sw-tmux.sh +591 -0
  69. package/scripts/sw-tracker-jira.sh +277 -0
  70. package/scripts/sw-tracker-linear.sh +292 -0
  71. package/scripts/sw-tracker.sh +409 -0
  72. package/scripts/{cct-upgrade.sh → sw-upgrade.sh} +103 -46
  73. package/scripts/{cct-worktree.sh → sw-worktree.sh} +3 -0
  74. package/templates/pipelines/autonomous.json +27 -5
  75. package/templates/pipelines/full.json +12 -0
  76. package/templates/pipelines/standard.json +12 -0
  77. package/tmux/{claude-teams-overlay.conf → shipwright-overlay.conf} +27 -9
  78. package/tmux/templates/accessibility.json +34 -0
  79. package/tmux/templates/api-design.json +35 -0
  80. package/tmux/templates/architecture.json +1 -0
  81. package/tmux/templates/bug-fix.json +9 -0
  82. package/tmux/templates/code-review.json +1 -0
  83. package/tmux/templates/compliance.json +36 -0
  84. package/tmux/templates/data-pipeline.json +36 -0
  85. package/tmux/templates/debt-paydown.json +34 -0
  86. package/tmux/templates/devops.json +1 -0
  87. package/tmux/templates/documentation.json +1 -0
  88. package/tmux/templates/exploration.json +1 -0
  89. package/tmux/templates/feature-dev.json +1 -0
  90. package/tmux/templates/full-stack.json +8 -0
  91. package/tmux/templates/i18n.json +34 -0
  92. package/tmux/templates/incident-response.json +36 -0
  93. package/tmux/templates/migration.json +1 -0
  94. package/tmux/templates/observability.json +35 -0
  95. package/tmux/templates/onboarding.json +33 -0
  96. package/tmux/templates/performance.json +35 -0
  97. package/tmux/templates/refactor.json +1 -0
  98. package/tmux/templates/release.json +35 -0
  99. package/tmux/templates/security-audit.json +8 -0
  100. package/tmux/templates/spike.json +34 -0
  101. package/tmux/templates/testing.json +1 -0
  102. package/tmux/tmux.conf +98 -9
  103. package/scripts/cct-doctor.sh +0 -414
  104. package/scripts/cct-session.sh +0 -284
  105. package/scripts/cct-status.sh +0 -169
@@ -0,0 +1,619 @@
1
+ #!/usr/bin/env bash
2
+ # ╔═══════════════════════════════════════════════════════════════════════════╗
3
+ # ║ sw-connect.sh — Sync local state to team dashboard ║
4
+ # ║ ║
5
+ # ║ Background heartbeat process that streams developer status, daemon ║
6
+ # ║ state, and events to a remote or local Shipwright dashboard. ║
7
+ # ╚═══════════════════════════════════════════════════════════════════════════╝
8
+ set -euo pipefail
9
+ trap 'echo "ERROR: $BASH_SOURCE:$LINENO exited with status $?" >&2' ERR
10
+
11
+ VERSION="1.9.0"
12
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
13
+
14
+ # ─── Colors (matches Seth's tmux theme) ─────────────────────────────────────
15
+ CYAN='\033[38;2;0;212;255m' # #00d4ff — primary accent
16
+ PURPLE='\033[38;2;124;58;237m' # #7c3aed — secondary
17
+ BLUE='\033[38;2;0;102;255m' # #0066ff — tertiary
18
+ GREEN='\033[38;2;74;222;128m' # success
19
+ YELLOW='\033[38;2;250;204;21m' # warning
20
+ RED='\033[38;2;248;113;113m' # error
21
+ DIM='\033[2m'
22
+ BOLD='\033[1m'
23
+ RESET='\033[0m'
24
+
25
+ # ─── Cross-platform compatibility ──────────────────────────────────────────
26
+ # shellcheck source=lib/compat.sh
27
+ [[ -f "$SCRIPT_DIR/lib/compat.sh" ]] && source "$SCRIPT_DIR/lib/compat.sh"
28
+
29
+ # ─── Output Helpers ─────────────────────────────────────────────────────────
30
+ info() { echo -e "${CYAN}${BOLD}▸${RESET} $*"; }
31
+ success() { echo -e "${GREEN}${BOLD}✓${RESET} $*"; }
32
+ warn() { echo -e "${YELLOW}${BOLD}⚠${RESET} $*"; }
33
+ error() { echo -e "${RED}${BOLD}✗${RESET} $*" >&2; }
34
+
35
+ # ─── Constants ──────────────────────────────────────────────────────────────
36
+ SHIPWRIGHT_DIR="$HOME/.shipwright"
37
+ PID_FILE="$SHIPWRIGHT_DIR/connect.pid"
38
+ TEAM_CONFIG="$SHIPWRIGHT_DIR/team-config.json"
39
+ DAEMON_PID_FILE="$SHIPWRIGHT_DIR/daemon.pid"
40
+ DAEMON_STATE_FILE="$SHIPWRIGHT_DIR/daemon-state.json"
41
+ EVENTS_FILE="$SHIPWRIGHT_DIR/events.jsonl"
42
+ CONNECT_LOG="$SHIPWRIGHT_DIR/connect.log"
43
+ DEFAULT_URL="http://localhost:8767"
44
+ HEARTBEAT_INTERVAL=10
45
+
46
+ now_iso() { date -u +"%Y-%m-%dT%H:%M:%SZ"; }
47
+
48
+ ensure_dir() {
49
+ mkdir -p "$SHIPWRIGHT_DIR"
50
+ }
51
+
52
+ # ─── Resolve identity ──────────────────────────────────────────────────────
53
+
54
+ resolve_developer_id() {
55
+ if [[ -n "${DEVELOPER_ID:-}" ]]; then
56
+ echo "$DEVELOPER_ID"
57
+ return
58
+ fi
59
+ local git_name
60
+ git_name="$(git config user.name 2>/dev/null || true)"
61
+ if [[ -n "$git_name" ]]; then
62
+ echo "$git_name"
63
+ return
64
+ fi
65
+ echo "${USER:-unknown}"
66
+ }
67
+
68
+ resolve_machine_name() {
69
+ if [[ -n "${MACHINE_NAME:-}" ]]; then
70
+ echo "$MACHINE_NAME"
71
+ return
72
+ fi
73
+ hostname -s 2>/dev/null || echo "unknown"
74
+ }
75
+
76
+ resolve_dashboard_url() {
77
+ local url_flag="${1:-}"
78
+
79
+ # 1. --url flag
80
+ if [[ -n "$url_flag" ]]; then
81
+ echo "$url_flag"
82
+ return
83
+ fi
84
+
85
+ # 2. Environment variable
86
+ if [[ -n "${DASHBOARD_URL:-}" ]]; then
87
+ echo "$DASHBOARD_URL"
88
+ return
89
+ fi
90
+
91
+ # 3. team-config.json
92
+ if [[ -f "$TEAM_CONFIG" ]]; then
93
+ local cfg_url
94
+ cfg_url="$(jq -r '.dashboard_url // empty' "$TEAM_CONFIG" 2>/dev/null || true)"
95
+ if [[ -n "$cfg_url" ]]; then
96
+ echo "$cfg_url"
97
+ return
98
+ fi
99
+ fi
100
+
101
+ # 4. Default
102
+ echo "$DEFAULT_URL"
103
+ }
104
+
105
+ # ─── Daemon state helpers ──────────────────────────────────────────────────
106
+
107
+ check_daemon_running() {
108
+ if [[ -f "$DAEMON_PID_FILE" ]]; then
109
+ local pid
110
+ pid="$(cat "$DAEMON_PID_FILE" 2>/dev/null || true)"
111
+ if [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null; then
112
+ echo "$pid"
113
+ return
114
+ fi
115
+ fi
116
+ echo ""
117
+ }
118
+
119
+ get_active_jobs() {
120
+ if [[ -f "$DAEMON_STATE_FILE" ]]; then
121
+ jq -c '[.active_jobs // [] | .[] | {issue: .issue, title: (.title // ""), stage: (.stage // "")}]' "$DAEMON_STATE_FILE" 2>/dev/null || echo "[]"
122
+ else
123
+ echo "[]"
124
+ fi
125
+ }
126
+
127
+ get_queued_issues() {
128
+ if [[ -f "$DAEMON_STATE_FILE" ]]; then
129
+ jq -c '[.queued // [] | .[]]' "$DAEMON_STATE_FILE" 2>/dev/null || echo "[]"
130
+ else
131
+ echo "[]"
132
+ fi
133
+ }
134
+
135
+ # ─── Events delta ──────────────────────────────────────────────────────────
136
+
137
+ get_new_events() {
138
+ local last_ts="$1"
139
+
140
+ if [[ ! -f "$EVENTS_FILE" ]]; then
141
+ echo "[]"
142
+ return
143
+ fi
144
+
145
+ if [[ -z "$last_ts" || "$last_ts" == "null" ]]; then
146
+ # First sync — send last 20 events
147
+ tail -n 20 "$EVENTS_FILE" 2>/dev/null | jq -s '.' 2>/dev/null || echo "[]"
148
+ else
149
+ # Send events newer than last_ts (events use "ts" field)
150
+ jq -c --arg ts "$last_ts" 'select(.ts > $ts)' "$EVENTS_FILE" 2>/dev/null | jq -s '.' 2>/dev/null || echo "[]"
151
+ fi
152
+ }
153
+
154
+ get_latest_event_ts() {
155
+ local events_json="$1"
156
+ echo "$events_json" | jq -r 'if length > 0 then .[-1].ts // "" else "" end' 2>/dev/null || echo ""
157
+ }
158
+
159
+ # ─── Heartbeat loop ────────────────────────────────────────────────────────
160
+
161
+ run_heartbeat_loop() {
162
+ local dashboard_url="$1"
163
+ local developer_id="$2"
164
+ local machine_name="$3"
165
+ local full_hostname
166
+ full_hostname="$(hostname 2>/dev/null || echo "unknown")"
167
+ local platform
168
+ platform="$(uname -s | tr '[:upper:]' '[:lower:]')"
169
+
170
+ local last_event_ts=""
171
+ local backoff=5
172
+ local max_backoff=30
173
+ local consecutive_failures=0
174
+
175
+ # Load invite token from team config if present (for auth)
176
+ local invite_token=""
177
+ if [[ -f "$TEAM_CONFIG" ]]; then
178
+ invite_token="$(jq -r '.invite_token // ""' "$TEAM_CONFIG" 2>/dev/null || true)"
179
+ fi
180
+
181
+ # Trap for graceful shutdown
182
+ trap 'send_disconnect "$dashboard_url" "$developer_id" "$machine_name"; exit 0' SIGTERM SIGINT
183
+
184
+ info "Connect heartbeat started (PID $$)"
185
+ info "Dashboard: ${dashboard_url}"
186
+ info "Developer: ${developer_id} @ ${machine_name}"
187
+
188
+ while true; do
189
+ # Collect state
190
+ local daemon_pid
191
+ daemon_pid="$(check_daemon_running)"
192
+ local daemon_running="false"
193
+ local daemon_pid_json="null"
194
+ if [[ -n "$daemon_pid" ]]; then
195
+ daemon_running="true"
196
+ daemon_pid_json="$daemon_pid"
197
+ fi
198
+
199
+ local active_jobs
200
+ active_jobs="$(get_active_jobs)"
201
+ local queued
202
+ queued="$(get_queued_issues)"
203
+ local events
204
+ events="$(get_new_events "$last_event_ts")"
205
+
206
+ # Build JSON payload with jq
207
+ local payload
208
+ payload="$(jq -n \
209
+ --arg developer_id "$developer_id" \
210
+ --arg machine_name "$machine_name" \
211
+ --arg hostname "$full_hostname" \
212
+ --arg platform "$platform" \
213
+ --argjson daemon_running "$daemon_running" \
214
+ --argjson daemon_pid "$daemon_pid_json" \
215
+ --argjson active_jobs "$active_jobs" \
216
+ --argjson queued "$queued" \
217
+ --argjson events "$events" \
218
+ --arg ts "$(now_iso)" \
219
+ --arg invite_token "$invite_token" \
220
+ '{
221
+ developer_id: $developer_id,
222
+ machine_name: $machine_name,
223
+ hostname: $hostname,
224
+ platform: $platform,
225
+ daemon_running: $daemon_running,
226
+ daemon_pid: $daemon_pid,
227
+ active_jobs: $active_jobs,
228
+ queued: $queued,
229
+ events: $events,
230
+ ts: $ts
231
+ } + (if $invite_token != "" then {invite_token: $invite_token} else {} end)')"
232
+
233
+ # Send heartbeat
234
+ local http_code
235
+ http_code="$(curl -s -o /dev/null -w "%{http_code}" --max-time 5 \
236
+ -X POST \
237
+ -H "Content-Type: application/json" \
238
+ -d "$payload" \
239
+ "${dashboard_url}/api/connect/heartbeat" 2>/dev/null || echo "000")"
240
+
241
+ if [[ "$http_code" == "200" || "$http_code" == "201" || "$http_code" == "204" ]]; then
242
+ # Success — update event bookmark and reset backoff
243
+ local new_ts
244
+ new_ts="$(get_latest_event_ts "$events")"
245
+ if [[ -n "$new_ts" ]]; then
246
+ last_event_ts="$new_ts"
247
+ fi
248
+ backoff=5
249
+ consecutive_failures=0
250
+ else
251
+ consecutive_failures=$((consecutive_failures + 1))
252
+ if [[ "$consecutive_failures" -le 3 ]]; then
253
+ echo "$(now_iso) WARN: Dashboard unreachable (HTTP $http_code), retrying in ${backoff}s" >> "$CONNECT_LOG"
254
+ fi
255
+ sleep "$backoff"
256
+ # Exponential backoff: 5 → 10 → 20 → 30 (capped)
257
+ backoff=$((backoff * 2))
258
+ if [[ "$backoff" -gt "$max_backoff" ]]; then
259
+ backoff="$max_backoff"
260
+ fi
261
+ continue
262
+ fi
263
+
264
+ sleep "$HEARTBEAT_INTERVAL"
265
+ done
266
+ }
267
+
268
+ send_disconnect() {
269
+ local dashboard_url="$1"
270
+ local developer_id="$2"
271
+ local machine_name="$3"
272
+
273
+ local payload
274
+ payload="$(jq -n \
275
+ --arg developer_id "$developer_id" \
276
+ --arg machine_name "$machine_name" \
277
+ '{developer_id: $developer_id, machine_name: $machine_name}')"
278
+
279
+ curl -s -o /dev/null --max-time 5 \
280
+ -X POST \
281
+ -H "Content-Type: application/json" \
282
+ -d "$payload" \
283
+ "${dashboard_url}/api/connect/disconnect" 2>/dev/null || true
284
+
285
+ info "Disconnected from dashboard"
286
+ }
287
+
288
+ # ─── Start ──────────────────────────────────────────────────────────────────
289
+
290
+ cmd_start() {
291
+ local url_flag=""
292
+
293
+ while [[ $# -gt 0 ]]; do
294
+ case "$1" in
295
+ --url)
296
+ url_flag="${2:-}"
297
+ shift 2
298
+ ;;
299
+ --url=*)
300
+ url_flag="${1#--url=}"
301
+ shift
302
+ ;;
303
+ --help|-h)
304
+ show_help
305
+ return 0
306
+ ;;
307
+ *)
308
+ warn "Unknown option: $1"
309
+ shift
310
+ ;;
311
+ esac
312
+ done
313
+
314
+ ensure_dir
315
+
316
+ # Check if already running
317
+ if [[ -f "$PID_FILE" ]]; then
318
+ local existing_pid
319
+ existing_pid="$(cat "$PID_FILE" 2>/dev/null || true)"
320
+ if [[ -n "$existing_pid" ]] && kill -0 "$existing_pid" 2>/dev/null; then
321
+ error "Connect already running (PID ${existing_pid})"
322
+ echo -e " ${DIM}Stop it first: shipwright connect stop${RESET}"
323
+ return 1
324
+ fi
325
+ # Stale PID file — clean up
326
+ rm -f "$PID_FILE"
327
+ fi
328
+
329
+ local dashboard_url
330
+ dashboard_url="$(resolve_dashboard_url "$url_flag")"
331
+ local developer_id
332
+ developer_id="$(resolve_developer_id)"
333
+ local machine_name
334
+ machine_name="$(resolve_machine_name)"
335
+
336
+ info "Starting connect to ${BOLD}${dashboard_url}${RESET}"
337
+ info "Developer: ${BOLD}${developer_id}${RESET} @ ${BOLD}${machine_name}${RESET}"
338
+
339
+ # Fork heartbeat loop to background
340
+ run_heartbeat_loop "$dashboard_url" "$developer_id" "$machine_name" >> "$CONNECT_LOG" 2>&1 &
341
+ local bg_pid=$!
342
+
343
+ # Write PID file atomically
344
+ local tmp_pid
345
+ tmp_pid="$(mktemp "$SHIPWRIGHT_DIR/.connect-pid.XXXXXX")"
346
+ echo "$bg_pid" > "$tmp_pid"
347
+ mv "$tmp_pid" "$PID_FILE"
348
+
349
+ success "Connect started (PID ${bg_pid})"
350
+ echo -e " ${DIM}Logs: ${CONNECT_LOG}${RESET}"
351
+ echo -e " ${DIM}Stop: shipwright connect stop${RESET}"
352
+ }
353
+
354
+ # ─── Stop ───────────────────────────────────────────────────────────────────
355
+
356
+ cmd_stop() {
357
+ if [[ ! -f "$PID_FILE" ]]; then
358
+ warn "Connect is not running (no PID file)"
359
+ return 0
360
+ fi
361
+
362
+ local pid
363
+ pid="$(cat "$PID_FILE" 2>/dev/null || true)"
364
+
365
+ if [[ -z "$pid" ]]; then
366
+ warn "Empty PID file — cleaning up"
367
+ rm -f "$PID_FILE"
368
+ return 0
369
+ fi
370
+
371
+ if kill -0 "$pid" 2>/dev/null; then
372
+ kill "$pid" 2>/dev/null || true
373
+ # Wait briefly for graceful shutdown
374
+ local waited=0
375
+ while kill -0 "$pid" 2>/dev/null && [[ "$waited" -lt 5 ]]; do
376
+ sleep 1
377
+ waited=$((waited + 1))
378
+ done
379
+ if kill -0 "$pid" 2>/dev/null; then
380
+ kill -9 "$pid" 2>/dev/null || true
381
+ fi
382
+ success "Connect stopped (PID ${pid})"
383
+ else
384
+ warn "Process ${pid} not running — cleaning up stale PID file"
385
+ fi
386
+
387
+ rm -f "$PID_FILE"
388
+ }
389
+
390
+ # ─── Status ─────────────────────────────────────────────────────────────────
391
+
392
+ cmd_status() {
393
+ echo ""
394
+ echo -e "${CYAN}${BOLD} Shipwright Connect${RESET} ${DIM}v${VERSION}${RESET}"
395
+ echo -e "${DIM} ══════════════════════════════════════════${RESET}"
396
+ echo ""
397
+
398
+ local running=false
399
+ local pid=""
400
+
401
+ if [[ -f "$PID_FILE" ]]; then
402
+ pid="$(cat "$PID_FILE" 2>/dev/null || true)"
403
+ if [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null; then
404
+ running=true
405
+ fi
406
+ fi
407
+
408
+ if [[ "$running" == "true" ]]; then
409
+ echo -e " Status: ${GREEN}${BOLD}connected${RESET} (PID ${pid})"
410
+ else
411
+ echo -e " Status: ${RED}${BOLD}disconnected${RESET}"
412
+ fi
413
+
414
+ # Show dashboard URL
415
+ local dashboard_url
416
+ dashboard_url="$(resolve_dashboard_url "")"
417
+ echo -e " Dashboard: ${CYAN}${dashboard_url}${RESET}"
418
+
419
+ # Show identity
420
+ local developer_id
421
+ developer_id="$(resolve_developer_id)"
422
+ local machine_name
423
+ machine_name="$(resolve_machine_name)"
424
+ echo -e " Developer: ${BOLD}${developer_id}${RESET}"
425
+ echo -e " Machine: ${BOLD}${machine_name}${RESET}"
426
+
427
+ # Show uptime if running
428
+ if [[ "$running" == "true" && -n "$pid" ]]; then
429
+ local start_time
430
+ # macOS: ps -o lstart= gives human-readable start time
431
+ start_time="$(ps -o lstart= -p "$pid" 2>/dev/null || true)"
432
+ if [[ -n "$start_time" ]]; then
433
+ echo -e " Started: ${DIM}${start_time}${RESET}"
434
+ fi
435
+ fi
436
+
437
+ # Show team config if exists
438
+ if [[ -f "$TEAM_CONFIG" ]]; then
439
+ local team_name
440
+ team_name="$(jq -r '.team_name // "—"' "$TEAM_CONFIG" 2>/dev/null || echo "—")"
441
+ echo -e " Team: ${BOLD}${team_name}${RESET}"
442
+ fi
443
+
444
+ echo ""
445
+ }
446
+
447
+ # ─── Join ───────────────────────────────────────────────────────────────────
448
+
449
+ cmd_join() {
450
+ local token=""
451
+ local url_flag=""
452
+
453
+ # Parse args: supports both positional and flag styles
454
+ # shipwright connect join <token>
455
+ # shipwright connect join --url <url> --token <token>
456
+ while [[ $# -gt 0 ]]; do
457
+ case "$1" in
458
+ --token) token="${2:-}"; shift 2 ;;
459
+ --token=*) token="${1#--token=}"; shift ;;
460
+ --url) url_flag="${2:-}"; shift 2 ;;
461
+ --url=*) url_flag="${1#--url=}"; shift ;;
462
+ --help|-h) show_help; return 0 ;;
463
+ -*) warn "Unknown flag: $1"; shift ;;
464
+ *)
465
+ # Positional: if no token yet, treat as token
466
+ if [[ -z "$token" ]]; then
467
+ token="$1"
468
+ fi
469
+ shift
470
+ ;;
471
+ esac
472
+ done
473
+
474
+ if [[ -z "$token" ]]; then
475
+ error "Usage: shipwright connect join <token>"
476
+ echo -e " ${DIM}Or: shipwright connect join --url <dashboard-url> --token <token>${RESET}"
477
+ echo -e " ${DIM}Get a token from the dashboard: Settings → Team → Invite${RESET}"
478
+ return 1
479
+ fi
480
+
481
+ ensure_dir
482
+
483
+ # Determine dashboard URL to verify against
484
+ local verify_url
485
+ verify_url="${url_flag:-$(resolve_dashboard_url "")}"
486
+
487
+ info "Verifying invite token against ${BOLD}${verify_url}${RESET}..."
488
+
489
+ # Try invite token verification endpoint first
490
+ local response
491
+ response="$(curl -s --max-time 10 "${verify_url}/api/team/invite/${token}" 2>/dev/null || true)"
492
+
493
+ if [[ -z "$response" ]]; then
494
+ error "Could not reach dashboard at ${verify_url}"
495
+ echo -e " ${DIM}Make sure the dashboard is running: shipwright dashboard start${RESET}"
496
+ return 1
497
+ fi
498
+
499
+ # Check if token is valid
500
+ local valid
501
+ valid="$(echo "$response" | jq -r '.valid // false' 2>/dev/null || echo "false")"
502
+
503
+ if [[ "$valid" != "true" ]]; then
504
+ local err_msg
505
+ err_msg="$(echo "$response" | jq -r '.error // "Unknown error"' 2>/dev/null || echo "Unknown error")"
506
+ error "Invalid invite token: ${err_msg}"
507
+ return 1
508
+ fi
509
+
510
+ # Parse response
511
+ local join_url join_team
512
+ join_url="$(echo "$response" | jq -r '.dashboard_url // empty' 2>/dev/null || true)"
513
+ join_team="$(echo "$response" | jq -r '.team_name // empty' 2>/dev/null || true)"
514
+
515
+ if [[ -z "$join_url" ]]; then
516
+ # Fallback: use the URL we verified against
517
+ join_url="$verify_url"
518
+ fi
519
+
520
+ local developer_id
521
+ developer_id="$(resolve_developer_id)"
522
+ local machine_name
523
+ machine_name="$(resolve_machine_name)"
524
+
525
+ # Save team config atomically
526
+ local tmp_config
527
+ tmp_config="$(mktemp "$SHIPWRIGHT_DIR/.team-config.XXXXXX")"
528
+
529
+ jq -n \
530
+ --arg dashboard_url "$join_url" \
531
+ --arg team_name "${join_team:-}" \
532
+ --arg developer_id "$developer_id" \
533
+ --arg machine_name "$machine_name" \
534
+ --arg invite_token "$token" \
535
+ --argjson auto_connect true \
536
+ '{
537
+ dashboard_url: $dashboard_url,
538
+ team_name: $team_name,
539
+ developer_id: $developer_id,
540
+ machine_name: $machine_name,
541
+ invite_token: $invite_token,
542
+ auto_connect: $auto_connect
543
+ }' > "$tmp_config"
544
+
545
+ mv "$tmp_config" "$TEAM_CONFIG"
546
+
547
+ success "Joined team ${BOLD}${join_team:-unknown}${RESET}"
548
+ echo -e " ${DIM}Dashboard: ${join_url}${RESET}"
549
+ echo -e " ${DIM}Config: ${TEAM_CONFIG}${RESET}"
550
+
551
+ # Auto-start connection
552
+ info "Starting connection..."
553
+ cmd_start --url "$join_url"
554
+ }
555
+
556
+ # ─── Help ───────────────────────────────────────────────────────────────────
557
+
558
+ show_help() {
559
+ echo ""
560
+ echo -e "${CYAN}${BOLD} Shipwright Connect${RESET} ${DIM}v${VERSION}${RESET}"
561
+ echo -e "${DIM} ══════════════════════════════════════════${RESET}"
562
+ echo ""
563
+ echo -e " ${BOLD}USAGE${RESET}"
564
+ echo -e " shipwright connect <command> [options]"
565
+ echo ""
566
+ echo -e " ${BOLD}COMMANDS${RESET}"
567
+ echo -e " ${CYAN}start${RESET} [--url <url>] Start syncing local state to dashboard"
568
+ echo -e " ${CYAN}stop${RESET} Stop the connect process"
569
+ echo -e " ${CYAN}status${RESET} Show connection status"
570
+ echo -e " ${CYAN}join${RESET} <token> Join a team using an invite token"
571
+ echo ""
572
+ echo -e " ${BOLD}START OPTIONS${RESET}"
573
+ echo -e " --url <url> Dashboard URL (default: from team-config or localhost:8767)"
574
+ echo ""
575
+ echo -e " ${BOLD}IDENTITY${RESET} ${DIM}(auto-detected, override with env vars)${RESET}"
576
+ echo -e " DEVELOPER_ID Developer name (default: git user.name or \$USER)"
577
+ echo -e " MACHINE_NAME Machine name (default: hostname -s)"
578
+ echo -e " DASHBOARD_URL Dashboard URL (default: http://localhost:8767)"
579
+ echo ""
580
+ echo -e " ${BOLD}EXAMPLES${RESET}"
581
+ echo -e " ${DIM}# Start syncing to local dashboard${RESET}"
582
+ echo -e " shipwright connect start"
583
+ echo ""
584
+ echo -e " ${DIM}# Connect to a remote dashboard${RESET}"
585
+ echo -e " shipwright connect start --url http://team-server:8767"
586
+ echo ""
587
+ echo -e " ${DIM}# Join a team with an invite token${RESET}"
588
+ echo -e " shipwright connect join abc123"
589
+ echo ""
590
+ echo -e " ${DIM}# Check connection status${RESET}"
591
+ echo -e " shipwright connect status"
592
+ echo ""
593
+ echo -e " ${DIM}# Stop syncing${RESET}"
594
+ echo -e " shipwright connect stop"
595
+ echo ""
596
+ }
597
+
598
+ # ─── Command Router ────────────────────────────────────────────────────────
599
+
600
+ main() {
601
+ local cmd="${1:-help}"
602
+ shift 2>/dev/null || true
603
+
604
+ case "$cmd" in
605
+ start) cmd_start "$@" ;;
606
+ stop) cmd_stop ;;
607
+ status) cmd_status ;;
608
+ join) cmd_join "$@" ;;
609
+ help|--help|-h) show_help ;;
610
+ *)
611
+ error "Unknown command: ${cmd}"
612
+ echo ""
613
+ show_help
614
+ exit 1
615
+ ;;
616
+ esac
617
+ }
618
+
619
+ main "$@"