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.
Files changed (119) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +284 -0
  3. package/bin/bskel.mjs +2384 -0
  4. package/contracts/completeness.mjs +176 -0
  5. package/contracts/emit.mjs +287 -0
  6. package/contracts/export.mjs +325 -0
  7. package/contracts/openapi.mjs +869 -0
  8. package/contracts/validate.mjs +147 -0
  9. package/handles/_engine.mjs +281 -0
  10. package/handles/codec.mjs +119 -0
  11. package/handles/conformance.mjs +74 -0
  12. package/handles/providers/java-spring/ast-bridge.mjs +59 -0
  13. package/handles/providers/java-spring/ast-helper/build.gradle +34 -0
  14. package/handles/providers/java-spring/ast-helper/gradle/wrapper/gradle-wrapper.jar +0 -0
  15. package/handles/providers/java-spring/ast-helper/gradle/wrapper/gradle-wrapper.properties +9 -0
  16. package/handles/providers/java-spring/ast-helper/gradlew +248 -0
  17. package/handles/providers/java-spring/ast-helper/gradlew.bat +82 -0
  18. package/handles/providers/java-spring/ast-helper/settings.gradle +1 -0
  19. package/handles/providers/java-spring/ast-helper/src/main/java/com/backendskeleton/asthelper/Main.java +178 -0
  20. package/handles/providers/java-spring/emit.mjs +232 -0
  21. package/handles/providers/java-spring/patch-strategy.mjs +229 -0
  22. package/handles/providers/java-spring/plan.mjs +377 -0
  23. package/handles/providers/java-spring/templates/HandleAspect.java.tmpl +125 -0
  24. package/handles/providers/java-spring/templates/HandleCodec.java.tmpl +150 -0
  25. package/handles/providers/java-spring/templates/HandleController.java.tmpl +177 -0
  26. package/handles/providers/java-spring/templates/HandleRegistry.java.tmpl +107 -0
  27. package/handles/providers/java-spring/templates/HandleRegistryRepository.java.tmpl +8 -0
  28. package/handles/providers/java-spring/templates/HandleService.java.tmpl +95 -0
  29. package/handles/providers/java-spring/templates/HandleSnapshot.java.tmpl +75 -0
  30. package/handles/providers/java-spring/templates/HandleSnapshotRepository.java.tmpl +20 -0
  31. package/handles/providers/java-spring/templates/RecordHandleSnapshot.java.tmpl +50 -0
  32. package/handles/providers/java-spring/templates/ResourceResolver.java.tmpl +50 -0
  33. package/handles/providers/java-spring/templates/ResourceResolverStub.java.tmpl +77 -0
  34. package/handles/providers/java-spring/templates/migration.sql.tmpl +34 -0
  35. package/handles/providers/java-spring.mjs +21 -0
  36. package/handles/providers/python-fastapi/emit.mjs +171 -0
  37. package/handles/providers/python-fastapi/plan.mjs +186 -0
  38. package/handles/providers/python-fastapi/templates/__init__.py.tmpl +1 -0
  39. package/handles/providers/python-fastapi/templates/codec.py.tmpl +122 -0
  40. package/handles/providers/python-fastapi/templates/handle_service.py.tmpl +96 -0
  41. package/handles/providers/python-fastapi/templates/migration.sql.tmpl +35 -0
  42. package/handles/providers/python-fastapi/templates/record_snapshot.py.tmpl +155 -0
  43. package/handles/providers/python-fastapi/templates/registry.py.tmpl +37 -0
  44. package/handles/providers/python-fastapi/templates/resolver.py.tmpl +59 -0
  45. package/handles/providers/python-fastapi/templates/resolvers_init.py.tmpl +13 -0
  46. package/handles/providers/python-fastapi/templates/router.py.tmpl +140 -0
  47. package/handles/providers/python-fastapi/templates/tables.py.tmpl +66 -0
  48. package/handles/providers/python-fastapi.mjs +22 -0
  49. package/handles/providers/typescript-express/emit.mjs +128 -0
  50. package/handles/providers/typescript-express/plan.mjs +234 -0
  51. package/handles/providers/typescript-express/templates/codec.ts.tmpl +116 -0
  52. package/handles/providers/typescript-express/templates/registry.ts.tmpl +39 -0
  53. package/handles/providers/typescript-express/templates/resolver.ts.tmpl +55 -0
  54. package/handles/providers/typescript-express/templates/resolvers_index.ts.tmpl +11 -0
  55. package/handles/providers/typescript-express/templates/router.ts.tmpl +122 -0
  56. package/handles/providers/typescript-express.mjs +20 -0
  57. package/handles/registry.mjs +90 -0
  58. package/lib/cli.mjs +430 -0
  59. package/lib/doctor.mjs +200 -0
  60. package/lib/exit-codes.mjs +67 -0
  61. package/lib/featureid.mjs +55 -0
  62. package/lib/featurelifecycle.mjs +205 -0
  63. package/lib/fsutil.mjs +50 -0
  64. package/lib/gate-definitions.mjs +293 -0
  65. package/lib/gates.mjs +263 -0
  66. package/lib/handles-manifest.mjs +92 -0
  67. package/lib/lock.mjs +68 -0
  68. package/lib/patch-approvals.mjs +56 -0
  69. package/lib/paths.mjs +21 -0
  70. package/lib/repo.mjs +44 -0
  71. package/lib/schema-validate.mjs +56 -0
  72. package/lib/state.mjs +124 -0
  73. package/lib/template.mjs +35 -0
  74. package/lib/verify.mjs +206 -0
  75. package/lib/workflow.mjs +142 -0
  76. package/new/fastapi.mjs +165 -0
  77. package/new/index.mjs +62 -0
  78. package/new/params.mjs +233 -0
  79. package/new/spring.mjs +198 -0
  80. package/new/templates/fastapi/README.md +26 -0
  81. package/new/templates/fastapi/app/__init__.py +0 -0
  82. package/new/templates/fastapi/app/main.py +8 -0
  83. package/new/templates/fastapi/gitignore +6 -0
  84. package/new/templates/fastapi/pyproject.toml +14 -0
  85. package/package.json +50 -0
  86. package/scanners/adapters/_express-shared.mjs +238 -0
  87. package/scanners/adapters/_java-spring-analyzer.mjs +273 -0
  88. package/scanners/adapters/generic-grep.mjs +128 -0
  89. package/scanners/adapters/java-spring.mjs +301 -0
  90. package/scanners/adapters/javascript-express.mjs +422 -0
  91. package/scanners/adapters/python-fastapi.mjs +348 -0
  92. package/scanners/adapters/typescript-express.mjs +299 -0
  93. package/scanners/capabilities.mjs +90 -0
  94. package/scanners/conformance.mjs +59 -0
  95. package/scanners/db/introspect.mjs +109 -0
  96. package/scanners/db/migrations.mjs +126 -0
  97. package/scanners/index.mjs +281 -0
  98. package/scanners/registry.mjs +130 -0
  99. package/scanners/render.mjs +136 -0
  100. package/scanners/text-util.mjs +8 -0
  101. package/schemas/adapter.schema.json +23 -0
  102. package/schemas/agent-envelope.schema.json +21 -0
  103. package/schemas/contract-resolution.schema.json +28 -0
  104. package/schemas/feature-contract.schema.json +78 -0
  105. package/schemas/feature-index.schema.json +25 -0
  106. package/schemas/feature.schema.json +17 -0
  107. package/schemas/gate-event.schema.json +19 -0
  108. package/schemas/handles-plan.schema.json +31 -0
  109. package/schemas/handles-provider.schema.json +26 -0
  110. package/schemas/patch-approvals.schema.json +28 -0
  111. package/schemas/scan-report.schema.json +102 -0
  112. package/schemas/stack-choice.schema.json +89 -0
  113. package/schemas/stack-record.schema.json +20 -0
  114. package/schemas/state.schema.json +43 -0
  115. package/scripts/preflight-base-ref.sh +226 -0
  116. package/stack/apply.mjs +159 -0
  117. package/stack/bootstrap/_lib.sh +73 -0
  118. package/stack/bootstrap/ngrok.sh +90 -0
  119. 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
@@ -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