shipwright-cli 1.10.0 β†’ 2.0.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 (108) hide show
  1. package/README.md +114 -36
  2. package/completions/_shipwright +212 -32
  3. package/completions/shipwright.bash +97 -25
  4. package/docs/strategy/01-market-research.md +619 -0
  5. package/docs/strategy/02-mission-and-brand.md +587 -0
  6. package/docs/strategy/03-gtm-and-roadmap.md +759 -0
  7. package/docs/strategy/QUICK-START.txt +289 -0
  8. package/docs/strategy/README.md +172 -0
  9. package/package.json +4 -2
  10. package/scripts/sw +208 -1
  11. package/scripts/sw-activity.sh +500 -0
  12. package/scripts/sw-adaptive.sh +925 -0
  13. package/scripts/sw-adversarial.sh +1 -1
  14. package/scripts/sw-architecture-enforcer.sh +1 -1
  15. package/scripts/sw-auth.sh +613 -0
  16. package/scripts/sw-autonomous.sh +664 -0
  17. package/scripts/sw-changelog.sh +704 -0
  18. package/scripts/sw-checkpoint.sh +1 -1
  19. package/scripts/sw-ci.sh +602 -0
  20. package/scripts/sw-cleanup.sh +1 -1
  21. package/scripts/sw-code-review.sh +637 -0
  22. package/scripts/sw-connect.sh +1 -1
  23. package/scripts/sw-context.sh +605 -0
  24. package/scripts/sw-cost.sh +1 -1
  25. package/scripts/sw-daemon.sh +432 -130
  26. package/scripts/sw-dashboard.sh +1 -1
  27. package/scripts/sw-db.sh +540 -0
  28. package/scripts/sw-decompose.sh +539 -0
  29. package/scripts/sw-deps.sh +551 -0
  30. package/scripts/sw-developer-simulation.sh +1 -1
  31. package/scripts/sw-discovery.sh +412 -0
  32. package/scripts/sw-docs-agent.sh +539 -0
  33. package/scripts/sw-docs.sh +1 -1
  34. package/scripts/sw-doctor.sh +59 -1
  35. package/scripts/sw-dora.sh +615 -0
  36. package/scripts/sw-durable.sh +710 -0
  37. package/scripts/sw-e2e-orchestrator.sh +535 -0
  38. package/scripts/sw-eventbus.sh +393 -0
  39. package/scripts/sw-feedback.sh +471 -0
  40. package/scripts/sw-fix.sh +1 -1
  41. package/scripts/sw-fleet-discover.sh +567 -0
  42. package/scripts/sw-fleet-viz.sh +404 -0
  43. package/scripts/sw-fleet.sh +8 -1
  44. package/scripts/sw-github-app.sh +596 -0
  45. package/scripts/sw-github-checks.sh +1 -1
  46. package/scripts/sw-github-deploy.sh +1 -1
  47. package/scripts/sw-github-graphql.sh +1 -1
  48. package/scripts/sw-guild.sh +569 -0
  49. package/scripts/sw-heartbeat.sh +1 -1
  50. package/scripts/sw-hygiene.sh +559 -0
  51. package/scripts/sw-incident.sh +617 -0
  52. package/scripts/sw-init.sh +88 -1
  53. package/scripts/sw-instrument.sh +699 -0
  54. package/scripts/sw-intelligence.sh +1 -1
  55. package/scripts/sw-jira.sh +1 -1
  56. package/scripts/sw-launchd.sh +363 -28
  57. package/scripts/sw-linear.sh +1 -1
  58. package/scripts/sw-logs.sh +1 -1
  59. package/scripts/sw-loop.sh +64 -3
  60. package/scripts/sw-memory.sh +1 -1
  61. package/scripts/sw-mission-control.sh +487 -0
  62. package/scripts/sw-model-router.sh +545 -0
  63. package/scripts/sw-otel.sh +596 -0
  64. package/scripts/sw-oversight.sh +689 -0
  65. package/scripts/sw-pipeline-composer.sh +1 -1
  66. package/scripts/sw-pipeline-vitals.sh +1 -1
  67. package/scripts/sw-pipeline.sh +687 -24
  68. package/scripts/sw-pm.sh +693 -0
  69. package/scripts/sw-pr-lifecycle.sh +522 -0
  70. package/scripts/sw-predictive.sh +1 -1
  71. package/scripts/sw-prep.sh +1 -1
  72. package/scripts/sw-ps.sh +1 -1
  73. package/scripts/sw-public-dashboard.sh +798 -0
  74. package/scripts/sw-quality.sh +595 -0
  75. package/scripts/sw-reaper.sh +1 -1
  76. package/scripts/sw-recruit.sh +573 -0
  77. package/scripts/sw-regression.sh +642 -0
  78. package/scripts/sw-release-manager.sh +736 -0
  79. package/scripts/sw-release.sh +706 -0
  80. package/scripts/sw-remote.sh +1 -1
  81. package/scripts/sw-replay.sh +520 -0
  82. package/scripts/sw-retro.sh +691 -0
  83. package/scripts/sw-scale.sh +444 -0
  84. package/scripts/sw-security-audit.sh +505 -0
  85. package/scripts/sw-self-optimize.sh +1 -1
  86. package/scripts/sw-session.sh +1 -1
  87. package/scripts/sw-setup.sh +1 -1
  88. package/scripts/sw-standup.sh +712 -0
  89. package/scripts/sw-status.sh +1 -1
  90. package/scripts/sw-strategic.sh +658 -0
  91. package/scripts/sw-stream.sh +450 -0
  92. package/scripts/sw-swarm.sh +583 -0
  93. package/scripts/sw-team-stages.sh +511 -0
  94. package/scripts/sw-templates.sh +1 -1
  95. package/scripts/sw-testgen.sh +515 -0
  96. package/scripts/sw-tmux-pipeline.sh +554 -0
  97. package/scripts/sw-tmux.sh +1 -1
  98. package/scripts/sw-trace.sh +485 -0
  99. package/scripts/sw-tracker-github.sh +188 -0
  100. package/scripts/sw-tracker-jira.sh +172 -0
  101. package/scripts/sw-tracker-linear.sh +251 -0
  102. package/scripts/sw-tracker.sh +117 -2
  103. package/scripts/sw-triage.sh +603 -0
  104. package/scripts/sw-upgrade.sh +1 -1
  105. package/scripts/sw-ux.sh +677 -0
  106. package/scripts/sw-webhook.sh +627 -0
  107. package/scripts/sw-widgets.sh +530 -0
  108. package/scripts/sw-worktree.sh +1 -1
