forked from sendit2me/dev-strap
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathdevstack.sh
More file actions
executable file
·2324 lines (2069 loc) · 91.2 KB
/
Copy pathdevstack.sh
File metadata and controls
executable file
·2324 lines (2069 loc) · 91.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/bin/bash
# =============================================================================
# DevStack CLI
# =============================================================================
# A container-first development environment with transparent mock interception.
#
# Usage:
# ./devstack.sh start Start the full stack (generates config, builds, runs)
# ./devstack.sh stop Stop and remove all containers + volumes (clean slate)
# ./devstack.sh test [filter] Run Playwright tests inside container
# ./devstack.sh shell [svc] Drop into a shell in a running container (default: app)
# ./devstack.sh status Show running containers and their health
# ./devstack.sh logs [svc] Tail logs for a service (default: all)
# ./devstack.sh generate Regenerate config without starting (for inspection)
# ./devstack.sh mocks List all configured mock services and domains
#
# Prerequisites: Docker and Docker Compose (v2)
# =============================================================================
set -euo pipefail
DEVSTACK_DIR="$(cd "$(dirname "$0")" && pwd)"
GENERATED_DIR="${DEVSTACK_DIR}/.generated"
BOOTSTRAP_FORCE=0 # set by --bootstrap --force; guards non-empty targets
BOOTSTRAP_DEST="" # set by --bootstrap --dest; overrides the assembly directory
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
NC='\033[0m' # No Color
log() { echo -e "${BLUE}[devstack]${NC} $*"; }
log_ok() { echo -e "${GREEN}[devstack]${NC} $*"; }
log_warn() { echo -e "${YELLOW}[devstack]${NC} $*"; }
log_err() { echo -e "${RED}[devstack]${NC} $*"; }
check_docker() {
if ! command -v docker &>/dev/null; then
log_err "Docker is not installed. Install Docker first: https://docs.docker.com/get-docker/"
exit 1
fi
if ! docker compose version &>/dev/null; then
log_err "Docker Compose v2 is required. Update Docker: https://docs.docker.com/compose/install/"
exit 1
fi
}
load_config() {
if [ ! -f "${DEVSTACK_DIR}/project.env" ]; then
log_err "project.env not found in ${DEVSTACK_DIR}"
log_err "Copy from project.env.example and configure for your project."
exit 1
fi
source "${DEVSTACK_DIR}/project.env"
}
# ---------------------------------------------------------------------------
# Generate all config from directory structure
# ---------------------------------------------------------------------------
cmd_generate() {
log "Generating configuration from directory structure..."
mkdir -p "${GENERATED_DIR}"
# 1. Generate Caddyfile
log "Generating Caddyfile..."
bash "${DEVSTACK_DIR}/core/caddy/generate-caddyfile.sh"
# 2. Generate docker-compose.yml
log "Generating docker-compose.yml..."
bash "${DEVSTACK_DIR}/core/compose/generate.sh"
log_ok "Configuration generated in ${GENERATED_DIR}/"
log " - Caddyfile"
log " - docker-compose.yml"
log " - domains.txt"
}
# ---------------------------------------------------------------------------
# Start
# ---------------------------------------------------------------------------
cmd_start() {
log "Starting DevStack for ${PROJECT_NAME}..."
# Ensure test results directory exists
mkdir -p "${DEVSTACK_DIR}/tests/results"
# Generate all config
cmd_generate
# Ensure app source directory exists
if [ ! -d "${DEVSTACK_DIR}/${APP_SOURCE}" ]; then
log_warn "App source directory '${APP_SOURCE}' not found."
log_warn "Creating it with a placeholder. Add your application code there."
mkdir -p "${DEVSTACK_DIR}/${APP_SOURCE}"
fi
# Build and start
log "Building containers..."
if ! docker compose -f "${GENERATED_DIR}/docker-compose.yml" \
-p "${PROJECT_NAME}" \
build; then
log_err "Docker build failed. Check your Dockerfile and app source."
exit 1
fi
log "Starting services..."
docker compose -f "${GENERATED_DIR}/docker-compose.yml" \
-p "${PROJECT_NAME}" \
up -d
# Wait for cert-gen to complete
log "Waiting for certificate generation..."
docker compose -f "${GENERATED_DIR}/docker-compose.yml" \
-p "${PROJECT_NAME}" \
wait cert-gen 2>/dev/null || true
# Wait for database health
if [ "${DB_TYPE}" != "none" ]; then
log "Waiting for database (${DB_TYPE})..."
local retries=0
local max_retries=30
while [ $retries -lt $max_retries ]; do
if docker compose -f "${GENERATED_DIR}/docker-compose.yml" \
-p "${PROJECT_NAME}" \
exec -T db true 2>/dev/null; then
local health
health=$(docker inspect --format='{{.State.Health.Status}}' "${PROJECT_NAME}-db" 2>/dev/null || echo "unknown")
if [ "${health}" = "healthy" ]; then
break
fi
fi
retries=$((retries + 1))
sleep 2
done
if [ $retries -ge $max_retries ]; then
log_warn "Database health check timed out — continuing anyway"
fi
fi
# Run init script if configured — pipe the script content into the container
# This works regardless of where the app source is mounted (/app, /var/www/html, etc.)
if [ -n "${APP_INIT_SCRIPT:-}" ] && [ -f "${DEVSTACK_DIR}/${APP_INIT_SCRIPT}" ]; then
log "Running app init script..."
docker compose -f "${GENERATED_DIR}/docker-compose.yml" \
-p "${PROJECT_NAME}" \
exec -T app sh < "${DEVSTACK_DIR}/${APP_INIT_SCRIPT}" || \
log_warn "Init script failed — check './devstack.sh logs app' for details"
fi
# Print summary
echo ""
log_ok "============================================="
log_ok " DevStack is running: ${PROJECT_NAME}"
log_ok "============================================="
echo ""
log "Application: http://localhost:${HTTP_PORT}"
log "Application SSL: https://localhost:${HTTPS_PORT}"
log "Test Dashboard: http://localhost:${TEST_DASHBOARD_PORT}"
# List mocked services
local mock_count=0
if [ -d "${DEVSTACK_DIR}/mocks" ]; then
for mock_dir in "${DEVSTACK_DIR}"/mocks/*/; do
[ -d "${mock_dir}" ] || continue
[ -f "${mock_dir}domains" ] || continue
mock_count=$((mock_count + 1))
done
fi
if [ $mock_count -gt 0 ]; then
echo ""
log "Mocked services (${mock_count}):"
for mock_dir in "${DEVSTACK_DIR}"/mocks/*/; do
[ -d "${mock_dir}" ] || continue
[ -f "${mock_dir}domains" ] || continue
local name=$(basename "${mock_dir}")
local domains=$(cat "${mock_dir}domains" | tr '\n' ', ' | sed 's/,$//')
log " ${CYAN}${name}${NC} → ${domains}"
done
fi
if [ "${DB_TYPE}" != "none" ]; then
echo ""
log "Database (${DB_TYPE}): ${DB_NAME} (user: ${DB_USER})"
fi
echo ""
log "Commands:"
log " ./devstack.sh test Run tests"
log " ./devstack.sh shell Shell into app container"
log " ./devstack.sh logs Tail all logs"
log " ./devstack.sh stop Stop and clean everything"
echo ""
}
# ---------------------------------------------------------------------------
# Stop
# ---------------------------------------------------------------------------
cmd_stop() {
log "Stopping DevStack..."
if [ -f "${GENERATED_DIR}/docker-compose.yml" ]; then
docker compose -f "${GENERATED_DIR}/docker-compose.yml" \
-p "${PROJECT_NAME}" \
down -v --remove-orphans 2>/dev/null || true
else
# Fallback: try to stop by project name
docker compose -p "${PROJECT_NAME}" down -v --remove-orphans 2>/dev/null || true
fi
# Clean generated files
rm -rf "${GENERATED_DIR}"
# Clean test results and node_modules (may be root-owned from containers)
if [ -d "${DEVSTACK_DIR}/tests/results" ] || [ -d "${DEVSTACK_DIR}/tests/playwright/node_modules" ]; then
docker run --rm \
-v "${DEVSTACK_DIR}/tests:/data" \
alpine sh -c "rm -rf /data/results/* /data/playwright/node_modules /data/playwright/package-lock.json" 2>/dev/null || true
fi
log_ok "DevStack stopped. All containers, volumes, and generated files removed."
log "Run './devstack.sh start' for a fresh stack."
}
# ---------------------------------------------------------------------------
# Test
# ---------------------------------------------------------------------------
cmd_test() {
local filter="${1:-}"
local run_id
run_id="$(date +%Y%m%d-%H%M%S)"
if [ ! -f "${GENERATED_DIR}/docker-compose.yml" ]; then
log_err "DevStack is not running. Run './devstack.sh start' first."
exit 1
fi
# Create run-specific results directory
local results_dir="${DEVSTACK_DIR}/tests/results/${run_id}"
mkdir -p "${results_dir}"
log "Running tests (run: ${run_id})..."
# Build the playwright command — install deps first, then run tests
local pw_cmd="cd /tests && npm install --silent 2>/dev/null && npx playwright test"
if [ -n "${filter}" ]; then
pw_cmd="${pw_cmd} --grep '${filter}'"
log "Filter: ${filter}"
fi
pw_cmd="${pw_cmd} --reporter=html,json"
pw_cmd="${pw_cmd} --output=/results/${run_id}/artifacts"
# Set environment for this run
local exit_code=0
docker compose -f "${GENERATED_DIR}/docker-compose.yml" \
-p "${PROJECT_NAME}" \
exec -T \
-e PLAYWRIGHT_HTML_REPORT="/results/${run_id}/report" \
-e PLAYWRIGHT_JSON_OUTPUT_FILE="/results/${run_id}/results.json" \
tester bash -c "${pw_cmd}" || exit_code=$?
echo ""
if [ $exit_code -eq 0 ]; then
log_ok "All tests passed."
else
log_err "Tests failed (exit code: ${exit_code})"
fi
log "Report: http://localhost:${TEST_DASHBOARD_PORT}/${run_id}/report/index.html"
log "Artifacts: http://localhost:${TEST_DASHBOARD_PORT}/${run_id}/artifacts/"
log "JSON: http://localhost:${TEST_DASHBOARD_PORT}/${run_id}/results.json"
return $exit_code
}
# ---------------------------------------------------------------------------
# Shell
# ---------------------------------------------------------------------------
cmd_shell() {
local service="${1:-app}"
if [ ! -f "${GENERATED_DIR}/docker-compose.yml" ]; then
log_err "DevStack is not running. Run './devstack.sh start' first."
exit 1
fi
log "Opening shell in '${service}' container..."
# Try bash first, fall back to sh
docker compose -f "${GENERATED_DIR}/docker-compose.yml" \
-p "${PROJECT_NAME}" \
exec "${service}" bash 2>/dev/null || \
docker compose -f "${GENERATED_DIR}/docker-compose.yml" \
-p "${PROJECT_NAME}" \
exec "${service}" sh
}
# ---------------------------------------------------------------------------
# Status
# ---------------------------------------------------------------------------
cmd_status() {
if [ ! -f "${GENERATED_DIR}/docker-compose.yml" ]; then
log_warn "DevStack is not running (no generated config found)."
return 0
fi
docker compose -f "${GENERATED_DIR}/docker-compose.yml" \
-p "${PROJECT_NAME}" \
ps --format "table {{.Name}}\t{{.Status}}\t{{.Ports}}"
}
# ---------------------------------------------------------------------------
# Logs
# ---------------------------------------------------------------------------
cmd_logs() {
local service="${1:-}"
if [ ! -f "${GENERATED_DIR}/docker-compose.yml" ]; then
log_err "DevStack is not running. Run './devstack.sh start' first."
exit 1
fi
if [ -n "${service}" ]; then
docker compose -f "${GENERATED_DIR}/docker-compose.yml" \
-p "${PROJECT_NAME}" \
logs -f "${service}"
else
docker compose -f "${GENERATED_DIR}/docker-compose.yml" \
-p "${PROJECT_NAME}" \
logs -f
fi
}
# ---------------------------------------------------------------------------
# Mocks — list configured mock services
# ---------------------------------------------------------------------------
cmd_mocks() {
echo ""
log "Configured mock services:"
echo ""
if [ ! -d "${DEVSTACK_DIR}/mocks" ]; then
log_warn "No mocks directory found."
return 0
fi
local count=0
for mock_dir in "${DEVSTACK_DIR}"/mocks/*/; do
[ -d "${mock_dir}" ] || continue
local name=$(basename "${mock_dir}")
local domains_file="${mock_dir}domains"
local mappings_dir="${mock_dir}mappings"
count=$((count + 1))
echo -e " ${CYAN}${name}${NC}"
if [ -f "${domains_file}" ]; then
echo " Domains:"
while IFS= read -r domain || [ -n "${domain}" ]; do
domain=$(echo "${domain}" | tr -d '[:space:]')
[ -z "${domain}" ] && continue
[[ "${domain}" == \#* ]] && continue
echo " - ${domain}"
done < "${domains_file}"
else
echo -e " ${YELLOW}No domains file${NC}"
fi
if [ -d "${mappings_dir}" ]; then
local mapping_count
mapping_count=$(find "${mappings_dir}" -name "*.json" | wc -l)
echo " Mappings: ${mapping_count} JSON file(s)"
# List mapping names from the JSON files
for mapping in "${mappings_dir}"/*.json; do
[ -f "${mapping}" ] || continue
local mapping_name
mapping_name=$(grep -o '"name"[[:space:]]*:[[:space:]]*"[^"]*"' "${mapping}" 2>/dev/null | head -1 | sed 's/"name"[[:space:]]*:[[:space:]]*"//;s/"$//')
if [ -n "${mapping_name}" ]; then
echo " - ${mapping_name}"
else
echo " - $(basename "${mapping}")"
fi
done
else
echo -e " ${YELLOW}No mappings directory${NC}"
fi
echo ""
done
if [ $count -eq 0 ]; then
log "No mock services configured."
log "Create one: mkdir -p mocks/my-api/mappings && echo 'api.example.com' > mocks/my-api/domains"
fi
log "To add a mock:"
log " 1. mkdir -p mocks/<name>/mappings"
log " 2. echo 'api.domain.com' > mocks/<name>/domains"
log " 3. Add WireMock JSON mappings to mocks/<name>/mappings/"
log " 4. Run './devstack.sh restart'"
}
# ---------------------------------------------------------------------------
# Restart — convenience stop + start
# ---------------------------------------------------------------------------
cmd_restart() {
cmd_stop
cmd_start
}
# ---------------------------------------------------------------------------
# Reload Mocks — hot-reload WireMock mappings without full restart
# ---------------------------------------------------------------------------
cmd_reload_mocks() {
if [ ! -f "${GENERATED_DIR}/docker-compose.yml" ]; then
log_err "DevStack is not running. Run './devstack.sh start' first."
exit 1
fi
log "Reloading mock mappings..."
# Reset WireMock mappings from disk
local response
response=$(docker compose -f "${GENERATED_DIR}/docker-compose.yml" \
-p "${PROJECT_NAME}" \
exec -T wiremock wget -qO- --post-data='' \
"http://localhost:8080/__admin/mappings/reset" 2>&1) || {
log_err "Failed to reload mocks. Is WireMock running?"
log "Try: ./devstack.sh status"
return 1
}
# Show loaded mapping count
local mappings_response count
mappings_response=$(docker compose -f "${GENERATED_DIR}/docker-compose.yml" \
-p "${PROJECT_NAME}" \
exec -T wiremock wget -qO- \
"http://localhost:8080/__admin/mappings" 2>/dev/null) || true
count=$(echo "${mappings_response}" | grep -o '"total" *: *[0-9]*' | grep -o '[0-9]*' | head -1)
count="${count:-0}"
log_ok "Mock mappings reloaded (${count} mappings loaded)."
log "Note: New domains require a full restart (./devstack.sh restart)"
}
# ---------------------------------------------------------------------------
# New Mock — scaffold a new mock service
# ---------------------------------------------------------------------------
cmd_new_mock() {
local name="${1:-}"
local domain="${2:-}"
if [ -z "${name}" ]; then
log_err "Usage: ./devstack.sh new-mock <name> <domain>"
log "Example: ./devstack.sh new-mock stripe api.stripe.com"
exit 1
fi
if [ -z "${domain}" ]; then
log_err "Usage: ./devstack.sh new-mock <name> <domain>"
log "Example: ./devstack.sh new-mock ${name} api.${name}.com"
exit 1
fi
local mock_dir="${DEVSTACK_DIR}/mocks/${name}"
if [ -d "${mock_dir}" ]; then
log_err "Mock '${name}' already exists at ${mock_dir}"
exit 1
fi
# Create directory structure
mkdir -p "${mock_dir}/mappings"
# Write domains file
echo "${domain}" > "${mock_dir}/domains"
# Write example mapping
cat > "${mock_dir}/mappings/example.json" <<MOCK_EOF
{
"name": "${name} — example endpoint",
"request": {
"method": "GET",
"url": "/v1/status"
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"jsonBody": {
"service": "${name}",
"status": "ok",
"mocked": true
}
}
}
MOCK_EOF
log_ok "Created mock '${name}' at mocks/${name}/"
log " Domain: ${domain}"
log " Mapping: mocks/${name}/mappings/example.json"
echo ""
log "Next steps:"
log " 1. Edit mocks/${name}/mappings/example.json (or add more mappings)"
log " 2. Run './devstack.sh restart' to pick up the new domain"
log " After the first restart, use './devstack.sh reload-mocks' for mapping changes"
}
# ---------------------------------------------------------------------------
# Record — proxy to real API and capture responses as mock mappings
# ---------------------------------------------------------------------------
cmd_record() {
local name="${1:-}"
if [ -z "${name}" ]; then
log_err "Usage: ./devstack.sh record <mock-name>"
log ""
log "Records real API responses and saves them as WireMock mappings."
log "The mock must already exist (use './devstack.sh new-mock' first)."
log ""
log "Example:"
log " ./devstack.sh new-mock stripe api.stripe.com"
log " ./devstack.sh record stripe"
log " # Make requests through the app — real responses are captured"
log " # Press Ctrl+C to stop recording"
log " # Review, then apply: ./devstack.sh apply-recording stripe"
exit 1
fi
local mock_dir="${DEVSTACK_DIR}/mocks/${name}"
if [ ! -d "${mock_dir}" ]; then
log_err "Mock '${name}' not found. Create it first:"
log " ./devstack.sh new-mock ${name} api.${name}.com"
exit 1
fi
local domains_file="${mock_dir}/domains"
if [ ! -f "${domains_file}" ]; then
log_err "No domains file in mocks/${name}/. Add the domain to intercept."
exit 1
fi
# Read the first domain as the proxy target
local target_domain
target_domain=$(head -1 "${domains_file}" | tr -d '[:space:]')
if [ -z "${target_domain}" ]; then
log_err "domains file is empty in mocks/${name}/"
exit 1
fi
if [ ! -f "${GENERATED_DIR}/docker-compose.yml" ]; then
log_err "DevStack is not running. Run './devstack.sh start' first."
exit 1
fi
local record_dir="${mock_dir}/recordings"
# Clean previous recordings (root-owned from container)
if [ -d "${record_dir}" ]; then
docker run --rm -v "${record_dir}:/data" alpine rm -rf /data/mappings /data/__files 2>/dev/null || true
fi
mkdir -p "${record_dir}/mappings" "${record_dir}/__files"
log "Starting recording for '${name}' (proxying to https://${target_domain})..."
log ""
log "How it works:"
log " 1. A temporary WireMock recorder proxies requests to the REAL ${target_domain}"
log " 2. Make requests through your app as normal"
log " 3. Press Ctrl+C when done — captured mappings are saved"
log ""
log_warn "This calls the REAL API. You need valid credentials and may incur costs."
echo ""
# Run a temporary WireMock container in record mode
docker run --rm -it \
--name "${PROJECT_NAME}-recorder" \
--network "${PROJECT_NAME}_${PROJECT_NAME}-internal" \
-v "${record_dir}/mappings:/home/wiremock/mappings" \
-v "${record_dir}/__files:/home/wiremock/__files" \
wiremock/wiremock:latest \
--port 8080 \
--proxy-all "https://${target_domain}" \
--record-mappings \
--verbose || true
# Count captured mappings (use docker to read root-owned files)
local captured
captured=$(docker run --rm -v "${record_dir}:/data" alpine sh -c \
'find /data/mappings -name "*.json" 2>/dev/null | wc -l' 2>/dev/null)
captured=$(echo "${captured}" | tr -d '[:space:]')
if [ "${captured}" -gt 0 ]; then
echo ""
log_ok "Recorded ${captured} mapping(s)."
echo ""
# List what was captured
docker run --rm -v "${record_dir}:/data" alpine sh -c \
'for f in /data/mappings/*.json; do echo " - $(basename "$f")"; done' 2>/dev/null
echo ""
log "Next steps:"
log " 1. Review recordings: ls mocks/${name}/recordings/mappings/"
log " 2. Apply to mock: ./devstack.sh apply-recording ${name}"
log " 3. Activate: ./devstack.sh reload-mocks (or restart for new domains)"
log ""
log_warn "Review recordings before applying — they may contain API keys or tokens in headers."
else
echo ""
log_warn "No mappings captured. Did you make requests while recording?"
log "The recorder listens at http://${PROJECT_NAME}-recorder:8080 inside the Docker network."
log "From another terminal: ./devstack.sh shell app"
log "Then: wget -qO- http://${PROJECT_NAME}-recorder:8080/your/endpoint"
fi
}
# ---------------------------------------------------------------------------
# Apply Recording — copy recorded mappings into the active mock
# ---------------------------------------------------------------------------
cmd_apply_recording() {
local name="${1:-}"
if [ -z "${name}" ]; then
log_err "Usage: ./devstack.sh apply-recording <mock-name>"
exit 1
fi
local mock_dir="${DEVSTACK_DIR}/mocks/${name}"
local record_dir="${mock_dir}/recordings"
if [ ! -d "${record_dir}/mappings" ]; then
log_err "No recordings found for '${name}'."
log "Run './devstack.sh record ${name}' first."
exit 1
fi
# Count recordings
local count
count=$(docker run --rm -v "${record_dir}:/data" alpine sh -c \
'find /data/mappings -name "*.json" 2>/dev/null | wc -l' 2>/dev/null)
count=$(echo "${count}" | tr -d '[:space:]')
if [ "${count}" -eq 0 ]; then
log_warn "No recorded mappings to apply."
exit 0
fi
log "Applying ${count} recording(s) to mocks/${name}/..."
# Ensure target directories exist
mkdir -p "${mock_dir}/mappings" "${mock_dir}/__files"
# Copy mappings and __files, fix ownership to current user, and rewrite
# bodyFileName paths to include the mock subdirectory (WireMock mounts
# __files at /home/wiremock/__files/<name>/)
docker run --rm \
-v "${record_dir}:/src:ro" \
-v "${mock_dir}:/dst" \
-e "MOCK_NAME=${name}" \
alpine sh -c '
# Copy mappings — rewrite bodyFileName to include subdirectory
for f in /src/mappings/*.json; do
[ -f "$f" ] || continue
fname=$(basename "$f")
if grep -q "bodyFileName" "$f"; then
# Rewrite "bodyFileName": "body-xxx.json"
# to "bodyFileName": "<mock_name>/body-xxx.json"
sed "s|\"bodyFileName\" *: *\"|\"bodyFileName\" : \"${MOCK_NAME}/|" "$f" \
> "/dst/mappings/${fname}"
else
cp "$f" "/dst/mappings/${fname}"
fi
done
# Copy response body files
for f in /src/__files/*; do
[ -f "$f" ] || continue
cp "$f" "/dst/__files/$(basename "$f")"
done
# Fix ownership so host user can read/edit
chown -R '"$(id -u):$(id -g)"' /dst/mappings/ /dst/__files/ 2>/dev/null || true
'
# List what was applied
log_ok "Applied ${count} recording(s):"
for f in "${mock_dir}/mappings"/mapping-*.json; do
[ -f "${f}" ] || continue
log " - $(basename "${f}")"
done
echo ""
# Clean up recordings
docker run --rm -v "${record_dir}:/data" alpine rm -rf /data/mappings /data/__files 2>/dev/null || true
rmdir "${record_dir}" 2>/dev/null || true
log_ok "Recordings applied and cleaned up."
# Reload if stack is running
if [ -f "${GENERATED_DIR}/docker-compose.yml" ]; then
log "Reloading mock mappings..."
cmd_reload_mocks
else
log "Run './devstack.sh restart' to activate the new mappings."
fi
}
# ---------------------------------------------------------------------------
# Verify Mocks — check all mocked domains are reachable from inside the app
# ---------------------------------------------------------------------------
cmd_verify_mocks() {
if [ ! -f "${GENERATED_DIR}/docker-compose.yml" ]; then
log_err "DevStack is not running. Run './devstack.sh start' first."
exit 1
fi
log "Verifying mock interception..."
echo ""
local pass=0
local fail=0
for mock_dir in "${DEVSTACK_DIR}"/mocks/*/; do
[ -d "${mock_dir}" ] || continue
local domains_file="${mock_dir}domains"
[ -f "${domains_file}" ] || continue
local name=$(basename "${mock_dir}")
while IFS= read -r domain || [ -n "${domain}" ]; do
domain=$(echo "${domain}" | tr -d '[:space:]')
[ -z "${domain}" ] && continue
[[ "${domain}" == \#* ]] && continue
# Try to reach the domain from inside the app container via HTTPS
# --no-check-certificate: we're testing routing, not cert trust
local http_code
http_code=$(docker compose -f "${GENERATED_DIR}/docker-compose.yml" \
-p "${PROJECT_NAME}" \
exec -T app sh -c \
"wget --no-check-certificate -qS --timeout=5 -O /dev/null https://${domain}/ 2>&1 | grep -o 'HTTP/[0-9.]* [0-9]*' | tail -1" 2>/dev/null) || true
if echo "${http_code}" | grep -qE "HTTP.*[0-9]"; then
local status_num
status_num=$(echo "${http_code}" | grep -o '[0-9]*$')
if [ "${status_num}" = "404" ]; then
echo -e " ${GREEN}PASS${NC} ${domain} (${name}) — routed to WireMock (404: no mapping for /)"
else
echo -e " ${GREEN}PASS${NC} ${domain} (${name}) — HTTP ${status_num}"
fi
pass=$((pass + 1))
else
echo -e " ${RED}FAIL${NC} ${domain} (${name}) — not reachable"
fail=$((fail + 1))
fi
done < "${domains_file}"
done
echo ""
if [ $fail -eq 0 ]; then
log_ok "All ${pass} mocked domain(s) verified."
else
log_err "${fail} domain(s) failed. ${pass} passed."
log "Check: ./devstack.sh logs web (proxy routing)"
log "Check: ./devstack.sh status (container health)"
return 1
fi
}
# ---------------------------------------------------------------------------
# Init — scaffold a new project interactively
# ---------------------------------------------------------------------------
cmd_init() {
# TTY guard — init must be interactive
if [ ! -t 0 ]; then
log_err "init requires an interactive terminal"
exit 1
fi
require_jq
local manifest="${DEVSTACK_DIR}/contract/manifest.json"
if [ ! -f "${manifest}" ]; then
log_err "Manifest file not found at contract/manifest.json"
exit 1
fi
echo ""
log "DevStack Project Setup"
echo ""
# ── 1. Parse flags ────────────────────────────────────────────────────
local preset_name=""
local preset=""
if [ "${1:-}" = "--preset" ] && [ -n "${2:-}" ]; then
preset_name="$2"
preset=$(jq -r --arg p "${preset_name}" '.presets[$p] // empty' "${manifest}")
if [ -z "${preset}" ]; then
log_err "Unknown preset: ${preset_name}"
log "Available presets:"
jq -r '.presets | to_entries[] | " \(.key): \(.value.description)"' "${manifest}"
exit 1
fi
log "Using preset: $(printf '%s' "${preset}" | jq -r '.label') — $(printf '%s' "${preset}" | jq -r '.description')"
echo ""
fi
# ── 2. Project name ───────────────────────────────────────────────────
echo -n " Project name [myproject]: "
read -r input_name
local project_name="${input_name:-myproject}"
# Validate project name format
if ! printf '%s' "${project_name}" | grep -qE '^[a-z][a-z0-9-]*$'; then
log_err "Invalid project name '${project_name}'. Must match [a-z][a-z0-9-]*"
exit 1
fi
# ── 3. Walk categories interactively ──────────────────────────────────
# --- App type (required, single) ---
local app_type=""
# Check if preset pre-selects an app
if [ -n "${preset}" ]; then
app_type=$(printf '%s' "${preset}" | jq -r '.selections.app[0] // empty')
fi
if [ -z "${app_type}" ]; then
echo ""
echo " Available app types:"
for dir in "${DEVSTACK_DIR}/templates/apps"/*/; do
[ -d "${dir}" ] || continue
local app_name
app_name=$(basename "${dir}")
local app_label
app_label=$(jq -r --arg a "${app_name}" '.categories.app.items[$a].label // $a' "${manifest}")
echo " - ${app_name} (${app_label})"
done
echo -n " App type [node-express]: "
read -r input_app
app_type="${input_app:-node-express}"
else
log " App type (from preset): ${app_type}"
fi
# Validate app type
if [ ! -d "${DEVSTACK_DIR}/templates/apps/${app_type}" ]; then
log_err "Unknown app type '${app_type}'. Available:"
for dir in "${DEVSTACK_DIR}/templates/apps"/*/; do
[ -d "${dir}" ] || continue
echo " - $(basename "${dir}")"
done
exit 1
fi
# --- Frontend (optional, single) ---
local frontend_type=""
if [ -n "${preset}" ]; then
frontend_type=$(printf '%s' "${preset}" | jq -r '.selections.frontend[0] // empty')
fi
if [ -z "${frontend_type}" ]; then
# Only prompt if preset doesn't specify and there are templates available
local has_frontends=false
for dir in "${DEVSTACK_DIR}/templates/frontends"/*/; do
[ -d "${dir}" ] || { continue; }
has_frontends=true
break
done
if [ "${has_frontends}" = true ]; then
echo ""
echo " Available frontends (or 'none'):"
for dir in "${DEVSTACK_DIR}/templates/frontends"/*/; do
[ -d "${dir}" ] || continue
local fe_name
fe_name=$(basename "${dir}")
local fe_label
fe_label=$(jq -r --arg f "${fe_name}" '.categories.frontend.items[$f].label // $f' "${manifest}")
echo " - ${fe_name} (${fe_label})"
done
echo -n " Frontend [none]: "
read -r input_frontend
frontend_type="${input_frontend:-none}"
else
frontend_type="none"
fi
else
log " Frontend (from preset): ${frontend_type}"
fi
# --- Database (optional, single) ---
local db_type=""
if [ -n "${preset}" ]; then
db_type=$(printf '%s' "${preset}" | jq -r '.selections.database[0] // empty')
fi
if [ -z "${db_type}" ]; then
echo ""
echo " Available databases (or 'none'):"
for dir in "${DEVSTACK_DIR}/templates/databases"/*/; do
[ -d "${dir}" ] || continue
local db_name
db_name=$(basename "${dir}")
local db_label
db_label=$(jq -r --arg d "${db_name}" '.categories.database.items[$d].label // $d' "${manifest}")
echo " - ${db_name} (${db_label})"
done
echo -n " Database [postgres]: "
read -r input_db
db_type="${input_db:-postgres}"
else
log " Database (from preset): ${db_type}"
fi
# --- Services (optional, multi) ---
local selected_services=""
if [ -n "${preset}" ]; then
selected_services=$(printf '%s' "${preset}" | jq -r '(.selections.services // []) | join(",")')
fi
if [ -z "${selected_services}" ] && [ -z "${preset}" ]; then
local available_services=""
for svc in $(jq -r '.categories.services.items | keys[]' "${manifest}"); do
if [ -d "${DEVSTACK_DIR}/templates/extras/${svc}" ]; then
available_services="${available_services} ${svc}"
fi
done
if [ -n "${available_services}" ]; then
echo ""
echo " Available services (comma-separated, or 'none'):"
for svc in ${available_services}; do
local svc_label
svc_label=$(jq -r --arg s "${svc}" '.categories.services.items[$s].label // $s' "${manifest}")
local svc_desc
svc_desc=$(jq -r --arg s "${svc}" '.categories.services.items[$s].description // ""' "${manifest}")
echo " - ${svc} (${svc_label}: ${svc_desc})"
done
echo -n " Services [none]: "
read -r input_services
selected_services="${input_services:-none}"
fi
elif [ -n "${selected_services}" ]; then
log " Services (from preset): ${selected_services}"
fi
# --- Tooling (optional, multi) ---
local selected_tooling=""
if [ -n "${preset}" ]; then
selected_tooling=$(printf '%s' "${preset}" | jq -r '(.selections.tooling // []) | join(",")')
fi
if [ -z "${selected_tooling}" ] && [ -z "${preset}" ]; then
local available_tooling=""
for tool in $(jq -r '.categories.tooling.items | keys[]' "${manifest}"); do
# tooling items live under templates/extras/ or are virtual (like qa, qa-dashboard, devcontainer)
if [ -d "${DEVSTACK_DIR}/templates/extras/${tool}" ] || [ "${tool}" = "qa" ] || [ "${tool}" = "qa-dashboard" ] || [ "${tool}" = "devcontainer" ]; then
available_tooling="${available_tooling} ${tool}"
fi
done
if [ -n "${available_tooling}" ]; then
echo ""
echo " Available tooling (comma-separated, or 'none'):"
for tool in ${available_tooling}; do
local tool_label
tool_label=$(jq -r --arg t "${tool}" '.categories.tooling.items[$t].label // $t' "${manifest}")
local tool_desc
tool_desc=$(jq -r --arg t "${tool}" '.categories.tooling.items[$t].description // ""' "${manifest}")
echo " - ${tool} (${tool_label}: ${tool_desc})"
done
echo -n " Tooling [none]: "
read -r input_tooling
selected_tooling="${input_tooling:-none}"
fi
elif [ -n "${selected_tooling}" ]; then
log " Tooling (from preset): ${selected_tooling}"
fi
# --- Observability (optional, multi) ---
local selected_observability=""
if [ -n "${preset}" ]; then
selected_observability=$(printf '%s' "${preset}" | jq -r '(.selections.observability // []) | join(",")')
fi
if [ -z "${selected_observability}" ] && [ -z "${preset}" ]; then
local available_obs=""
for obs in $(jq -r '.categories.observability.items | keys[]' "${manifest}"); do
if [ -d "${DEVSTACK_DIR}/templates/extras/${obs}" ]; then
available_obs="${available_obs} ${obs}"
fi
done
if [ -n "${available_obs}" ]; then
echo ""