backend-skeleton 1.0.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +284 -0
- package/bin/bskel.mjs +2384 -0
- package/contracts/completeness.mjs +176 -0
- package/contracts/emit.mjs +287 -0
- package/contracts/export.mjs +325 -0
- package/contracts/openapi.mjs +869 -0
- package/contracts/validate.mjs +147 -0
- package/handles/_engine.mjs +281 -0
- package/handles/codec.mjs +119 -0
- package/handles/conformance.mjs +74 -0
- package/handles/providers/java-spring/ast-bridge.mjs +59 -0
- package/handles/providers/java-spring/ast-helper/build.gradle +34 -0
- package/handles/providers/java-spring/ast-helper/gradle/wrapper/gradle-wrapper.jar +0 -0
- package/handles/providers/java-spring/ast-helper/gradle/wrapper/gradle-wrapper.properties +9 -0
- package/handles/providers/java-spring/ast-helper/gradlew +248 -0
- package/handles/providers/java-spring/ast-helper/gradlew.bat +82 -0
- package/handles/providers/java-spring/ast-helper/settings.gradle +1 -0
- package/handles/providers/java-spring/ast-helper/src/main/java/com/backendskeleton/asthelper/Main.java +178 -0
- package/handles/providers/java-spring/emit.mjs +232 -0
- package/handles/providers/java-spring/patch-strategy.mjs +229 -0
- package/handles/providers/java-spring/plan.mjs +377 -0
- package/handles/providers/java-spring/templates/HandleAspect.java.tmpl +125 -0
- package/handles/providers/java-spring/templates/HandleCodec.java.tmpl +150 -0
- package/handles/providers/java-spring/templates/HandleController.java.tmpl +177 -0
- package/handles/providers/java-spring/templates/HandleRegistry.java.tmpl +107 -0
- package/handles/providers/java-spring/templates/HandleRegistryRepository.java.tmpl +8 -0
- package/handles/providers/java-spring/templates/HandleService.java.tmpl +95 -0
- package/handles/providers/java-spring/templates/HandleSnapshot.java.tmpl +75 -0
- package/handles/providers/java-spring/templates/HandleSnapshotRepository.java.tmpl +20 -0
- package/handles/providers/java-spring/templates/RecordHandleSnapshot.java.tmpl +50 -0
- package/handles/providers/java-spring/templates/ResourceResolver.java.tmpl +50 -0
- package/handles/providers/java-spring/templates/ResourceResolverStub.java.tmpl +77 -0
- package/handles/providers/java-spring/templates/migration.sql.tmpl +34 -0
- package/handles/providers/java-spring.mjs +21 -0
- package/handles/providers/python-fastapi/emit.mjs +171 -0
- package/handles/providers/python-fastapi/plan.mjs +186 -0
- package/handles/providers/python-fastapi/templates/__init__.py.tmpl +1 -0
- package/handles/providers/python-fastapi/templates/codec.py.tmpl +122 -0
- package/handles/providers/python-fastapi/templates/handle_service.py.tmpl +96 -0
- package/handles/providers/python-fastapi/templates/migration.sql.tmpl +35 -0
- package/handles/providers/python-fastapi/templates/record_snapshot.py.tmpl +155 -0
- package/handles/providers/python-fastapi/templates/registry.py.tmpl +37 -0
- package/handles/providers/python-fastapi/templates/resolver.py.tmpl +59 -0
- package/handles/providers/python-fastapi/templates/resolvers_init.py.tmpl +13 -0
- package/handles/providers/python-fastapi/templates/router.py.tmpl +140 -0
- package/handles/providers/python-fastapi/templates/tables.py.tmpl +66 -0
- package/handles/providers/python-fastapi.mjs +22 -0
- package/handles/providers/typescript-express/emit.mjs +128 -0
- package/handles/providers/typescript-express/plan.mjs +234 -0
- package/handles/providers/typescript-express/templates/codec.ts.tmpl +116 -0
- package/handles/providers/typescript-express/templates/registry.ts.tmpl +39 -0
- package/handles/providers/typescript-express/templates/resolver.ts.tmpl +55 -0
- package/handles/providers/typescript-express/templates/resolvers_index.ts.tmpl +11 -0
- package/handles/providers/typescript-express/templates/router.ts.tmpl +122 -0
- package/handles/providers/typescript-express.mjs +20 -0
- package/handles/registry.mjs +90 -0
- package/lib/cli.mjs +430 -0
- package/lib/doctor.mjs +200 -0
- package/lib/exit-codes.mjs +67 -0
- package/lib/featureid.mjs +55 -0
- package/lib/featurelifecycle.mjs +205 -0
- package/lib/fsutil.mjs +50 -0
- package/lib/gate-definitions.mjs +293 -0
- package/lib/gates.mjs +263 -0
- package/lib/handles-manifest.mjs +92 -0
- package/lib/lock.mjs +68 -0
- package/lib/patch-approvals.mjs +56 -0
- package/lib/paths.mjs +21 -0
- package/lib/repo.mjs +44 -0
- package/lib/schema-validate.mjs +56 -0
- package/lib/state.mjs +124 -0
- package/lib/template.mjs +35 -0
- package/lib/verify.mjs +206 -0
- package/lib/workflow.mjs +142 -0
- package/new/fastapi.mjs +165 -0
- package/new/index.mjs +62 -0
- package/new/params.mjs +233 -0
- package/new/spring.mjs +198 -0
- package/new/templates/fastapi/README.md +26 -0
- package/new/templates/fastapi/app/__init__.py +0 -0
- package/new/templates/fastapi/app/main.py +8 -0
- package/new/templates/fastapi/gitignore +6 -0
- package/new/templates/fastapi/pyproject.toml +14 -0
- package/package.json +50 -0
- package/scanners/adapters/_express-shared.mjs +238 -0
- package/scanners/adapters/_java-spring-analyzer.mjs +273 -0
- package/scanners/adapters/generic-grep.mjs +128 -0
- package/scanners/adapters/java-spring.mjs +301 -0
- package/scanners/adapters/javascript-express.mjs +422 -0
- package/scanners/adapters/python-fastapi.mjs +348 -0
- package/scanners/adapters/typescript-express.mjs +299 -0
- package/scanners/capabilities.mjs +90 -0
- package/scanners/conformance.mjs +59 -0
- package/scanners/db/introspect.mjs +109 -0
- package/scanners/db/migrations.mjs +126 -0
- package/scanners/index.mjs +281 -0
- package/scanners/registry.mjs +130 -0
- package/scanners/render.mjs +136 -0
- package/scanners/text-util.mjs +8 -0
- package/schemas/adapter.schema.json +23 -0
- package/schemas/agent-envelope.schema.json +21 -0
- package/schemas/contract-resolution.schema.json +28 -0
- package/schemas/feature-contract.schema.json +78 -0
- package/schemas/feature-index.schema.json +25 -0
- package/schemas/feature.schema.json +17 -0
- package/schemas/gate-event.schema.json +19 -0
- package/schemas/handles-plan.schema.json +31 -0
- package/schemas/handles-provider.schema.json +26 -0
- package/schemas/patch-approvals.schema.json +28 -0
- package/schemas/scan-report.schema.json +102 -0
- package/schemas/stack-choice.schema.json +89 -0
- package/schemas/stack-record.schema.json +20 -0
- package/schemas/state.schema.json +43 -0
- package/scripts/preflight-base-ref.sh +226 -0
- package/stack/apply.mjs +159 -0
- package/stack/bootstrap/_lib.sh +73 -0
- package/stack/bootstrap/ngrok.sh +90 -0
- package/stack/catalog/ngrok.yml +63 -0
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# D2: bash (not POSIX dash, not Node) for the preflight core.
|
|
3
|
+
# WHY: reusable outside this skill (drop into any repo or CI job) with zero package deps;
|
|
4
|
+
# bash is present on every machine/CI image we target, and the string/array handling
|
|
5
|
+
# needed for the 3-way default-branch cross-check is painful in strict POSIX sh.
|
|
6
|
+
# COST: won't run under `sh` on a minimal container without bash installed.
|
|
7
|
+
# EXIT: `bskel preflight --native` (lib/) reimplements this in Node if that ever bites.
|
|
8
|
+
#
|
|
9
|
+
# Verifies the current checkout is actually based on the repo's real default branch, not a
|
|
10
|
+
# stale/abandoned one. Written after EnterWorktree's "fresh" mode branched a worktree off a
|
|
11
|
+
# 658-commit-stale `origin/main` in a repo whose real default branch is `develop` -- this
|
|
12
|
+
# script is the regression check for exactly that failure mode.
|
|
13
|
+
#
|
|
14
|
+
# Exit codes: 0 PASS | 10 NOT_A_REPO | 11 STALE_BASE | 12 WRONG_DEFAULT | 13 DIRTY | 14 BAD_ARGS
|
|
15
|
+
# | 18 REFRESH_FAILED (D-preflight-freshness, S3)
|
|
16
|
+
set -euo pipefail
|
|
17
|
+
|
|
18
|
+
MAX_BEHIND=0
|
|
19
|
+
OFFLINE=0
|
|
20
|
+
ALLOW_DIRTY=0
|
|
21
|
+
JSON=0
|
|
22
|
+
FETCH_TIMEOUT=60
|
|
23
|
+
|
|
24
|
+
while [ $# -gt 0 ]; do
|
|
25
|
+
case "$1" in
|
|
26
|
+
--max-behind) MAX_BEHIND="$2"; shift 2 ;;
|
|
27
|
+
# D-preflight-freshness (S3): --offline is the real name -- it means "I explicitly accept
|
|
28
|
+
# a local-only verdict, even if that means not knowing whether the remote has moved."
|
|
29
|
+
# --no-fetch is kept as an exact alias: it predates this item, is already documented in
|
|
30
|
+
# SKILL.md, this script's own header says it's "reusable outside this skill" (so an
|
|
31
|
+
# external caller may already depend on the old name), and 5 existing tests use it --
|
|
32
|
+
# removing it would be a needless breaking rename for a flag whose meaning didn't change.
|
|
33
|
+
--offline|--no-fetch) OFFLINE=1; shift ;;
|
|
34
|
+
--allow-dirty) ALLOW_DIRTY=1; shift ;;
|
|
35
|
+
--json) JSON=1; shift ;;
|
|
36
|
+
--fetch-timeout-seconds) FETCH_TIMEOUT="$2"; shift 2 ;;
|
|
37
|
+
*) echo "unknown argument: $1" >&2; exit 14 ;;
|
|
38
|
+
esac
|
|
39
|
+
done
|
|
40
|
+
|
|
41
|
+
# D-cli-contract (D2): a non-numeric --max-behind used to make the `[ "$BEHIND" -gt "$MAX_BEHIND" ]`
|
|
42
|
+
# comparison below fail with a bash arithmetic error (status 2) rather than raise -- under
|
|
43
|
+
# `set -euo pipefail` that error is INSIDE a conditional test, so the shell does not exit, and the
|
|
44
|
+
# comparison is simply treated as false. That silently disabled this script's entire reason for
|
|
45
|
+
# existing (the stale-base check) instead of refusing the bad argument. `bskel preflight` itself
|
|
46
|
+
# now validates --max-behind before ever invoking this script, but this script is documented as
|
|
47
|
+
# "reusable outside this skill" (see the file header) and must not rely on that caller alone.
|
|
48
|
+
case "$MAX_BEHIND" in
|
|
49
|
+
''|*[!0-9]*) echo "--max-behind must be a non-negative whole number, got: $MAX_BEHIND" >&2; exit 14 ;;
|
|
50
|
+
esac
|
|
51
|
+
case "$FETCH_TIMEOUT" in
|
|
52
|
+
''|*[!0-9]*) echo "--fetch-timeout-seconds must be a non-negative whole number, got: $FETCH_TIMEOUT" >&2; exit 14 ;;
|
|
53
|
+
esac
|
|
54
|
+
|
|
55
|
+
# Minimal JSON string escaper (backslash, double-quote, control chars) so this script has
|
|
56
|
+
# zero external deps -- not a general JSON encoder, just enough for the strings *this* script
|
|
57
|
+
# builds itself (branch names, paths, our own messages).
|
|
58
|
+
json_str() {
|
|
59
|
+
local s="$1"
|
|
60
|
+
s="${s//\\/\\\\}"
|
|
61
|
+
s="${s//\"/\\\"}"
|
|
62
|
+
s="${s//$'\n'/\\n}"
|
|
63
|
+
s="${s//$'\t'/\\t}"
|
|
64
|
+
printf '"%s"' "$s"
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
fail() {
|
|
68
|
+
code="$1"; reason="$2"; message="$3"
|
|
69
|
+
if [ "$JSON" -eq 1 ]; then
|
|
70
|
+
printf '{"verdict":"FAIL","reason":"%s","message":%s}\n' "$reason" "$(json_str "$message")"
|
|
71
|
+
else
|
|
72
|
+
echo "FAIL ($reason): $message" >&2
|
|
73
|
+
fi
|
|
74
|
+
exit "$code"
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
TOPLEVEL=$(git rev-parse --show-toplevel 2>/dev/null) || fail 10 NOT_A_REPO "not inside a git repository"
|
|
78
|
+
cd "$TOPLEVEL"
|
|
79
|
+
|
|
80
|
+
# D-preflight-freshness (S3): computed unconditionally now (previously skipped entirely under
|
|
81
|
+
# --allow-dirty) so evidence can honestly distinguish "clean tree, passed" from "dirty tree,
|
|
82
|
+
# --allow-dirty overrode it" -- both used to look identical in the recorded evidence.
|
|
83
|
+
DIRTY_STATUS=$(git status --porcelain)
|
|
84
|
+
WORKTREE_DIRTY="false"
|
|
85
|
+
[ -n "$DIRTY_STATUS" ] && WORKTREE_DIRTY="true"
|
|
86
|
+
if [ "$ALLOW_DIRTY" -eq 0 ] && [ "$WORKTREE_DIRTY" = "true" ]; then
|
|
87
|
+
fail 13 DIRTY "working tree is not clean (pass --allow-dirty to override)"
|
|
88
|
+
fi
|
|
89
|
+
|
|
90
|
+
REMOTE_URL=$(git remote get-url origin 2>/dev/null || echo "")
|
|
91
|
+
# Only extract an owner/repo pair when the remote is actually github.com -- a local path or
|
|
92
|
+
# non-GitHub host must not be passed to `gh api`, both because it's meaningless and because
|
|
93
|
+
# (found by test/preflight.test.mjs) `gh api` on a bogus path returns a 404 error BODY on
|
|
94
|
+
# stdout with an exit code we must check, not just swallow with `|| true`.
|
|
95
|
+
OWNER_REPO=""
|
|
96
|
+
case "$REMOTE_URL" in
|
|
97
|
+
git@github.com:*) OWNER_REPO=$(echo "$REMOTE_URL" | sed -E 's#^git@github\.com:##; s#\.git$##') ;;
|
|
98
|
+
https://github.com/*) OWNER_REPO=$(echo "$REMOTE_URL" | sed -E 's#^https://github\.com/##; s#\.git$##') ;;
|
|
99
|
+
esac
|
|
100
|
+
|
|
101
|
+
# Three independent sources for "what is the real default branch" -- never assume `main`.
|
|
102
|
+
# D-preflight-freshness (S3): each source's OUTCOME is now tracked explicitly (ok/failed/empty/
|
|
103
|
+
# unavailable), not just its value -- a network failure silently produced the same empty string
|
|
104
|
+
# as "this source doesn't apply here" before, so a repo whose only working source happened to be
|
|
105
|
+
# the (possibly stale) local symbolic-ref cache could look identical to a repo that was properly
|
|
106
|
+
# cross-checked. This does not turn a single-source resolution into a hard failure (see
|
|
107
|
+
# D-preflight-freshness's EXIT in DECISIONS.md for why) -- it only makes that fact observable.
|
|
108
|
+
SRC_SYMREF=""
|
|
109
|
+
SYMREF_STATUS="absent"
|
|
110
|
+
if SRC_SYMREF=$(git symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null | sed 's#^origin/##') && [ -n "$SRC_SYMREF" ]; then
|
|
111
|
+
SYMREF_STATUS="ok"
|
|
112
|
+
else
|
|
113
|
+
SRC_SYMREF=""
|
|
114
|
+
fi
|
|
115
|
+
|
|
116
|
+
SRC_REMOTE_SHOW=""
|
|
117
|
+
REMOTE_SHOW_STATUS="failed"
|
|
118
|
+
if REMOTE_SHOW_OUT=$(git remote show origin 2>/dev/null); then
|
|
119
|
+
SRC_REMOTE_SHOW=$(printf '%s\n' "$REMOTE_SHOW_OUT" | sed -n 's/^ *HEAD branch: //p')
|
|
120
|
+
if [ -n "$SRC_REMOTE_SHOW" ]; then REMOTE_SHOW_STATUS="ok"; else REMOTE_SHOW_STATUS="empty"; fi
|
|
121
|
+
fi
|
|
122
|
+
|
|
123
|
+
SRC_GH_API=""
|
|
124
|
+
GH_API_STATUS="unavailable"
|
|
125
|
+
if command -v gh >/dev/null 2>&1 && [ -n "$OWNER_REPO" ]; then
|
|
126
|
+
# Explicit exit-code check, NOT `$(...) || true` -- on a non-2xx response gh can print the
|
|
127
|
+
# error body (e.g. a 404 JSON payload with raw CRLFs) to stdout while exiting non-zero, and
|
|
128
|
+
# `|| true` alone would still let that garbage flow into SRC_GH_API.
|
|
129
|
+
if GH_API_OUT=$(gh api "repos/$OWNER_REPO" --jq .default_branch 2>/dev/null); then
|
|
130
|
+
SRC_GH_API="$GH_API_OUT"
|
|
131
|
+
GH_API_STATUS="ok"
|
|
132
|
+
else
|
|
133
|
+
GH_API_STATUS="failed"
|
|
134
|
+
fi
|
|
135
|
+
fi
|
|
136
|
+
|
|
137
|
+
SOURCES_OK=0
|
|
138
|
+
for s in "$SYMREF_STATUS" "$REMOTE_SHOW_STATUS" "$GH_API_STATUS"; do
|
|
139
|
+
[ "$s" = "ok" ] && SOURCES_OK=$((SOURCES_OK + 1))
|
|
140
|
+
done
|
|
141
|
+
CROSS_CHECK_JSON=$(printf '{"sources_ok":%d,"symbolic_ref":"%s","remote_show":"%s","gh_api":"%s"}' "$SOURCES_OK" "$SYMREF_STATUS" "$REMOTE_SHOW_STATUS" "$GH_API_STATUS")
|
|
142
|
+
|
|
143
|
+
CANDIDATES=""
|
|
144
|
+
for c in "$SRC_SYMREF" "$SRC_REMOTE_SHOW" "$SRC_GH_API"; do
|
|
145
|
+
[ -n "$c" ] && CANDIDATES="$CANDIDATES $c"
|
|
146
|
+
done
|
|
147
|
+
UNIQUE=$(echo "$CANDIDATES" | tr ' ' '\n' | sed '/^$/d' | sort -u)
|
|
148
|
+
UNIQUE_COUNT=$(echo "$UNIQUE" | sed '/^$/d' | wc -l | tr -d ' ')
|
|
149
|
+
|
|
150
|
+
if [ "$UNIQUE_COUNT" -eq 0 ]; then
|
|
151
|
+
fail 12 WRONG_DEFAULT "could not determine default branch from origin/HEAD, 'git remote show origin', or 'gh api' -- refusing to assume 'main'"
|
|
152
|
+
elif [ "$UNIQUE_COUNT" -gt 1 ]; then
|
|
153
|
+
fail 12 WRONG_DEFAULT "default-branch sources disagree: symbolic-ref='$SRC_SYMREF' remote-show='$SRC_REMOTE_SHOW' gh-api='$SRC_GH_API' -- resolve the discrepancy before proceeding, never guess"
|
|
154
|
+
fi
|
|
155
|
+
DEFAULT_BRANCH="$UNIQUE"
|
|
156
|
+
|
|
157
|
+
# D-preflight-freshness (S3): fetch failure used to be swallowed entirely (`2>/dev/null || true`)
|
|
158
|
+
# -- a genuinely offline/unreachable remote left this script computing `behind`/`ahead` against
|
|
159
|
+
# whatever stale local `origin/<branch>` ref happened to already exist, and still reporting PASS.
|
|
160
|
+
# Now: fetch is attempted unless --offline was given, and a failed attempt fails closed instead of
|
|
161
|
+
# silently falling through to the (possibly very stale) cached ref. `http.lowSpeedLimit`/
|
|
162
|
+
# `http.lowSpeedTime` bound a slow-but-connected transfer; a fully hung connection (or a non-http
|
|
163
|
+
# transport that ignores those config keys, e.g. a local path or ssh remote) is bounded by the
|
|
164
|
+
# Node-side `execFileSync` timeout in `bin/bskel.mjs::cmdPreflight` instead -- this script has no
|
|
165
|
+
# portable `timeout(1)` to rely on (not present on macOS by default).
|
|
166
|
+
FETCH_OUTCOME="skipped"
|
|
167
|
+
FETCH_STATUS=""
|
|
168
|
+
FETCH_STDERR=""
|
|
169
|
+
if [ "$OFFLINE" -eq 0 ]; then
|
|
170
|
+
if FETCH_ERR=$(git -c "http.lowSpeedLimit=1000" -c "http.lowSpeedTime=$FETCH_TIMEOUT" fetch origin "$DEFAULT_BRANCH" --quiet 2>&1); then
|
|
171
|
+
FETCH_OUTCOME="ok"
|
|
172
|
+
else
|
|
173
|
+
FETCH_STATUS=$?
|
|
174
|
+
FETCH_STDERR=$(printf '%s\n' "$FETCH_ERR" | head -1)
|
|
175
|
+
fail 18 REFRESH_FAILED "could not refresh '$DEFAULT_BRANCH' from origin (git fetch exited $FETCH_STATUS: ${FETCH_STDERR:-no output}) -- fix connectivity, or re-run with --offline to accept a local-only verdict (it will be recorded as such)"
|
|
176
|
+
fi
|
|
177
|
+
fi
|
|
178
|
+
|
|
179
|
+
DEFAULT_REF="origin/$DEFAULT_BRANCH"
|
|
180
|
+
git rev-parse --verify --quiet "$DEFAULT_REF" >/dev/null || fail 12 WRONG_DEFAULT "resolved default branch '$DEFAULT_BRANCH' has no ref '$DEFAULT_REF' locally (fetch failed or branch renamed)"
|
|
181
|
+
ORIGIN_TIP_SHA=$(git rev-parse --verify --quiet "$DEFAULT_REF")
|
|
182
|
+
|
|
183
|
+
HEAD_SHA=$(git rev-parse HEAD)
|
|
184
|
+
BEHIND=$(git rev-list --count "HEAD..$DEFAULT_REF")
|
|
185
|
+
AHEAD=$(git rev-list --count "$DEFAULT_REF..HEAD")
|
|
186
|
+
MERGE_BASE=$(git merge-base HEAD "$DEFAULT_REF")
|
|
187
|
+
DEFAULT_TIP_DATE=$(git log -1 --format=%ct "$DEFAULT_REF")
|
|
188
|
+
MERGE_BASE_DATE=$(git log -1 --format=%ct "$MERGE_BASE")
|
|
189
|
+
BASE_AGE_DAYS=$(( (DEFAULT_TIP_DATE - MERGE_BASE_DATE) / 86400 ))
|
|
190
|
+
|
|
191
|
+
CURRENT_BRANCH=$(git branch --show-current)
|
|
192
|
+
WORKTREE_PATH="$TOPLEVEL"
|
|
193
|
+
CREATED_FROM=$(git reflog show "$CURRENT_BRANCH" 2>/dev/null | tail -1 || echo "")
|
|
194
|
+
CHECKED_AT=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
|
195
|
+
|
|
196
|
+
POLICY_JSON=$(printf '{"max_behind":%d,"allow_dirty":%s,"offline":%s,"fetch_timeout_seconds":%d}' "$MAX_BEHIND" "$([ "$ALLOW_DIRTY" -eq 1 ] && echo true || echo false)" "$([ "$OFFLINE" -eq 1 ] && echo true || echo false)" "$FETCH_TIMEOUT")
|
|
197
|
+
|
|
198
|
+
VERDICT="PASS"
|
|
199
|
+
REASON=""
|
|
200
|
+
if [ "$BEHIND" -gt "$MAX_BEHIND" ]; then
|
|
201
|
+
VERDICT="FAIL"
|
|
202
|
+
REASON="STALE_BASE"
|
|
203
|
+
fi
|
|
204
|
+
|
|
205
|
+
EVIDENCE_JSON=$(cat <<EOF
|
|
206
|
+
{"default_branch":"$DEFAULT_BRANCH","source_of_truth":{"symbolic_ref":"$SRC_SYMREF","remote_show":"$SRC_REMOTE_SHOW","gh_api":"$SRC_GH_API"},"head_sha":"$HEAD_SHA","merge_base":"$MERGE_BASE","behind":$BEHIND,"ahead":$AHEAD,"base_age_days":$BASE_AGE_DAYS,"worktree_path":"$WORKTREE_PATH","current_branch":"$CURRENT_BRANCH","created_from":$(json_str "$CREATED_FROM"),"origin_tip_sha":$([ -n "$ORIGIN_TIP_SHA" ] && json_str "$ORIGIN_TIP_SHA" || echo null),"checked_at":"$CHECKED_AT","worktree_dirty":$WORKTREE_DIRTY,"fetch":"$FETCH_OUTCOME","policy":$POLICY_JSON,"cross_check":$CROSS_CHECK_JSON}
|
|
207
|
+
EOF
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
if [ "$VERDICT" = "FAIL" ]; then
|
|
211
|
+
MSG="HEAD is $BEHIND commits behind $DEFAULT_REF (base is $BASE_AGE_DAYS days stale). Remediation: git worktree add <path> -b <branch> $DEFAULT_REF (or, in-place: git rebase $DEFAULT_REF)"
|
|
212
|
+
if [ "$JSON" -eq 1 ]; then
|
|
213
|
+
printf '{"verdict":"FAIL","reason":"STALE_BASE","message":%s,"evidence":%s}\n' "$(json_str "$MSG")" "$EVIDENCE_JSON"
|
|
214
|
+
else
|
|
215
|
+
echo "FAIL (STALE_BASE): $MSG" >&2
|
|
216
|
+
echo "evidence: $EVIDENCE_JSON" >&2
|
|
217
|
+
fi
|
|
218
|
+
exit 11
|
|
219
|
+
fi
|
|
220
|
+
|
|
221
|
+
if [ "$JSON" -eq 1 ]; then
|
|
222
|
+
printf '{"verdict":"PASS","evidence":%s}\n' "$EVIDENCE_JSON"
|
|
223
|
+
else
|
|
224
|
+
echo "PASS: HEAD is up to date with $DEFAULT_REF (behind=$BEHIND, ahead=$AHEAD)"
|
|
225
|
+
fi
|
|
226
|
+
exit 0
|
package/stack/apply.mjs
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { parse as parseYaml } from 'yaml';
|
|
5
|
+
import Ajv2020 from 'ajv/dist/2020.js';
|
|
6
|
+
// P2b (D-greenfield-parameters): was a private `renderTemplate(templatePath, vars)` here, moved to
|
|
7
|
+
// lib/template.mjs unchanged once `new/fastapi.mjs` became its second real consumer.
|
|
8
|
+
import { renderTemplateFile } from '../lib/template.mjs';
|
|
9
|
+
|
|
10
|
+
const STACK_ROOT = path.dirname(fileURLToPath(import.meta.url));
|
|
11
|
+
const SCHEMAS_ROOT = path.join(STACK_ROOT, '..', 'schemas');
|
|
12
|
+
|
|
13
|
+
// D-security-4: `choiceId` must be a bare identifier, never a path. Found by the Codex security
|
|
14
|
+
// review: `path.join(STACK_ROOT, 'catalog', choiceId + '.yml')` with an unvalidated choiceId
|
|
15
|
+
// lets `--choice ../../../../whatever` escape the catalog dir entirely.
|
|
16
|
+
const CHOICE_ID_RE = /^[a-z][a-z0-9-]*$/;
|
|
17
|
+
|
|
18
|
+
export function listCatalogChoices() {
|
|
19
|
+
const catalogDir = path.join(STACK_ROOT, 'catalog');
|
|
20
|
+
if (!fs.existsSync(catalogDir)) return [];
|
|
21
|
+
return fs.readdirSync(catalogDir)
|
|
22
|
+
.filter((f) => f.endsWith('.yml'))
|
|
23
|
+
.map((f) => f.replace(/\.yml$/, ''));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Resolves `target` and asserts it stays within `root` -- used for both catalog-entry template
|
|
27
|
+
// paths (must stay under STACK_ROOT) and generated-file target paths (must stay under the
|
|
28
|
+
// caller's repoRoot). A catalog entry is data (currently only ships with this skill, but the
|
|
29
|
+
// mechanism doesn't assume that), so every path it names is treated as untrusted input.
|
|
30
|
+
function assertContained(root, target, label) {
|
|
31
|
+
const resolvedRoot = path.resolve(root);
|
|
32
|
+
const resolvedTarget = path.resolve(target);
|
|
33
|
+
const rel = path.relative(resolvedRoot, resolvedTarget);
|
|
34
|
+
if (rel === '..' || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) {
|
|
35
|
+
throw new Error(`${label} "${target}" escapes "${root}" -- refusing (possible path traversal in a stack catalog entry)`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
let _ajv = null;
|
|
40
|
+
function ajv() {
|
|
41
|
+
if (!_ajv) _ajv = new Ajv2020({ allErrors: true, strict: false });
|
|
42
|
+
return _ajv;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function loadStackChoiceSchema() {
|
|
46
|
+
return JSON.parse(fs.readFileSync(path.join(SCHEMAS_ROOT, 'stack-choice.schema.json'), 'utf8'));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function loadCatalogEntry(choiceId) {
|
|
50
|
+
if (!CHOICE_ID_RE.test(choiceId)) {
|
|
51
|
+
throw new Error(`invalid stack choice "${choiceId}" -- must match ${CHOICE_ID_RE} (known choices: ${listCatalogChoices().join(', ') || '(none)'})`);
|
|
52
|
+
}
|
|
53
|
+
const catalogPath = path.join(STACK_ROOT, 'catalog', `${choiceId}.yml`);
|
|
54
|
+
assertContained(path.join(STACK_ROOT, 'catalog'), catalogPath, 'catalog entry path');
|
|
55
|
+
if (!fs.existsSync(catalogPath)) {
|
|
56
|
+
throw new Error(`unknown stack choice "${choiceId}" -- known choices: ${listCatalogChoices().join(', ') || '(none)'}`);
|
|
57
|
+
}
|
|
58
|
+
const entry = parseYaml(fs.readFileSync(catalogPath, 'utf8'));
|
|
59
|
+
|
|
60
|
+
const schema = loadStackChoiceSchema();
|
|
61
|
+
const validateFn = ajv().getSchema(schema.$id) ?? ajv().compile(schema);
|
|
62
|
+
if (!validateFn(entry)) {
|
|
63
|
+
const details = (validateFn.errors ?? []).map((e) => `${e.instancePath || '(root)'} ${e.message}`).join('; ');
|
|
64
|
+
throw new Error(`catalog entry "${choiceId}.yml" does not match schemas/stack-choice.schema.json: ${details}`);
|
|
65
|
+
}
|
|
66
|
+
return entry;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// D7 (DECISIONS.md): a stack choice's static half is entirely data-driven (this catalog entry)
|
|
70
|
+
// -- planApply/applyPlan are generic across any catalog entry shaped like schemas/stack-choice.
|
|
71
|
+
// schema.json, so "add a stack" is a YAML edit (+ optionally a template), not new glue code.
|
|
72
|
+
export function planApply(repoRoot, entry, { port = 8080 } = {}) {
|
|
73
|
+
const plan = { choice: entry.id, alreadyDetected: false, files: [], envExampleActions: [], configChecks: [] };
|
|
74
|
+
|
|
75
|
+
// D-security-6: never read the target repo's .env, even for a read-only dry-run detection
|
|
76
|
+
// check. This project's own convention (D-security-6 in DECISIONS.md) and the target repo's CLAUDE.md
|
|
77
|
+
// both say the agent doesn't read/edit .env -- only a human-invoked runtime bootstrap script
|
|
78
|
+
// does. Detection now relies solely on `detect.files` (e.g. does scripts/dev-tunnel.sh
|
|
79
|
+
// already exist), which is what the ngrok catalog entry primarily uses anyway. Found by the
|
|
80
|
+
// Codex security review (a dry-run reading the full .env, even without printing it, still
|
|
81
|
+
// crossed the stated boundary).
|
|
82
|
+
plan.alreadyDetected = (entry.detect?.files ?? []).some((f) => fs.existsSync(path.join(repoRoot, f)));
|
|
83
|
+
|
|
84
|
+
for (const f of entry.static?.files ?? []) {
|
|
85
|
+
const templatePath = path.join(STACK_ROOT, f.template);
|
|
86
|
+
assertContained(STACK_ROOT, templatePath, 'catalog template path');
|
|
87
|
+
const targetPath = path.join(repoRoot, f.path);
|
|
88
|
+
assertContained(repoRoot, targetPath, 'catalog target path');
|
|
89
|
+
const rendered = renderTemplateFile(templatePath, { PORT: port });
|
|
90
|
+
const exists = fs.existsSync(targetPath);
|
|
91
|
+
const unchanged = exists && fs.readFileSync(targetPath, 'utf8') === rendered;
|
|
92
|
+
plan.files.push({
|
|
93
|
+
path: f.path,
|
|
94
|
+
mode: f.mode ?? null,
|
|
95
|
+
action: !exists ? 'create' : (unchanged ? 'unchanged' : 'update'),
|
|
96
|
+
content: rendered,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const envExamplePath = path.join(repoRoot, '.env.example');
|
|
101
|
+
const existingEnvExample = fs.existsSync(envExamplePath) ? fs.readFileSync(envExamplePath, 'utf8') : '';
|
|
102
|
+
for (const e of entry.static?.env_example ?? []) {
|
|
103
|
+
const already = new RegExp(`^${e.key}=`, 'm').test(existingEnvExample);
|
|
104
|
+
plan.envExampleActions.push({
|
|
105
|
+
key: e.key, doc: e.doc, required: Boolean(e.required), secret: Boolean(e.secret),
|
|
106
|
+
action: already ? 'unchanged' : 'append',
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
for (const c of entry.static?.config_check ?? []) {
|
|
111
|
+
const targetPath = path.join(repoRoot, c.target);
|
|
112
|
+
assertContained(repoRoot, targetPath, 'catalog config_check target path');
|
|
113
|
+
let status = 'target-missing';
|
|
114
|
+
if (fs.existsSync(targetPath)) {
|
|
115
|
+
status = new RegExp(c.externalized_pattern).test(fs.readFileSync(targetPath, 'utf8'))
|
|
116
|
+
? 'already-externalized'
|
|
117
|
+
: 'needs-manual-patch';
|
|
118
|
+
}
|
|
119
|
+
plan.configChecks.push({ target: c.target, status, note: c.note });
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return plan;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// D-config-patch: config_check is informational ONLY -- backend-skeleton never auto-edits an
|
|
126
|
+
// application config file. WHY: the target config (e.g. Spring's application.yaml) is often
|
|
127
|
+
// comment-dense and hand-tuned; a wrong automatic edit there is a worse failure mode than
|
|
128
|
+
// asking a human to add one line. COST: a `needs-manual-patch` status requires a human step.
|
|
129
|
+
// EXIT: if a safe, comment-preserving patcher is built later (the `yaml` package's Document
|
|
130
|
+
// API supports this), config_check could gain an `apply` action -- not built now because the
|
|
131
|
+
// real target (Team-IZ-Backend) doesn't need it (already externalized), so there's no concrete
|
|
132
|
+
// case to validate a patcher against yet.
|
|
133
|
+
export function applyPlan(repoRoot, plan) {
|
|
134
|
+
const written = [];
|
|
135
|
+
for (const f of plan.files) {
|
|
136
|
+
if (f.action === 'unchanged') continue;
|
|
137
|
+
const targetPath = path.join(repoRoot, f.path);
|
|
138
|
+
// Re-asserted here too (planApply already checked it) -- applyPlan must not assume it's
|
|
139
|
+
// only ever called with a plan it just generated for the same repoRoot.
|
|
140
|
+
assertContained(repoRoot, targetPath, 'catalog target path');
|
|
141
|
+
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
|
|
142
|
+
fs.writeFileSync(targetPath, f.content);
|
|
143
|
+
if (f.mode) fs.chmodSync(targetPath, Number.parseInt(f.mode, 8));
|
|
144
|
+
written.push(f.path);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const toAppend = plan.envExampleActions.filter((a) => a.action === 'append');
|
|
148
|
+
if (toAppend.length > 0) {
|
|
149
|
+
const envExamplePath = path.join(repoRoot, '.env.example');
|
|
150
|
+
let addition = fs.existsSync(envExamplePath) ? '' : '# Environment variables -- copy relevant ones into your own .env.\n\n';
|
|
151
|
+
for (const a of toAppend) {
|
|
152
|
+
addition += `# ${a.doc}${a.required ? ' (required)' : ' (optional)'}\n${a.key}=\n\n`;
|
|
153
|
+
}
|
|
154
|
+
fs.appendFileSync(envExamplePath, addition);
|
|
155
|
+
written.push('.env.example');
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return written;
|
|
159
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Shared helpers for backend-skeleton stack bootstrap scripts. Copied into the target repo
|
|
3
|
+
# alongside the script that sources it (see stack/apply.mjs) -- self-contained, no dependency
|
|
4
|
+
# on backend-skeleton being installed at runtime.
|
|
5
|
+
|
|
6
|
+
# Polls a JSON endpoint (e.g. ngrok's local API) once a second until it returns a body
|
|
7
|
+
# containing `"public_url"`, or times out. Whole-second granularity is deliberate -- a tunnel
|
|
8
|
+
# typically comes up in 1-3s, and this only needs to be "prompt", not "instant".
|
|
9
|
+
wait_for_tunnel() {
|
|
10
|
+
local url="$1" timeout_s="$2" waited=0
|
|
11
|
+
while [ "$waited" -lt "$timeout_s" ]; do
|
|
12
|
+
local body
|
|
13
|
+
# --connect-timeout/--max-time bound EACH poll attempt -- without these, a port that
|
|
14
|
+
# accepts-but-never-responds (or certain firewalled/unusual ports) can make a single
|
|
15
|
+
# curl call hang far longer than $timeout_s, defeating the outer loop's bound entirely.
|
|
16
|
+
body=$(curl -sf --connect-timeout 2 --max-time 3 "$url" 2>/dev/null || true)
|
|
17
|
+
if [ -n "$body" ] && echo "$body" | grep -q '"public_url"'; then
|
|
18
|
+
echo "$body"
|
|
19
|
+
return 0
|
|
20
|
+
fi
|
|
21
|
+
sleep 1
|
|
22
|
+
waited=$((waited + 1))
|
|
23
|
+
done
|
|
24
|
+
return 1
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
# Extracts the first https public_url from an ngrok /api/tunnels response. grep+sed, not a
|
|
28
|
+
# JSON parser -- the response shape (`"public_url":"https://..."` as a flat string field) is
|
|
29
|
+
# stable enough for this and avoids a jq/python dependency in a script meant to be portable.
|
|
30
|
+
extract_https_url() {
|
|
31
|
+
echo "$1" | grep -o '"public_url":"https://[^"]*"' | head -1 | sed -E 's/.*"(https:\/\/[^"]*)".*/\1/'
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
# Idempotent: replaces `KEY=...` if the line already exists, appends a new line if not.
|
|
35
|
+
#
|
|
36
|
+
# D-security-5: `.env` holds real secrets (NGROK_AUTHTOKEN and whatever else the target app
|
|
37
|
+
# needs), so this must never leave it more exposed than it started. Two fixes here, found by the
|
|
38
|
+
# Codex security review:
|
|
39
|
+
# 1. `mktemp`, not a predictable `${file}.$$.tmp` name -- the PID-based name let another local
|
|
40
|
+
# process race a symlink into that exact path before the `mv` lands.
|
|
41
|
+
# 2. `chmod 600` unconditionally after every write -- shell redirection creates the temp file
|
|
42
|
+
# at the process umask (0644 under a typical 022 umask), and `mv` then replaced .env with
|
|
43
|
+
# that laxer-mode file, silently downgrading it from whatever it was (normally 0600).
|
|
44
|
+
env_upsert() {
|
|
45
|
+
local file="$1" key="$2" value="$3"
|
|
46
|
+
touch "$file"
|
|
47
|
+
if grep -q "^${key}=" "$file" 2>/dev/null; then
|
|
48
|
+
local tmp
|
|
49
|
+
tmp=$(mktemp "${file}.XXXXXX")
|
|
50
|
+
sed "s|^${key}=.*|${key}=${value}|" "$file" > "$tmp" && mv "$tmp" "$file"
|
|
51
|
+
else
|
|
52
|
+
echo "${key}=${value}" >> "$file"
|
|
53
|
+
fi
|
|
54
|
+
chmod 600 "$file"
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
# Appends `value` to a comma-separated `KEY=a,b,c` value, only if not already present verbatim
|
|
58
|
+
# (used for AUTH_LOGIN_ALLOWED_ORIGINS -- re-running the tunnel script with the same ephemeral
|
|
59
|
+
# URL, or restarting with a reserved domain that's already listed, must not duplicate entries).
|
|
60
|
+
env_append_unique() {
|
|
61
|
+
local file="$1" key="$2" value="$3"
|
|
62
|
+
touch "$file"
|
|
63
|
+
local current
|
|
64
|
+
current=$(grep "^${key}=" "$file" 2>/dev/null | head -1 | cut -d= -f2-)
|
|
65
|
+
if [ -z "$current" ]; then
|
|
66
|
+
env_upsert "$file" "$key" "$value"
|
|
67
|
+
return
|
|
68
|
+
fi
|
|
69
|
+
case ",$current," in
|
|
70
|
+
*",$value,"*) return ;;
|
|
71
|
+
esac
|
|
72
|
+
env_upsert "$file" "$key" "${current},${value}"
|
|
73
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Generated by backend-skeleton (stack choice: ngrok). Self-contained in this repo -- does not
|
|
3
|
+
# require backend-skeleton to be installed to run. See scripts/_bskel-lib.sh for shared helpers.
|
|
4
|
+
#
|
|
5
|
+
# D-ngrok: ephemeral vs. reserved-domain mode is switched by whether NGROK_DOMAIN is set --
|
|
6
|
+
# not a fixed choice made at codegen time. D8 (DECISIONS.md): this script writes .env; the
|
|
7
|
+
# agent that ran `bskel stack apply` never touches .env directly -- only a human-invoked
|
|
8
|
+
# runtime script does, per CLAUDE.md's ".env is never read/edited by the agent" rule.
|
|
9
|
+
set -euo pipefail
|
|
10
|
+
cd "$(dirname "${BASH_SOURCE[0]}")/.."
|
|
11
|
+
source scripts/_bskel-lib.sh
|
|
12
|
+
|
|
13
|
+
# D-cli-contract (D2): {{PORT}} is substituted by stack/apply.mjs's renderTemplate() at `bskel
|
|
14
|
+
# stack apply --port N` time -- before this, the template had no substitution site for the PORT
|
|
15
|
+
# variable planApply() already computed and passed in, so --port silently had zero effect no
|
|
16
|
+
# matter what value was given (the deployed default was always literally 8080). This only sets
|
|
17
|
+
# the DEFAULT baked into the deployed script -- a human re-running it later can still override
|
|
18
|
+
# with the script's own runtime `--port` flag (line below), unchanged.
|
|
19
|
+
PORT="${PORT:-{{PORT}}}"
|
|
20
|
+
ENV_FILE=".env"
|
|
21
|
+
EXEC_CMD=""
|
|
22
|
+
PRINT_ONLY=0
|
|
23
|
+
TIMEOUT_S=30
|
|
24
|
+
|
|
25
|
+
while [ $# -gt 0 ]; do
|
|
26
|
+
case "$1" in
|
|
27
|
+
--port) PORT="$2"; shift 2 ;;
|
|
28
|
+
--exec) EXEC_CMD="$2"; shift 2 ;;
|
|
29
|
+
--print-only) PRINT_ONLY=1; shift ;;
|
|
30
|
+
--timeout) TIMEOUT_S="$2"; shift 2 ;;
|
|
31
|
+
*) echo "unknown argument: $1" >&2; exit 14 ;;
|
|
32
|
+
esac
|
|
33
|
+
done
|
|
34
|
+
|
|
35
|
+
: "${NGROK_AUTHTOKEN:?NGROK_AUTHTOKEN not set -- see .env.example, get one at https://dashboard.ngrok.com/get-started/your-authtoken}"
|
|
36
|
+
|
|
37
|
+
NGROK_ARGS=(http "$PORT" --log=stdout)
|
|
38
|
+
if [ -n "${NGROK_DOMAIN:-}" ]; then
|
|
39
|
+
echo "using reserved domain: $NGROK_DOMAIN"
|
|
40
|
+
NGROK_ARGS+=(--domain "$NGROK_DOMAIN")
|
|
41
|
+
else
|
|
42
|
+
echo "no NGROK_DOMAIN set -- using an ephemeral URL (different every run)"
|
|
43
|
+
fi
|
|
44
|
+
|
|
45
|
+
# D-security-5: mktemp, not a predictable /tmp/bskel-ngrok-$$.log name -- the PID-based name
|
|
46
|
+
# let another local process race a symlink into that exact path before ngrok's stdout/stderr
|
|
47
|
+
# ever gets written to it. Found by the Codex security review.
|
|
48
|
+
LOG_FILE=$(mktemp "${TMPDIR:-/tmp}/bskel-ngrok-XXXXXX.log")
|
|
49
|
+
ngrok "${NGROK_ARGS[@]}" > "$LOG_FILE" 2>&1 &
|
|
50
|
+
NGROK_PID=$!
|
|
51
|
+
mkdir -p .sbf 2>/dev/null || true
|
|
52
|
+
echo "$NGROK_PID" > .sbf/ngrok.pid 2>/dev/null || true
|
|
53
|
+
|
|
54
|
+
cleanup() {
|
|
55
|
+
kill "$NGROK_PID" 2>/dev/null || true
|
|
56
|
+
}
|
|
57
|
+
trap cleanup EXIT INT TERM
|
|
58
|
+
|
|
59
|
+
echo "waiting for ngrok tunnel (up to ${TIMEOUT_S}s)..."
|
|
60
|
+
TUNNEL_JSON=$(wait_for_tunnel "http://127.0.0.1:4040/api/tunnels" "$TIMEOUT_S") || {
|
|
61
|
+
echo "ngrok did not come up within ${TIMEOUT_S}s -- log:" >&2
|
|
62
|
+
cat "$LOG_FILE" >&2
|
|
63
|
+
exit 1
|
|
64
|
+
}
|
|
65
|
+
PUBLIC_URL=$(extract_https_url "$TUNNEL_JSON")
|
|
66
|
+
if [ -z "$PUBLIC_URL" ]; then
|
|
67
|
+
echo "could not extract an https tunnel URL from ngrok's API response: $TUNNEL_JSON" >&2
|
|
68
|
+
exit 1
|
|
69
|
+
fi
|
|
70
|
+
echo "tunnel up: $PUBLIC_URL"
|
|
71
|
+
|
|
72
|
+
if [ "$PRINT_ONLY" -eq 1 ]; then
|
|
73
|
+
echo "$PUBLIC_URL"
|
|
74
|
+
exit 0
|
|
75
|
+
fi
|
|
76
|
+
|
|
77
|
+
# Spring reads .env only at boot (spring.config.import) -- writing these AFTER the app has
|
|
78
|
+
# already started does nothing until the next restart. That's why this script's default mode
|
|
79
|
+
# is "write env, then tell you to (re)start", and --exec exists for a one-shot tunnel-then-run.
|
|
80
|
+
env_upsert "$ENV_FILE" PUBLIC_BASE_URL "$PUBLIC_URL"
|
|
81
|
+
env_append_unique "$ENV_FILE" AUTH_LOGIN_ALLOWED_ORIGINS "$PUBLIC_URL"
|
|
82
|
+
echo "wrote PUBLIC_BASE_URL and appended to AUTH_LOGIN_ALLOWED_ORIGINS in $ENV_FILE"
|
|
83
|
+
|
|
84
|
+
if [ -n "$EXEC_CMD" ]; then
|
|
85
|
+
echo "starting: $EXEC_CMD"
|
|
86
|
+
exec $EXEC_CMD
|
|
87
|
+
fi
|
|
88
|
+
|
|
89
|
+
echo "tunnel is running (pid $NGROK_PID) -- (re)start your app now to pick up the env vars just written."
|
|
90
|
+
wait "$NGROK_PID"
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
id: ngrok
|
|
2
|
+
description: >
|
|
3
|
+
Expose the local dev server through an ngrok tunnel -- ephemeral URL by default, or a
|
|
4
|
+
reserved domain if NGROK_DOMAIN is set (paid ngrok plan). For webhook testing or sharing a
|
|
5
|
+
running instance without deploying. See D-ngrok in DECISIONS.md: both modes are supported by
|
|
6
|
+
the same script, switched purely by whether NGROK_DOMAIN is present in the environment --
|
|
7
|
+
this is not a fixed choice baked in at codegen time.
|
|
8
|
+
|
|
9
|
+
detect:
|
|
10
|
+
files:
|
|
11
|
+
- scripts/dev-tunnel.sh
|
|
12
|
+
env_keys:
|
|
13
|
+
- NGROK_AUTHTOKEN
|
|
14
|
+
|
|
15
|
+
static:
|
|
16
|
+
files:
|
|
17
|
+
- path: scripts/dev-tunnel.sh
|
|
18
|
+
template: bootstrap/ngrok.sh
|
|
19
|
+
mode: "755"
|
|
20
|
+
- path: scripts/_bskel-lib.sh
|
|
21
|
+
template: bootstrap/_lib.sh
|
|
22
|
+
mode: "644"
|
|
23
|
+
|
|
24
|
+
env_example:
|
|
25
|
+
- key: NGROK_AUTHTOKEN
|
|
26
|
+
required: true
|
|
27
|
+
secret: true
|
|
28
|
+
doc: "ngrok authtoken -- https://dashboard.ngrok.com/get-started/your-authtoken"
|
|
29
|
+
- key: NGROK_DOMAIN
|
|
30
|
+
required: false
|
|
31
|
+
doc: "reserved domain (paid ngrok plan). Unset = ephemeral URL, different every run."
|
|
32
|
+
- key: PUBLIC_BASE_URL
|
|
33
|
+
required: false
|
|
34
|
+
doc: "written automatically by scripts/dev-tunnel.sh once the tunnel is confirmed up -- do not set this by hand, it will be overwritten"
|
|
35
|
+
|
|
36
|
+
config_check:
|
|
37
|
+
- target: src/main/resources/application.yaml
|
|
38
|
+
externalized_pattern: "\\$\\{AUTH_LOGIN_ALLOWED_ORIGINS"
|
|
39
|
+
note: >
|
|
40
|
+
allowed-origins must be environment-variable-driven for the tunnel URL to actually take
|
|
41
|
+
effect (e.g. `allowed-origins: ${AUTH_LOGIN_ALLOWED_ORIGINS:default-value}`). If this
|
|
42
|
+
check reports needs-manual-patch, add that yourself -- backend-skeleton deliberately
|
|
43
|
+
does not auto-edit application config files (see D-config-patch in DECISIONS.md).
|
|
44
|
+
|
|
45
|
+
runtime:
|
|
46
|
+
script: scripts/dev-tunnel.sh
|
|
47
|
+
# D5: binaries scripts/dev-tunnel.sh (bootstrap/ngrok.sh) and its shared lib actually shell out
|
|
48
|
+
# to at runtime -- ngrok itself, plus curl (bootstrap/_lib.sh's wait_for_tunnel). `bskel stack
|
|
49
|
+
# apply` never runs either of these itself; `bskel doctor --workflow stack` reads this field to
|
|
50
|
+
# tell a human what the SCRIPT it's about to generate will need, before they hit a missing-
|
|
51
|
+
# binary failure mid-tunnel-setup.
|
|
52
|
+
requires:
|
|
53
|
+
- ngrok
|
|
54
|
+
- curl
|
|
55
|
+
readiness:
|
|
56
|
+
probe: "http://127.0.0.1:4040/api/tunnels"
|
|
57
|
+
produces:
|
|
58
|
+
- key: PUBLIC_BASE_URL
|
|
59
|
+
sink: env
|
|
60
|
+
mode: set
|
|
61
|
+
- key: AUTH_LOGIN_ALLOWED_ORIGINS
|
|
62
|
+
sink: env
|
|
63
|
+
mode: append-unique
|