@@ -0,0 +1,798 @@
1
+ #!/usr/bin/env bash
2
+ # ╔═══════════════════════════════════════════════════════════════════════════╗
3
+ # β•‘ shipwright public-dashboard β€” Public real-time pipeline progress β•‘
4
+ # β•‘ Shareable URLs Β· Self-contained HTML Β· Privacy controls β•‘
5
+ # β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•
6
+ set -euo pipefail
7
+ trap 'echo "ERROR: $BASH_SOURCE:$LINENO exited with status $?" >&2' ERR
8
+
9
+ VERSION="2.0.0"
10
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
11
+ REPO_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
12
+
13
+ # ─── Colors (matches Seth's tmux theme) ─────────────────────────────────────
14
+ CYAN='\033[38;2;0;212;255m' # #00d4ff β€” primary accent
15
+ PURPLE='\033[38;2;124;58;237m' # #7c3aed β€” secondary
16
+ BLUE='\033[38;2;0;102;255m' # #0066ff β€” tertiary
17
+ GREEN='\033[38;2;74;222;128m' # success
18
+ YELLOW='\033[38;2;250;204;21m' # warning
19
+ RED='\033[38;2;248;113;113m' # error
20
+ DIM='\033[2m'
21
+ BOLD='\033[1m'
22
+ RESET='\033[0m'
23
+
24
+ # ─── Cross-platform compatibility ──────────────────────────────────────────
25
+ # shellcheck source=lib/compat.sh
26
+ [[ -f "$SCRIPT_DIR/lib/compat.sh" ]] && source "$SCRIPT_DIR/lib/compat.sh"
27
+
28
+ # ─── Output Helpers ─────────────────────────────────────────────────────────
29
+ info() { echo -e "${CYAN}${BOLD}β–Έ${RESET} $*"; }
30
+ success() { echo -e "${GREEN}${BOLD}βœ“${RESET} $*"; }
31
+ warn() { echo -e "${YELLOW}${BOLD}⚠${RESET} $*"; }
32
+ error() { echo -e "${RED}${BOLD}βœ—${RESET} $*" >&2; }
33
+
34
+ now_iso() { date -u +"%Y-%m-%dT%H:%M:%SZ"; }
35
+ now_epoch() { date +%s; }
36
+
37
+ # ─── Paths ──────────────────────────────────────────────────────────────────
38
+ PUB_DASH_DIR="${HOME}/.shipwright/public-dashboard"
39
+ SHARE_LINKS_FILE="${PUB_DASH_DIR}/share-links.json"
40
+ SHARE_CONFIG_FILE="${PUB_DASH_DIR}/config.json"
41
+ EVENTS_FILE="${HOME}/.shipwright/events.jsonl"
42
+
43
+ # ─── Structured Event Log ──────────────────────────────────────────────────
44
+ emit_event() {
45
+ local event_type="$1"
46
+ shift
47
+ local json_fields=""
48
+ for kv in "$@"; do
49
+ local key="${kv%%=*}"
50
+ local val="${kv#*=}"
51
+ if [[ "$val" =~ ^-?[0-9]+\.?[0-9]*$ ]]; then
52
+ json_fields="${json_fields},\"${key}\":${val}"
53
+ else
54
+ val="${val//\"/\\\"}"
55
+ json_fields="${json_fields},\"${key}\":\"${val}\""
56
+ fi
57
+ done
58
+ mkdir -p "${HOME}/.shipwright"
59
+ echo "{\"ts\":\"$(now_iso)\",\"ts_epoch\":$(now_epoch),\"type\":\"${event_type}\"${json_fields}}" >> "$EVENTS_FILE"
60
+ }
61
+
62
+ # ─── Initialization ──────────────────────────────────────────────────────────
63
+ ensure_dirs() {
64
+ mkdir -p "$PUB_DASH_DIR"
65
+ [[ -f "$SHARE_LINKS_FILE" ]] || echo '{"links":[]}' > "$SHARE_LINKS_FILE"
66
+ [[ -f "$SHARE_CONFIG_FILE" ]] || echo '{"privacy":"stages_only","expiry_hours":24,"custom_domain":"","branding":""}' > "$SHARE_CONFIG_FILE"
67
+ }
68
+
69
+ # ─── Sanitize Functions ──────────────────────────────────────────────────────
70
+ sanitize_for_privacy() {
71
+ local input="$1"
72
+ local privacy_level="${2:-stages_only}"
73
+
74
+ case "$privacy_level" in
75
+ public)
76
+ # Full details
77
+ echo "$input"
78
+ ;;
79
+ anonymized)
80
+ # Hide paths and tokens
81
+ echo "$input" | sed -E \
82
+ -e 's|/Users/[^/]+|/home/user|g' \
83
+ -e 's/(ghp_|sk_live_)[A-Za-z0-9_-]+/[REDACTED_TOKEN]/g' \
84
+ -e 's/(CLAUDECODE|GITHUB_TOKEN)=[^ ]*/[REDACTED_ENV]/g' \
85
+ -e 's/@[^ ]*\.com/@redacted.com/g'
86
+ ;;
87
+ stages_only)
88
+ # Only stage names and status
89
+ echo "$input" | sed -E \
90
+ -e 's|"description":"[^"]*"|"description":""|g' \
91
+ -e 's|"logs":"[^"]*"|"logs":""|g' \
92
+ -e 's|"output":"[^"]*"|"output":""|g' \
93
+ -e 's|/Users/[^/]+|/home/user|g'
94
+ ;;
95
+ esac
96
+ }
97
+
98
+ # ─── Gather Current Pipeline State ──────────────────────────────────────────
99
+ gather_pipeline_state() {
100
+ local privacy="${1:-stages_only}"
101
+
102
+ local state_file="${REPO_DIR}/.claude/pipeline-state.md"
103
+ local daemon_state="${HOME}/.shipwright/daemon-state.json"
104
+ local pipeline_artifacts="${REPO_DIR}/.claude/pipeline-artifacts"
105
+
106
+ local pipeline_data='{
107
+ "status":"unknown",
108
+ "stages":[],
109
+ "agents":[],
110
+ "events":[],
111
+ "updated_at":"'"$(now_iso)"'"
112
+ }'
113
+
114
+ # Read daemon state
115
+ if [[ -f "$daemon_state" ]]; then
116
+ local active_jobs queued_count
117
+ active_jobs=$(jq -c '.active_jobs // []' "$daemon_state" 2>/dev/null || echo "[]")
118
+ queued_count=$(jq 'length' <<<"$active_jobs" 2>/dev/null || echo "0")
119
+
120
+ if [[ "$queued_count" -gt 0 ]]; then
121
+ pipeline_data=$(jq --argjson jobs "$active_jobs" '.agents = $jobs' <<<"$pipeline_data")
122
+ fi
123
+ fi
124
+
125
+ # Read recent events (last 50)
126
+ if [[ -f "$EVENTS_FILE" ]]; then
127
+ local events
128
+ events=$(tail -50 "$EVENTS_FILE" | jq -c -s '.' 2>/dev/null || echo "[]")
129
+ pipeline_data=$(jq --argjson events "$events" '.events = $events' <<<"$pipeline_data")
130
+ fi
131
+
132
+ # Read pipeline artifacts if available
133
+ if [[ -d "$pipeline_artifacts" ]]; then
134
+ local stage_count
135
+ stage_count=$(find "$pipeline_artifacts" -name "*.md" -o -name "*.json" | wc -l || echo "0")
136
+ pipeline_data=$(jq --arg count "$stage_count" '.artifact_count = $count' <<<"$pipeline_data")
137
+ fi
138
+
139
+ # Sanitize based on privacy level
140
+ sanitize_for_privacy "$pipeline_data" "$privacy"
141
+ }
142
+
143
+ # ─── Generate Token ─────────────────────────────────────────────────────────
144
+ generate_token() {
145
+ # Create a read-only token (32 hex chars)
146
+ if command -v openssl &>/dev/null; then
147
+ openssl rand -hex 16
148
+ else
149
+ # Fallback to simple pseudo-random
150
+ head -c 32 /dev/urandom | od -An -tx1 | tr -d ' '
151
+ fi
152
+ }
153
+
154
+ # ─── Generate Self-Contained HTML ──────────────────────────────────────────
155
+ generate_html() {
156
+ local data_json="$1"
157
+ local title="${2:-Shipwright Pipeline Progress}"
158
+ local privacy="${3:-stages_only}"
159
+
160
+ # Escape JSON for embedding in HTML
161
+ local json_escaped
162
+ json_escaped=$(echo "$data_json" | sed 's/"/\\"/g' | tr '\n' ' ')
163
+
164
+ cat <<'EOF'
165
+ <!DOCTYPE html>
166
+ <html lang="en">
167
+ <head>
168
+ <meta charset="UTF-8">
169
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
170
+ <title>TITLE_PLACEHOLDER</title>
171
+ <style>
172
+ * { margin: 0; padding: 0; box-sizing: border-box; }
173
+ body {
174
+ font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
175
+ background: linear-gradient(135deg, #0a1428 0%, #1a2332 100%);
176
+ color: #e0e0e0;
177
+ padding: 20px;
178
+ min-height: 100vh;
179
+ }
180
+ .container { max-width: 1200px; margin: 0 auto; }
181
+ header {
182
+ display: flex;
183
+ justify-content: space-between;
184
+ align-items: center;
185
+ margin-bottom: 30px;
186
+ padding-bottom: 20px;
187
+ border-bottom: 2px solid #00d4ff;
188
+ }
189
+ h1 { color: #00d4ff; font-size: 28px; }
190
+ .meta {
191
+ font-size: 12px;
192
+ color: #7c3aed;
193
+ display: flex;
194
+ gap: 20px;
195
+ }
196
+ .meta-item { display: flex; flex-direction: column; }
197
+ .meta-label { color: #999; text-transform: uppercase; }
198
+ .meta-value { color: #00d4ff; font-weight: bold; }
199
+ .grid {
200
+ display: grid;
201
+ grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
202
+ gap: 20px;
203
+ margin-bottom: 30px;
204
+ }
205
+ .card {
206
+ background: rgba(30, 30, 40, 0.8);
207
+ border: 1px solid #333;
208
+ border-radius: 8px;
209
+ padding: 20px;
210
+ backdrop-filter: blur(10px);
211
+ transition: all 0.3s ease;
212
+ }
213
+ .card:hover { border-color: #00d4ff; box-shadow: 0 0 20px rgba(0, 212, 255, 0.2); }
214
+ .card-title {
215
+ color: #00d4ff;
216
+ font-size: 14px;
217
+ font-weight: bold;
218
+ text-transform: uppercase;
219
+ margin-bottom: 15px;
220
+ display: flex;
221
+ align-items: center;
222
+ gap: 8px;
223
+ }
224
+ .badge {
225
+ display: inline-block;
226
+ padding: 4px 12px;
227
+ border-radius: 20px;
228
+ font-size: 12px;
229
+ font-weight: bold;
230
+ }
231
+ .badge-success { background: rgba(74, 222, 128, 0.2); color: #4ade80; }
232
+ .badge-warning { background: rgba(250, 204, 21, 0.2); color: #f8cc15; }
233
+ .badge-error { background: rgba(248, 113, 113, 0.2); color: #f87171; }
234
+ .badge-info { background: rgba(0, 212, 255, 0.2); color: #00d4ff; }
235
+ .progress-bar {
236
+ width: 100%;
237
+ height: 6px;
238
+ background: #222;
239
+ border-radius: 3px;
240
+ overflow: hidden;
241
+ margin-bottom: 10px;
242
+ }
243
+ .progress-fill {
244
+ height: 100%;
245
+ background: linear-gradient(90deg, #00d4ff, #7c3aed);
246
+ width: 0%;
247
+ transition: width 0.3s ease;
248
+ }
249
+ .list-item {
250
+ padding: 10px 0;
251
+ border-bottom: 1px solid #222;
252
+ display: flex;
253
+ justify-content: space-between;
254
+ align-items: center;
255
+ font-size: 13px;
256
+ }
257
+ .list-item:last-child { border-bottom: none; }
258
+ .timestamp {
259
+ color: #666;
260
+ font-size: 11px;
261
+ font-family: 'Monaco', 'Courier New', monospace;
262
+ }
263
+ .footer {
264
+ text-align: center;
265
+ padding: 20px;
266
+ color: #666;
267
+ font-size: 12px;
268
+ border-top: 1px solid #333;
269
+ }
270
+ .footer a { color: #00d4ff; text-decoration: none; }
271
+ .footer a:hover { text-decoration: underline; }
272
+ @media (prefers-reduced-motion: reduce) {
273
+ * { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; }
274
+ }
275
+ </style>
276
+ </head>
277
+ <body>
278
+ <div class="container">
279
+ <header>
280
+ <div>
281
+ <h1>βš“ TITLE_PLACEHOLDER</h1>
282
+ </div>
283
+ <div class="meta">
284
+ <div class="meta-item">
285
+ <span class="meta-label">Updated</span>
286
+ <span class="meta-value" id="updated-time">β€”</span>
287
+ </div>
288
+ <div class="meta-item">
289
+ <span class="meta-label">Privacy</span>
290
+ <span class="meta-value">PRIVACY_PLACEHOLDER</span>
291
+ </div>
292
+ </div>
293
+ </header>
294
+
295
+ <div class="grid" id="dashboard">
296
+ <div class="card">
297
+ <div class="card-title">πŸ“Š Pipeline Status</div>
298
+ <div id="pipeline-status">
299
+ <div class="list-item">
300
+ <span>Overall</span>
301
+ <span class="badge badge-info" id="overall-status">Loading...</span>
302
+ </div>
303
+ <div class="list-item">
304
+ <span>Active Agents</span>
305
+ <span id="agent-count">β€”</span>
306
+ </div>
307
+ <div class="list-item">
308
+ <span>Completed Events</span>
309
+ <span id="event-count">β€”</span>
310
+ </div>
311
+ </div>
312
+ </div>
313
+
314
+ <div class="card">
315
+ <div class="card-title">βš™οΈ Artifacts</div>
316
+ <div id="artifacts-info">
317
+ <div class="list-item">
318
+ <span>Stage Files</span>
319
+ <span id="artifact-count">β€”</span>
320
+ </div>
321
+ <div style="padding: 10px 0; font-size: 12px; color: #999;">
322
+ Stage outputs and checkpoints available in pipeline artifacts
323
+ </div>
324
+ </div>
325
+ </div>
326
+
327
+ <div class="card">
328
+ <div class="card-title">πŸ“ Recent Events</div>
329
+ <div id="events-list" style="max-height: 300px; overflow-y: auto;">
330
+ <div style="color: #666; font-size: 12px;">Loading events...</div>
331
+ </div>
332
+ </div>
333
+ </div>
334
+
335
+ <div class="footer">
336
+ <p>Generated by <a href="https://github.com/sethdford/shipwright">Shipwright</a> v1.13.0</p>
337
+ <p style="margin-top: 8px; color: #555;">Dashboard auto-refreshes every 30s when served from dashboard server</p>
338
+ <p style="margin-top: 8px;" id="footer-timestamp">Generated: β€”</p>
339
+ </div>
340
+ </div>
341
+
342
+ <script>
343
+ const pipelineData = {DATA_PLACEHOLDER};
344
+
345
+ function formatTime(isoString) {
346
+ if (!isoString) return 'β€”';
347
+ const date = new Date(isoString);
348
+ return date.toLocaleTimeString();
349
+ }
350
+
351
+ function renderDashboard() {
352
+ if (!pipelineData) return;
353
+
354
+ document.getElementById('updated-time').textContent = formatTime(pipelineData.updated_at);
355
+ document.getElementById('footer-timestamp').textContent = 'Generated: ' + new Date().toLocaleString();
356
+
357
+ // Pipeline status
358
+ const agents = pipelineData.agents || [];
359
+ document.getElementById('agent-count').textContent = agents.length + ' running';
360
+
361
+ const events = pipelineData.events || [];
362
+ document.getElementById('event-count').textContent = events.length + ' total';
363
+
364
+ const artifactCount = pipelineData.artifact_count || 0;
365
+ document.getElementById('artifact-count').textContent = artifactCount + ' files';
366
+
367
+ // Render events
368
+ const eventsList = document.getElementById('events-list');
369
+ if (events.length === 0) {
370
+ eventsList.innerHTML = '<div style="color: #666; font-size: 12px;">No events recorded</div>';
371
+ } else {
372
+ eventsList.innerHTML = events
373
+ .slice(-10)
374
+ .reverse()
375
+ .map(e => {
376
+ const eventType = (e.type || 'unknown').toUpperCase();
377
+ const time = formatTime(e.ts);
378
+ return '<div class="list-item"><span>' + eventType + '</span><span class="timestamp">' + time + '</span></div>';
379
+ })
380
+ .join('');
381
+ }
382
+ }
383
+
384
+ // Render on page load
385
+ document.addEventListener('DOMContentLoaded', renderDashboard);
386
+
387
+ // Auto-refresh every 30 seconds if this is served from a server
388
+ if (window.location.protocol.startsWith('http')) {
389
+ setInterval(function() {
390
+ fetch(window.location.href)
391
+ .then(r => r.text())
392
+ .then(html => {
393
+ const parser = new DOMParser();
394
+ const newDoc = parser.parseFromString(html, 'text/html');
395
+ const newScript = newDoc.querySelector('script');
396
+ if (newScript) {
397
+ eval(newScript.textContent);
398
+ renderDashboard();
399
+ }
400
+ })
401
+ .catch(e => console.log('Auto-refresh failed:', e));
402
+ }, 30000);
403
+ }
404
+ </script>
405
+ </body>
406
+ </html>
407
+ EOF
408
+ }
409
+
410
+ # ─── Export Command ─────────────────────────────────────────────────────────
411
+ cmd_export() {
412
+ local output_file="${1:-${PUB_DASH_DIR}/dashboard.html}"
413
+ local title="${2:-Shipwright Pipeline Progress}"
414
+ local privacy="${3:-stages_only}"
415
+
416
+ ensure_dirs
417
+
418
+ info "Gathering pipeline state (privacy: $privacy)..."
419
+ local state_data
420
+ state_data=$(gather_pipeline_state "$privacy")
421
+
422
+ info "Generating HTML export..."
423
+ local html
424
+ html=$(generate_html "$state_data" "$title" "$privacy")
425
+
426
+ # Replace placeholders
427
+ html="${html//TITLE_PLACEHOLDER/$title}"
428
+ html="${html//PRIVACY_PLACEHOLDER/$privacy}"
429
+ html="${html//\{DATA_PLACEHOLDER\}/$state_data}"
430
+
431
+ # Atomic write
432
+ local tmp_file
433
+ tmp_file=$(mktemp)
434
+ trap "rm -f '$tmp_file'" EXIT
435
+ echo "$html" > "$tmp_file"
436
+ mv "$tmp_file" "$output_file"
437
+
438
+ emit_event "public_dashboard_export" "privacy=$privacy" "path=$output_file"
439
+
440
+ success "Dashboard exported to: $output_file"
441
+ echo " Size: $(du -h "$output_file" | cut -f1)"
442
+ echo " Privacy: $privacy"
443
+ }
444
+
445
+ # ─── Share Command ──────────────────────────────────────────────────────────
446
+ cmd_share() {
447
+ local expiry_hours="${1:-24}"
448
+ local privacy="${2:-stages_only}"
449
+
450
+ ensure_dirs
451
+
452
+ info "Creating share link (expires in ${expiry_hours}h)..."
453
+ local token
454
+ token=$(generate_token)
455
+ local expires_at
456
+ expires_at=$(($(now_epoch) + expiry_hours * 3600))
457
+
458
+ local link_entry
459
+ link_entry=$(jq -n \
460
+ --arg token "$token" \
461
+ --arg privacy "$privacy" \
462
+ --arg created "$(now_iso)" \
463
+ --arg expires "$(date -u -d "@$expires_at" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -u -v +${expiry_hours}H +%Y-%m-%dT%H:%M:%SZ)" \
464
+ '{token:$token, privacy:$privacy, created:$created, expires:$expires, view_count:0, last_viewed:null}')
465
+
466
+ # Append to share links file (atomic)
467
+ local tmp_file
468
+ tmp_file=$(mktemp)
469
+ trap "rm -f '$tmp_file'" EXIT
470
+ jq ".links += [$link_entry]" "$SHARE_LINKS_FILE" > "$tmp_file"
471
+ mv "$tmp_file" "$SHARE_LINKS_FILE"
472
+
473
+ emit_event "public_dashboard_share" "token=$token" "privacy=$privacy" "expires_hours=$expiry_hours"
474
+
475
+ success "Share link created!"
476
+ echo " Token: $token"
477
+ echo " Privacy: $privacy"
478
+ echo " Expires: $(date -u -d "@$expires_at" +%Y-%m-%d\ %H:%M:%S 2>/dev/null || date -u -v +${expiry_hours}H +%Y-%m-%d\ %H:%M:%S)"
479
+ echo ""
480
+ echo " Share URL: https://your-domain.com/public-dashboard/$token"
481
+ echo " or embed: <iframe src=\"https://your-domain.com/public-dashboard/$token\"></iframe>"
482
+ }
483
+
484
+ # ─── Revoke Command ─────────────────────────────────────────────────────────
485
+ cmd_revoke() {
486
+ local token="$1"
487
+
488
+ [[ -z "$token" ]] && error "Token required" && return 1
489
+
490
+ ensure_dirs
491
+
492
+ info "Revoking share link: $token"
493
+
494
+ local tmp_file
495
+ tmp_file=$(mktemp)
496
+ trap "rm -f '$tmp_file'" EXIT
497
+
498
+ if jq ".links |= map(select(.token != \"$token\"))" "$SHARE_LINKS_FILE" > "$tmp_file"; then
499
+ mv "$tmp_file" "$SHARE_LINKS_FILE"
500
+ emit_event "public_dashboard_revoke" "token=$token"
501
+ success "Share link revoked"
502
+ else
503
+ error "Failed to revoke link"
504
+ return 1
505
+ fi
506
+ }
507
+
508
+ # ─── List Command ───────────────────────────────────────────────────────────
509
+ cmd_list() {
510
+ ensure_dirs
511
+
512
+ local now_epoch_val
513
+ now_epoch_val=$(now_epoch)
514
+
515
+ info "Active share links:"
516
+ echo ""
517
+
518
+ if ! jq empty "$SHARE_LINKS_FILE" 2>/dev/null; then
519
+ warn "No share links found"
520
+ return 0
521
+ fi
522
+
523
+ local active_count=0
524
+ jq -r '.links[] |
525
+ if (.expires | fromdateiso8601) > '$now_epoch_val' then
526
+ .token + "|" + .privacy + "|" + .expires + "|" + (.view_count | tostring)
527
+ else
528
+ empty
529
+ end' "$SHARE_LINKS_FILE" | while IFS='|' read -r token privacy expires views; do
530
+ active_count=$((active_count + 1))
531
+ printf " %s... | Privacy: %-12s | Expires: %s | Views: %s\n" "${token:0:8}" "$privacy" "$expires" "$views"
532
+ done
533
+
534
+ local expired_count
535
+ expired_count=$(jq "[.links[] | select((.expires | fromdateiso8601) <= $now_epoch_val)] | length" "$SHARE_LINKS_FILE" 2>/dev/null || echo "0")
536
+
537
+ if [[ "$expired_count" -gt 0 ]]; then
538
+ echo ""
539
+ warn "$expired_count expired link(s) β€” run 'shipwright public-dashboard cleanup' to remove"
540
+ fi
541
+ }
542
+
543
+ # ─── Config Command ─────────────────────────────────────────────────────────
544
+ cmd_config() {
545
+ local key="${1:-}"
546
+ local value="${2:-}"
547
+
548
+ ensure_dirs
549
+
550
+ if [[ -z "$key" ]]; then
551
+ info "Current config:"
552
+ jq '.' "$SHARE_CONFIG_FILE"
553
+ return 0
554
+ fi
555
+
556
+ case "$key" in
557
+ privacy)
558
+ [[ -z "$value" ]] && error "Value required for privacy" && return 1
559
+ local tmp_file
560
+ tmp_file=$(mktemp)
561
+ trap "rm -f '$tmp_file'" EXIT
562
+ jq ".privacy = \"$value\"" "$SHARE_CONFIG_FILE" > "$tmp_file"
563
+ mv "$tmp_file" "$SHARE_CONFIG_FILE"
564
+ success "Privacy set to: $value"
565
+ ;;
566
+ expiry)
567
+ [[ -z "$value" ]] && error "Value required for expiry (hours)" && return 1
568
+ local tmp_file
569
+ tmp_file=$(mktemp)
570
+ trap "rm -f '$tmp_file'" EXIT
571
+ jq ".expiry_hours = $value" "$SHARE_CONFIG_FILE" > "$tmp_file"
572
+ mv "$tmp_file" "$SHARE_CONFIG_FILE"
573
+ success "Default expiry set to: ${value}h"
574
+ ;;
575
+ domain)
576
+ [[ -z "$value" ]] && error "Value required for domain" && return 1
577
+ local tmp_file
578
+ tmp_file=$(mktemp)
579
+ trap "rm -f '$tmp_file'" EXIT
580
+ jq ".custom_domain = \"$value\"" "$SHARE_CONFIG_FILE" > "$tmp_file"
581
+ mv "$tmp_file" "$SHARE_CONFIG_FILE"
582
+ success "Custom domain set to: $value"
583
+ ;;
584
+ *)
585
+ error "Unknown config key: $key"
586
+ return 1
587
+ ;;
588
+ esac
589
+ }
590
+
591
+ # ─── Embed Command ──────────────────────────────────────────────────────────
592
+ cmd_embed() {
593
+ local token="$1"
594
+ local format="${2:-iframe}"
595
+
596
+ [[ -z "$token" ]] && error "Token required" && return 1
597
+
598
+ ensure_dirs
599
+
600
+ local domain
601
+ domain=$(jq -r '.custom_domain // "your-domain.com"' "$SHARE_CONFIG_FILE")
602
+ local url="https://${domain}/public-dashboard/${token}"
603
+
604
+ case "$format" in
605
+ iframe)
606
+ cat <<EOF
607
+ <!-- Shipwright Public Dashboard Embed -->
608
+ <iframe
609
+ src="$url"
610
+ style="width: 100%; height: 600px; border: 1px solid #ddd; border-radius: 8px;"
611
+ title="Pipeline Progress"
612
+ sandbox="allow-same-origin"
613
+ ></iframe>
614
+ EOF
615
+ ;;
616
+ badge)
617
+ cat <<EOF
618
+ <!-- Shipwright Public Dashboard Badge -->
619
+ <a href="$url" style="display: inline-block;">
620
+ <img
621
+ alt="Pipeline Status"
622
+ src="$url/badge"
623
+ style="max-width: 200px;"
624
+ />
625
+ </a>
626
+ EOF
627
+ ;;
628
+ markdown)
629
+ cat <<EOF
630
+ <!-- Shipwright Public Dashboard -->
631
+ [![Pipeline Status]($url/badge)]($url)
632
+
633
+ [View Full Dashboard]($url)
634
+ EOF
635
+ ;;
636
+ link)
637
+ echo "$url"
638
+ ;;
639
+ *)
640
+ error "Unknown format: $format (iframe, badge, markdown, link)"
641
+ return 1
642
+ ;;
643
+ esac
644
+ }
645
+
646
+ # ─── Cleanup Command ────────────────────────────────────────────────────────
647
+ cmd_cleanup() {
648
+ ensure_dirs
649
+
650
+ local now_epoch_val
651
+ now_epoch_val=$(now_epoch)
652
+
653
+ local before_count after_count
654
+ before_count=$(jq '.links | length' "$SHARE_LINKS_FILE" 2>/dev/null || echo "0")
655
+
656
+ local tmp_file
657
+ tmp_file=$(mktemp)
658
+ trap "rm -f '$tmp_file'" EXIT
659
+
660
+ jq ".links |= map(select((.expires | fromdateiso8601) > $now_epoch_val))" "$SHARE_LINKS_FILE" > "$tmp_file"
661
+ mv "$tmp_file" "$SHARE_LINKS_FILE"
662
+
663
+ after_count=$(jq '.links | length' "$SHARE_LINKS_FILE" 2>/dev/null || echo "0")
664
+ local removed=$((before_count - after_count))
665
+
666
+ if [[ "$removed" -gt 0 ]]; then
667
+ success "Cleaned up $removed expired link(s)"
668
+ else
669
+ info "No expired links to clean up"
670
+ fi
671
+ }
672
+
673
+ # ─── Help ────────────────────────────────────────────────────────────────────
674
+ show_help() {
675
+ cat <<EOF
676
+ ${CYAN}${BOLD}shipwright public-dashboard${RESET} β€” Public real-time pipeline progress
677
+
678
+ ${BOLD}USAGE${RESET}
679
+ ${CYAN}shipwright public-dashboard${RESET} <command> [options]
680
+ ${CYAN}shipwright share${RESET} [--expires 24h] [--privacy anonymized]
681
+
682
+ ${BOLD}COMMANDS${RESET}
683
+ ${CYAN}export${RESET} [file] [title] [privacy]
684
+ Generate self-contained HTML dashboard file
685
+ File: output path (default: ~/.shipwright/public-dashboard/dashboard.html)
686
+ Title: page title (default: "Shipwright Pipeline Progress")
687
+ Privacy: stages_only, anonymized, or public (default: stages_only)
688
+
689
+ ${CYAN}share${RESET} [expiry_hours] [privacy_level]
690
+ Create a shareable link for real-time dashboard
691
+ Expiry: hours until link expires (default: 24)
692
+ Privacy: stages_only, anonymized, or public
693
+
694
+ ${CYAN}revoke${RESET} <token>
695
+ Revoke a share link (invalidate the token)
696
+
697
+ ${CYAN}list${RESET}
698
+ List all active share links
699
+
700
+ ${CYAN}config${RESET} [key] [value]
701
+ View or modify dashboard configuration
702
+ Keys: privacy, expiry, domain
703
+
704
+ ${CYAN}embed${RESET} <token> [format]
705
+ Generate embed code (iframe, badge, markdown, link)
706
+
707
+ ${CYAN}cleanup${RESET}
708
+ Remove expired share links
709
+
710
+ ${CYAN}help${RESET}
711
+ Show this help message
712
+
713
+ ${BOLD}EXAMPLES${RESET}
714
+ # Export static HTML
715
+ ${DIM}shipwright public-dashboard export${RESET}
716
+ ${DIM}shipwright public-dashboard export dashboard.html "My Pipeline"${RESET}
717
+
718
+ # Create shareable link (requires dashboard server)
719
+ ${DIM}shipwright public-dashboard share 48 anonymized${RESET}
720
+
721
+ # Generate embed code for README
722
+ ${DIM}shipwright public-dashboard embed abc123def456 markdown${RESET}
723
+
724
+ # Configure default privacy level
725
+ ${DIM}shipwright public-dashboard config privacy anonymized${RESET}
726
+ ${DIM}shipwright public-dashboard config domain app.example.com${RESET}
727
+
728
+ ${BOLD}PRIVACY LEVELS${RESET}
729
+ ${CYAN}stages_only${RESET}
730
+ Only stage names and generic status info (most private)
731
+
732
+ ${CYAN}anonymized${RESET}
733
+ Full details with paths and tokens redacted
734
+
735
+ ${CYAN}public${RESET}
736
+ All details including paths and environment (least private)
737
+
738
+ ${BOLD}OUTPUT${RESET}
739
+ Generated HTML files are completely self-contained:
740
+ - No external resources (all CSS/JS embedded)
741
+ - ~50 KB gzipped
742
+ - Works offline
743
+ - Safe to share
744
+
745
+ ${BOLD}SHARE LINKS${RESET}
746
+ Share links require a running dashboard server to serve the public endpoint.
747
+ By default, requires dashboard to serve at: https://your-domain.com/public-dashboard/<token>
748
+
749
+ ${DIM}Docs: https://sethdford.github.io/shipwright${RESET}
750
+ EOF
751
+ }
752
+
753
+ # ─── Main ───────────────────────────────────────────────────────────────────
754
+ main() {
755
+ local cmd="${1:-help}"
756
+
757
+ case "$cmd" in
758
+ export)
759
+ shift
760
+ cmd_export "$@"
761
+ ;;
762
+ share)
763
+ shift
764
+ cmd_share "$@"
765
+ ;;
766
+ revoke)
767
+ shift
768
+ cmd_revoke "$@"
769
+ ;;
770
+ list)
771
+ cmd_list
772
+ ;;
773
+ config)
774
+ shift
775
+ cmd_config "$@"
776
+ ;;
777
+ embed)
778
+ shift
779
+ cmd_embed "$@"
780
+ ;;
781
+ cleanup)
782
+ cmd_cleanup
783
+ ;;
784
+ help|--help|-h)
785
+ show_help
786
+ ;;
787
+ *)
788
+ error "Unknown command: $cmd"
789
+ echo ""
790
+ show_help
791
+ exit 1
792
+ ;;
793
+ esac
794
+ }
795
+
796
+ if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
797
+ main "$@"
798
+ fi