werkmeister 0.0.0 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CONVENTIONS.md +55 -0
- package/README.md +58 -4
- package/bin/generate-certs.sh +254 -0
- package/dist/lib/gate.js +278 -0
- package/dist/lib/pr_guard.js +75 -0
- package/dist/main.js +64477 -0
- package/dist/mcp.js +90008 -0
- package/dist/sandbox-workspaces.vsix +0 -0
- package/infra/compose/k8s-port-forward/Dockerfile +31 -0
- package/infra/compose/k8s-port-forward/entrypoint.sh +301 -0
- package/infra/compose/k8s-port-forward/probe.sh +96 -0
- package/infra/compose/redisinsight/databases.json +7 -0
- package/infra/compose/scripts/otel-init.bundle.js +63606 -0
- package/infra/compose/scripts/sandbox-install-startup.sh +41 -0
- package/infra/compose/shared-infra.yml +199 -0
- package/infra/proxy/ui/index.html +248 -0
- package/infra/schemas/services-fragment.schema.json +246 -0
- package/infra/schemas/workspace.schema.json +110 -0
- package/infra/skills/sb-author/SKILL.md +39 -0
- package/infra/skills/sb-handoff/SKILL.md +40 -0
- package/infra/skills/sb-implement/SKILL.md +91 -0
- package/infra/skills/sb-pr/SKILL.md +55 -0
- package/infra/skills/sb-research/SKILL.md +45 -0
- package/infra/skills/sb-review/SKILL.md +61 -0
- package/infra/skills/sb-test/SKILL.md +39 -0
- package/infra/templates/Caddyfile.njk +71 -0
- package/infra/templates/docker-compose.feature.yml.njk +245 -0
- package/infra/templates/env.njk +29 -0
- package/infra/templates/supergraph-config.yml.njk +21 -0
- package/package.json +59 -5
- package/sandbox +4 -0
- package/w9r +3 -0
- package/werkmeister +122 -0
package/CONVENTIONS.md
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# Code conventions — `src/`
|
|
2
|
+
|
|
3
|
+
Conventions this CLI holds itself to. They exist because the runtime data in
|
|
4
|
+
`infra/` and `docs/standards/` is consumed as-is and much of the output is
|
|
5
|
+
contract-shaped: generated files, exit codes, and printed text are depended on
|
|
6
|
+
by other tooling, so they are not free to drift.
|
|
7
|
+
|
|
8
|
+
## Layout
|
|
9
|
+
|
|
10
|
+
| Path | Holds |
|
|
11
|
+
|---|---|
|
|
12
|
+
| `src/main.ts` | the commander program and command registry |
|
|
13
|
+
| `src/commands/<name>.ts` | one module per CLI command |
|
|
14
|
+
| `src/lib/<name>.ts` | shared library modules |
|
|
15
|
+
|
|
16
|
+
Runtime data dirs live alongside the source: `infra/`, `docs/standards/`, `bin/`.
|
|
17
|
+
Path resolution: repo root = `SANDBOX_ROOT` env, else two levels up from
|
|
18
|
+
`dist/lib/` (`src/lib/config.ts` owns `getSandboxRoot()` / `getInfraRoot()`).
|
|
19
|
+
|
|
20
|
+
## Language
|
|
21
|
+
|
|
22
|
+
- **ESM + NodeNext**: every relative import ends in `.js`
|
|
23
|
+
(`import { x } from "./config.js"`).
|
|
24
|
+
- Keep a module docstring at the top of each file explaining what it owns.
|
|
25
|
+
- Functions/vars are `camelCase`; classes/types `PascalCase`; module-level
|
|
26
|
+
private constants `_UPPER_SNAKE`.
|
|
27
|
+
- **The CLI surface is frozen**: flag names, defaults, help text, printed
|
|
28
|
+
output, exit codes, and JSON shapes are contracts. Changing one is a
|
|
29
|
+
deliberate, breaking change — not a cleanup.
|
|
30
|
+
- YAML via the `yaml` package (`parse`, `stringify`).
|
|
31
|
+
- Templates in `infra/templates/` are rendered with `nunjucks`. They are shared
|
|
32
|
+
verbatim with other consumers, so the environment options in `generate.ts`
|
|
33
|
+
(autoescape, trim/lstrip) are load-bearing — see the notes there before
|
|
34
|
+
touching them.
|
|
35
|
+
- Subprocesses go through `src/lib/proc.ts` (spawnSync-based, synchronous):
|
|
36
|
+
- `run(cmd: string[], opts)` → `{ status, stdout, stderr }`; `check: true` throws `ProcError`.
|
|
37
|
+
- `capture(cmd)` → trimmed stdout, throws on failure.
|
|
38
|
+
- Streaming/inherit-stdio cases: pass `stdio: "inherit"`.
|
|
39
|
+
- `timeout` is in **seconds**, not milliseconds.
|
|
40
|
+
- Filesystem work uses the sync `node:fs` API (`readFileSync` etc.) plus `node:path`.
|
|
41
|
+
- ANSI colors come from `src/lib/colors.ts` — the single definition site.
|
|
42
|
+
- `console.error` for stderr, `console.log` for stdout.
|
|
43
|
+
- `process.exit(n)` for exit codes. Thrown errors reach `main`, which prints
|
|
44
|
+
`Error: <msg>` in red and exits 1; `SANDBOX_DEBUG` re-raises with a stack.
|
|
45
|
+
- Interactive prompts use `node:readline/promises`, which makes the enclosing
|
|
46
|
+
function `async` — command `run()` signatures are `async (args) => Promise<void>`.
|
|
47
|
+
- No new npm dependencies beyond: commander, nunjucks, yaml.
|
|
48
|
+
- Tests are vitest, in `src/lib/__tests__/<name>.test.ts`.
|
|
49
|
+
|
|
50
|
+
## Behavior rules
|
|
51
|
+
|
|
52
|
+
- Keep comments that explain *why*; drop line-by-line *what* comments.
|
|
53
|
+
- `gate.ts` is deliberately dependency-light (it backs a PreToolUse hook with a
|
|
54
|
+
cold-start budget): it must not import commander/yaml/nunjucks, and must stay
|
|
55
|
+
runnable directly via `node dist/lib/gate.js` — it has a `main()` guard.
|
package/README.md
CHANGED
|
@@ -1,5 +1,59 @@
|
|
|
1
|
-
#
|
|
1
|
+
# sandbox
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
A multi-feature concurrent development orchestrator: run up to 12 isolated
|
|
4
|
+
feature environments side by side, each with its own containers, ports, and
|
|
5
|
+
ingress hostnames.
|
|
6
|
+
|
|
7
|
+
## Quick start
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
npm install
|
|
11
|
+
npm run build
|
|
12
|
+
./sandbox --help
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
`./sandbox` resolves a node runtime (SANDBOX_NODE → PATH → newest nvm) and execs
|
|
16
|
+
`dist/main.js`. `sandbox hooks gate` bypasses the CLI import chain via
|
|
17
|
+
`dist/lib/gate.js` for a fast PreToolUse cold start.
|
|
18
|
+
|
|
19
|
+
## Tab completion
|
|
20
|
+
|
|
21
|
+
`sandbox __complete` answers bash's `complete -C` protocol (COMP_LINE/COMP_POINT
|
|
22
|
+
in, candidates on stdout), so both shells share one code path and there is no
|
|
23
|
+
generated completion script to keep in sync — candidates come off the commander
|
|
24
|
+
program itself.
|
|
25
|
+
|
|
26
|
+
`sandbox setup` installs it into `~/.zshrc` as a managed block (idempotent;
|
|
27
|
+
re-running replaces its own block and nothing else). To wire it up by hand, or
|
|
28
|
+
in bash, which setup does not touch:
|
|
29
|
+
|
|
30
|
+
```sh
|
|
31
|
+
autoload -Uz compinit && compinit # zsh only; skip if your rc runs it
|
|
32
|
+
autoload -Uz +X bashcompinit && bashcompinit
|
|
33
|
+
complete -C 'sandbox __complete' sandbox
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Completes subcommand names, a command's long flags, and any positional as a
|
|
37
|
+
feature name (`infra` plus the workspaces under the workspaces root).
|
|
38
|
+
|
|
39
|
+
## Layout
|
|
40
|
+
|
|
41
|
+
- `src/main.ts` — commander program (subcommand registry + dispatch)
|
|
42
|
+
- `src/commands/` — one module per subcommand (`run(args)` entrypoints)
|
|
43
|
+
- `src/lib/` — shared logic
|
|
44
|
+
- `infra/`, `docs/standards/`, `bin/` — runtime data the CLI consumes as-is
|
|
45
|
+
- `CONVENTIONS.md` — the code conventions used throughout
|
|
46
|
+
- `docs/design.md` — scope and decisions
|
|
47
|
+
|
|
48
|
+
## Notes
|
|
49
|
+
|
|
50
|
+
- CLI parsing is commander; templates in `infra/templates/*.njk` render through
|
|
51
|
+
nunjucks with a small compat layer (`{% do %}`, dict truthiness,
|
|
52
|
+
`map(attribute=…)`) so they stay shared verbatim with other consumers
|
|
53
|
+
- `sandbox mcp` runs the headless MCP server over stdio. Its source is
|
|
54
|
+
`apps/mcp`, bundled into `dist/mcp.js` by `esbuild.mjs` so it ships in the npm
|
|
55
|
+
package. `sandbox dashboard` runs the richer workspaces dashboard from
|
|
56
|
+
`apps/sandbox-dashboard` (Jira, PRs, MCP Apps UI), which is checkout-only;
|
|
57
|
+
where that app is absent the verb falls back to the bundled headless server
|
|
58
|
+
- interactive prompts and process-spawn edges are async (`run()` is
|
|
59
|
+
`async` throughout); core logic stays synchronous
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# Generate the self-signed TLS certificate the sandbox ingress terminates.
|
|
3
|
+
#
|
|
4
|
+
# The hostnames are NOT hardcoded here. They come from the sandbox ingress
|
|
5
|
+
# config (`ingress.local_domain` / `ingress.remote_domain` in
|
|
6
|
+
# ~/.sandbox/config.yaml, crossed with the routed services in
|
|
7
|
+
# ~/.sandbox/services.yaml) and are passed in by `sandbox setup` via --domain.
|
|
8
|
+
# That way the certificate SANs, the /etc/hosts entries and the Caddy site
|
|
9
|
+
# blocks all derive from one list instead of three hand-synced copies.
|
|
10
|
+
#
|
|
11
|
+
# Standalone use (no CLI): pass --domain yourself, or set WERKMEISTER_INGRESS_HOSTS
|
|
12
|
+
# to a comma/space-separated list. `sandbox setup` is the normal entry point.
|
|
13
|
+
# WERKMEISTER_HOSTS_FILE overrides /etc/hosts (tests). Each WERKMEISTER_* falls back to SANDBOX_*.
|
|
14
|
+
#
|
|
15
|
+
# Usage:
|
|
16
|
+
# generate-certs.sh [--domain HOST]... [--certs-dir DIR]
|
|
17
|
+
# [--no-trust] [--no-hosts] [--force] [--check]
|
|
18
|
+
#
|
|
19
|
+
# --domain HOST Hostname to cover (repeatable). Also seeds /etc/hosts.
|
|
20
|
+
# --certs-dir DIR Output directory [$SANDBOX_CERTS_DIR, else ~/.docker/certs]
|
|
21
|
+
# --no-trust Skip adding the cert to the macOS System keychain (no sudo)
|
|
22
|
+
# --no-hosts Skip the /etc/hosts update (no sudo)
|
|
23
|
+
# --force Regenerate even if a certificate already exists
|
|
24
|
+
# --check Report only; exit 1 if the cert or /etc/hosts drifted
|
|
25
|
+
#
|
|
26
|
+
# Exit codes: 0 ok / already current, 1 missing-or-stale (--check) or failure.
|
|
27
|
+
|
|
28
|
+
set -euo pipefail
|
|
29
|
+
|
|
30
|
+
CERTS_DIR="${WERKMEISTER_CERTS_DIR:-${SANDBOX_CERTS_DIR:-$HOME/.docker/certs}}"
|
|
31
|
+
HOSTS_FILE="${WERKMEISTER_HOSTS_FILE:-${SANDBOX_HOSTS_FILE:-/etc/hosts}}"
|
|
32
|
+
HOSTS_BEGIN="# BEGIN sandbox ingress"
|
|
33
|
+
HOSTS_END="# END sandbox ingress"
|
|
34
|
+
DO_TRUST=true
|
|
35
|
+
DO_HOSTS=true
|
|
36
|
+
FORCE=false
|
|
37
|
+
CHECK_ONLY=false
|
|
38
|
+
DOMAINS=()
|
|
39
|
+
|
|
40
|
+
# Seed from the env fallback (comma or whitespace separated) before parsing
|
|
41
|
+
# flags, so an explicit --domain adds to it rather than being overridden.
|
|
42
|
+
INGRESS_HOSTS="${WERKMEISTER_INGRESS_HOSTS:-${SANDBOX_INGRESS_HOSTS:-}}"
|
|
43
|
+
if [ -n "$INGRESS_HOSTS" ]; then
|
|
44
|
+
IFS=', ' read -r -a DOMAINS <<< "$INGRESS_HOSTS"
|
|
45
|
+
fi
|
|
46
|
+
|
|
47
|
+
while [ $# -gt 0 ]; do
|
|
48
|
+
case "$1" in
|
|
49
|
+
--domain) DOMAINS+=("$2"); shift 2 ;;
|
|
50
|
+
--domain=*) DOMAINS+=("${1#*=}"); shift ;;
|
|
51
|
+
--certs-dir) CERTS_DIR="$2"; shift 2 ;;
|
|
52
|
+
--certs-dir=*) CERTS_DIR="${1#*=}"; shift ;;
|
|
53
|
+
--no-trust) DO_TRUST=false; shift ;;
|
|
54
|
+
--no-hosts) DO_HOSTS=false; shift ;;
|
|
55
|
+
--force) FORCE=true; shift ;;
|
|
56
|
+
--check) CHECK_ONLY=true; shift ;;
|
|
57
|
+
-h|--help) sed -n '2,26p' "$0"; exit 0 ;;
|
|
58
|
+
*) echo "Unknown argument: $1" >&2; exit 1 ;;
|
|
59
|
+
esac
|
|
60
|
+
done
|
|
61
|
+
|
|
62
|
+
CRT="$CERTS_DIR/localhost.crt"
|
|
63
|
+
KEY="$CERTS_DIR/localhost.key"
|
|
64
|
+
|
|
65
|
+
# --- SAN list --------------------------------------------------------------
|
|
66
|
+
# localhost always; then each requested host plus a wildcard for its parent
|
|
67
|
+
# domain, so a later service on the same domain is already covered.
|
|
68
|
+
SANS=("DNS:localhost" "DNS:*.localhost" "IP:127.0.0.1")
|
|
69
|
+
seen_wildcards=""
|
|
70
|
+
for domain in ${DOMAINS+"${DOMAINS[@]}"}; do
|
|
71
|
+
[ -n "$domain" ] || continue
|
|
72
|
+
SANS+=("DNS:$domain")
|
|
73
|
+
parent="${domain#*.}"
|
|
74
|
+
# Only wildcard a real parent domain (a.b.c -> *.b.c), never a bare TLD.
|
|
75
|
+
if [ "$parent" != "$domain" ] && [ "${parent#*.}" != "$parent" ]; then
|
|
76
|
+
case " $seen_wildcards " in
|
|
77
|
+
*" $parent "*) ;;
|
|
78
|
+
*) SANS+=("DNS:*.$parent"); SANS+=("DNS:$parent")
|
|
79
|
+
seen_wildcards="$seen_wildcards $parent" ;;
|
|
80
|
+
esac
|
|
81
|
+
fi
|
|
82
|
+
done
|
|
83
|
+
|
|
84
|
+
SAN_STRING="$(IFS=,; echo "${SANS[*]}")"
|
|
85
|
+
|
|
86
|
+
# Lines in sorted set $1 that are not in sorted set $2, space-separated.
|
|
87
|
+
only_in() { comm -23 <(printf '%s\n' "$1") <(printf '%s\n' "$2") | sed '/^$/d' | tr '\n' ' '; }
|
|
88
|
+
|
|
89
|
+
# --- check mode ------------------------------------------------------------
|
|
90
|
+
# Stale means the explicit SANs differ from the registry either way: a missing
|
|
91
|
+
# host appears when a service is added, an extra one when a service is removed.
|
|
92
|
+
missing_sans=()
|
|
93
|
+
extra_sans=""
|
|
94
|
+
if [ -f "$CRT" ]; then
|
|
95
|
+
cert_text="$(openssl x509 -in "$CRT" -noout -ext subjectAltName 2>/dev/null || true)"
|
|
96
|
+
for domain in ${DOMAINS+"${DOMAINS[@]}"}; do
|
|
97
|
+
[ -n "$domain" ] || continue
|
|
98
|
+
parent="${domain#*.}"
|
|
99
|
+
if ! grep -qF "DNS:$domain" <<< "$cert_text" \
|
|
100
|
+
&& ! grep -qF "DNS:*.$parent" <<< "$cert_text"; then
|
|
101
|
+
missing_sans+=("$domain")
|
|
102
|
+
fi
|
|
103
|
+
done
|
|
104
|
+
extra_sans="$(only_in \
|
|
105
|
+
"$(grep -oE 'DNS:[^,[:space:]]+' <<< "$cert_text" | sort -u || true)" \
|
|
106
|
+
"$(printf '%s\n' "${SANS[@]}" | grep '^DNS:' | sort -u)")"
|
|
107
|
+
extra_sans="${extra_sans//DNS:/}"
|
|
108
|
+
fi
|
|
109
|
+
|
|
110
|
+
# Hosts entries this script owns: everything inside the managed block, plus bare
|
|
111
|
+
# `127.0.0.1` lines whose every name sits under an ingress domain — the lines
|
|
112
|
+
# older versions appended before the block existed. Anything else is the user's.
|
|
113
|
+
# `hosts_scan names` prints the owned names; `hosts_scan rest` prints the file
|
|
114
|
+
# without them.
|
|
115
|
+
hosts_scan() {
|
|
116
|
+
[ -f "$HOSTS_FILE" ] || return 0
|
|
117
|
+
awk -v mode="$1" -v parents="$seen_wildcards" -v b="$HOSTS_BEGIN" -v e="$HOSTS_END" '
|
|
118
|
+
BEGIN { n = split(parents, p, " ") }
|
|
119
|
+
function ingress(h, i, s) {
|
|
120
|
+
for (i = 1; i <= n; i++) {
|
|
121
|
+
s = "." p[i]
|
|
122
|
+
if (length(h) > length(s) && substr(h, length(h) - length(s) + 1) == s) return 1
|
|
123
|
+
}
|
|
124
|
+
return 0
|
|
125
|
+
}
|
|
126
|
+
function emit( i) { if (mode == "names") for (i = 2; i <= NF; i++) print $i }
|
|
127
|
+
$0 == b { inblock = 1; next }
|
|
128
|
+
$0 == e { inblock = 0; next }
|
|
129
|
+
inblock { emit(); next }
|
|
130
|
+
$1 == "127.0.0.1" && NF > 1 {
|
|
131
|
+
owned = 1
|
|
132
|
+
for (i = 2; i <= NF; i++) if (!ingress($i)) owned = 0
|
|
133
|
+
if (owned) { emit(); next }
|
|
134
|
+
}
|
|
135
|
+
mode == "rest" { print }
|
|
136
|
+
' "$HOSTS_FILE"
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
wanted_hosts="$(printf '%s\n' ${DOMAINS+"${DOMAINS[@]}"} | sed '/^$/d' | sort -u)"
|
|
140
|
+
if [ "$DO_HOSTS" = true ]; then
|
|
141
|
+
owned_hosts="$(hosts_scan names | sort -u)"
|
|
142
|
+
extra_hosts="$(only_in "$owned_hosts" "$wanted_hosts")"
|
|
143
|
+
missing_hosts="$(only_in "$wanted_hosts" "$owned_hosts")"
|
|
144
|
+
fi
|
|
145
|
+
|
|
146
|
+
if [ "$CHECK_ONLY" = true ]; then
|
|
147
|
+
drift=false
|
|
148
|
+
if [ ! -f "$CRT" ] || [ ! -f "$KEY" ]; then
|
|
149
|
+
echo "missing: no certificate at $CRT"
|
|
150
|
+
drift=true
|
|
151
|
+
else
|
|
152
|
+
if [ ${#missing_sans[@]} -gt 0 ]; then
|
|
153
|
+
echo "stale: certificate does not cover ${missing_sans[*]}"
|
|
154
|
+
drift=true
|
|
155
|
+
fi
|
|
156
|
+
if [ -n "$extra_sans" ]; then
|
|
157
|
+
echo "stale: certificate has extra SANs ${extra_sans% }"
|
|
158
|
+
drift=true
|
|
159
|
+
fi
|
|
160
|
+
fi
|
|
161
|
+
if [ -n "${missing_hosts:-}" ]; then
|
|
162
|
+
echo "stale: $HOSTS_FILE is missing ${missing_hosts% }"
|
|
163
|
+
drift=true
|
|
164
|
+
fi
|
|
165
|
+
if [ -n "${extra_hosts:-}" ]; then
|
|
166
|
+
echo "stale: $HOSTS_FILE has extra entries ${extra_hosts% }"
|
|
167
|
+
drift=true
|
|
168
|
+
fi
|
|
169
|
+
[ "$drift" = true ] && exit 1
|
|
170
|
+
echo "ok: $CRT covers ${#DOMAINS[@]} ingress hostname(s)"
|
|
171
|
+
exit 0
|
|
172
|
+
fi
|
|
173
|
+
|
|
174
|
+
# --- generate --------------------------------------------------------------
|
|
175
|
+
mkdir -p "$CERTS_DIR"
|
|
176
|
+
|
|
177
|
+
if [ -f "$CRT" ] && [ -f "$KEY" ] && [ "$FORCE" != true ]; then
|
|
178
|
+
if [ ${#missing_sans[@]} -gt 0 ]; then
|
|
179
|
+
echo "Certificate at $CRT does not cover: ${missing_sans[*]}"
|
|
180
|
+
echo "Regenerating to add the missing hostnames..."
|
|
181
|
+
elif [ -n "$extra_sans" ]; then
|
|
182
|
+
echo "Certificate at $CRT has SANs no longer routed: ${extra_sans% }"
|
|
183
|
+
echo "Regenerating to drop them..."
|
|
184
|
+
else
|
|
185
|
+
echo "Certificates already exist in $CERTS_DIR and cover every ingress hostname."
|
|
186
|
+
echo "To regenerate anyway: $0 --force"
|
|
187
|
+
# Still reconcile /etc/hosts below — a new or removed service changes
|
|
188
|
+
# its hosts entry even when the wildcard SAN already covered it.
|
|
189
|
+
FORCE=skip_generate
|
|
190
|
+
fi
|
|
191
|
+
fi
|
|
192
|
+
|
|
193
|
+
if [ "$FORCE" != "skip_generate" ]; then
|
|
194
|
+
echo "Generating self-signed SSL certificate..."
|
|
195
|
+
echo " SANs: $SAN_STRING"
|
|
196
|
+
|
|
197
|
+
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
|
|
198
|
+
-keyout "$KEY" \
|
|
199
|
+
-out "$CRT" \
|
|
200
|
+
-subj "/C=US/ST=State/L=City/O=Dev/CN=localhost" \
|
|
201
|
+
-addext "subjectAltName=$SAN_STRING"
|
|
202
|
+
|
|
203
|
+
chmod 600 "$KEY"
|
|
204
|
+
|
|
205
|
+
echo ""
|
|
206
|
+
echo "SSL certificate generated:"
|
|
207
|
+
echo " Certificate: $CRT"
|
|
208
|
+
echo " Private Key: $KEY"
|
|
209
|
+
echo ""
|
|
210
|
+
echo "Note: Docker Compose mounts these into Caddy via \$SANDBOX_CERTS_DIR."
|
|
211
|
+
|
|
212
|
+
# Trust the certificate on macOS.
|
|
213
|
+
if [ "$DO_TRUST" = true ] && [[ "$OSTYPE" == "darwin"* ]]; then
|
|
214
|
+
echo ""
|
|
215
|
+
echo "Trusting the certificate in the System keychain (requires sudo)..."
|
|
216
|
+
if sudo security add-trusted-cert -d -r trustRoot \
|
|
217
|
+
-k /Library/Keychains/System.keychain "$CRT"; then
|
|
218
|
+
echo "Certificate trusted. Restart your browser for it to take effect."
|
|
219
|
+
else
|
|
220
|
+
echo "Failed to trust the certificate automatically. To do it manually:"
|
|
221
|
+
echo " sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain $CRT"
|
|
222
|
+
fi
|
|
223
|
+
elif [ "$DO_TRUST" = true ]; then
|
|
224
|
+
echo ""
|
|
225
|
+
echo "Not macOS — trust $CRT with your platform's certificate store to"
|
|
226
|
+
echo "avoid browser warnings."
|
|
227
|
+
fi
|
|
228
|
+
fi
|
|
229
|
+
|
|
230
|
+
# --- /etc/hosts ------------------------------------------------------------
|
|
231
|
+
# Rebuild the managed block from the registry so removed services drop out;
|
|
232
|
+
# lines outside it are left exactly as they were.
|
|
233
|
+
if [ "$DO_HOSTS" = true ] && [ ${#DOMAINS[@]} -gt 0 ]; then
|
|
234
|
+
echo ""
|
|
235
|
+
echo "Pointing ingress hostnames at 127.0.0.1 in $HOSTS_FILE..."
|
|
236
|
+
rendered="$(
|
|
237
|
+
hosts_scan rest
|
|
238
|
+
echo "$HOSTS_BEGIN"
|
|
239
|
+
while read -r domain; do echo "127.0.0.1 $domain"; done <<< "$wanted_hosts"
|
|
240
|
+
echo "$HOSTS_END"
|
|
241
|
+
)"
|
|
242
|
+
if [ -f "$HOSTS_FILE" ] && [ "$rendered" = "$(cat "$HOSTS_FILE")" ]; then
|
|
243
|
+
echo "No changes needed to $HOSTS_FILE."
|
|
244
|
+
else
|
|
245
|
+
for domain in $missing_hosts; do echo " + $domain"; done
|
|
246
|
+
for domain in $extra_hosts; do echo " - $domain"; done
|
|
247
|
+
if [ -w "$HOSTS_FILE" ]; then
|
|
248
|
+
printf '%s\n' "$rendered" > "$HOSTS_FILE"
|
|
249
|
+
else
|
|
250
|
+
printf '%s\n' "$rendered" | sudo tee "$HOSTS_FILE" > /dev/null
|
|
251
|
+
fi
|
|
252
|
+
echo "$HOSTS_FILE updated."
|
|
253
|
+
fi
|
|
254
|
+
fi
|
package/dist/lib/gate.js
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import { createRequire as __sandboxCreateRequire } from "node:module";
|
|
2
|
+
const require = __sandboxCreateRequire(import.meta.url);
|
|
3
|
+
|
|
4
|
+
// ../../packages/sandbox-core/dist/gate.js
|
|
5
|
+
import * as fs2 from "node:fs";
|
|
6
|
+
import * as path2 from "node:path";
|
|
7
|
+
|
|
8
|
+
// ../../packages/sandbox-core/dist/dot_dir.js
|
|
9
|
+
import * as fs from "node:fs";
|
|
10
|
+
import * as path from "node:path";
|
|
11
|
+
var DOT_DIRS = [".werkmeister", ".sandbox"];
|
|
12
|
+
function dotDir(parent) {
|
|
13
|
+
const found = DOT_DIRS.find((d) => fs.existsSync(path.join(parent, d)));
|
|
14
|
+
return path.join(parent, found ?? DOT_DIRS[0]);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// ../../packages/sandbox-core/dist/gate.js
|
|
18
|
+
var _PRECEDENCE_TEXT = "Precedence: these standards' rules win over conflicting task framing \u2014 if a rule and the task clash, follow the rule and say so (invariant: apply it and write a `decision` note; rule: surface the conflict in one line, then apply). This gate fires once; your retry will proceed.";
|
|
19
|
+
var _PHASE_ENTRY_TEXT = "Standards gate: invoke `sb-implement` before coding \u2014 it loads the universal base (ponytail + comments + engineering) and the workspace notes. Then retry this edit.";
|
|
20
|
+
function _translate(pat) {
|
|
21
|
+
let res = "";
|
|
22
|
+
let i = 0;
|
|
23
|
+
const n = pat.length;
|
|
24
|
+
while (i < n) {
|
|
25
|
+
const c = pat[i];
|
|
26
|
+
i += 1;
|
|
27
|
+
if (c === "*") {
|
|
28
|
+
res += ".*";
|
|
29
|
+
} else if (c === "?") {
|
|
30
|
+
res += ".";
|
|
31
|
+
} else if (c === "[") {
|
|
32
|
+
let j = i;
|
|
33
|
+
if (j < n && pat[j] === "!")
|
|
34
|
+
j += 1;
|
|
35
|
+
if (j < n && pat[j] === "]")
|
|
36
|
+
j += 1;
|
|
37
|
+
while (j < n && pat[j] !== "]")
|
|
38
|
+
j += 1;
|
|
39
|
+
if (j >= n) {
|
|
40
|
+
res += "\\[";
|
|
41
|
+
} else {
|
|
42
|
+
let stuff = pat.slice(i, j).replace(/\\/g, "\\\\");
|
|
43
|
+
i = j + 1;
|
|
44
|
+
if (stuff.startsWith("!"))
|
|
45
|
+
stuff = "^" + stuff.slice(1);
|
|
46
|
+
else if (stuff.startsWith("^"))
|
|
47
|
+
stuff = "\\" + stuff;
|
|
48
|
+
res += `[${stuff}]`;
|
|
49
|
+
}
|
|
50
|
+
} else {
|
|
51
|
+
res += c.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return `^(?:${res})$`;
|
|
55
|
+
}
|
|
56
|
+
function fnmatch(name, pat) {
|
|
57
|
+
return new RegExp(_translate(pat), "s").test(name);
|
|
58
|
+
}
|
|
59
|
+
function findWorkspaceRoot(start) {
|
|
60
|
+
let directory = path2.resolve(start);
|
|
61
|
+
while (true) {
|
|
62
|
+
if (_isDir(dotDir(directory))) {
|
|
63
|
+
return directory;
|
|
64
|
+
}
|
|
65
|
+
const parent = path2.dirname(directory);
|
|
66
|
+
if (parent === directory) {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
directory = parent;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
function _isDir(p) {
|
|
73
|
+
try {
|
|
74
|
+
return fs2.statSync(p).isDirectory();
|
|
75
|
+
} catch {
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
function _isFile(p) {
|
|
80
|
+
try {
|
|
81
|
+
return fs2.statSync(p).isFile();
|
|
82
|
+
} catch {
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
function _isDict(value) {
|
|
87
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
88
|
+
}
|
|
89
|
+
function loadManifest(workspace) {
|
|
90
|
+
const manifestPath = path2.join(dotDir(workspace), "standards.json");
|
|
91
|
+
if (!_isFile(manifestPath)) {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
let data;
|
|
95
|
+
try {
|
|
96
|
+
data = JSON.parse(fs2.readFileSync(manifestPath, "utf8"));
|
|
97
|
+
} catch {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
return _isDict(data) ? data : null;
|
|
101
|
+
}
|
|
102
|
+
function _statePath(workspace, sessionId) {
|
|
103
|
+
return path2.join(dotDir(workspace), ".gate", `${sessionId}.json`);
|
|
104
|
+
}
|
|
105
|
+
function _loadState(workspace, sessionId) {
|
|
106
|
+
const statePath = _statePath(workspace, sessionId);
|
|
107
|
+
if (!_isFile(statePath)) {
|
|
108
|
+
return {};
|
|
109
|
+
}
|
|
110
|
+
let data;
|
|
111
|
+
try {
|
|
112
|
+
data = JSON.parse(fs2.readFileSync(statePath, "utf8"));
|
|
113
|
+
} catch {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
return _isDict(data) ? data : null;
|
|
117
|
+
}
|
|
118
|
+
function _saveState(workspace, sessionId, state) {
|
|
119
|
+
try {
|
|
120
|
+
const statePath = _statePath(workspace, sessionId);
|
|
121
|
+
fs2.mkdirSync(path2.dirname(statePath), { recursive: true });
|
|
122
|
+
fs2.writeFileSync(statePath, JSON.stringify(state));
|
|
123
|
+
return true;
|
|
124
|
+
} catch {
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
function _sorted(values) {
|
|
129
|
+
return [...values].sort();
|
|
130
|
+
}
|
|
131
|
+
function _noteStatus(text) {
|
|
132
|
+
const block = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text);
|
|
133
|
+
if (!block) {
|
|
134
|
+
return void 0;
|
|
135
|
+
}
|
|
136
|
+
const status = /^status:\s*["']?([^\s"'#]+)/m.exec(block[1]);
|
|
137
|
+
return status ? status[1].toLowerCase() : null;
|
|
138
|
+
}
|
|
139
|
+
function _phaseGateSatisfied(workspace, gate) {
|
|
140
|
+
const note = String(gate["note"] ?? "");
|
|
141
|
+
if (!/^[a-z]+$/.test(note)) {
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
144
|
+
const want = gate["status"] ? String(gate["status"]).toLowerCase() : null;
|
|
145
|
+
const dir = path2.join(dotDir(workspace), "notes");
|
|
146
|
+
let names;
|
|
147
|
+
try {
|
|
148
|
+
names = fs2.readdirSync(dir);
|
|
149
|
+
} catch (exc) {
|
|
150
|
+
return exc.code !== "ENOENT";
|
|
151
|
+
}
|
|
152
|
+
const pattern = new RegExp(`^\\d{3}-${note}-.+\\.md$`);
|
|
153
|
+
for (const name of names) {
|
|
154
|
+
if (!pattern.test(name)) {
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
let text;
|
|
158
|
+
try {
|
|
159
|
+
text = fs2.readFileSync(path2.join(dir, name), "utf8");
|
|
160
|
+
} catch {
|
|
161
|
+
return true;
|
|
162
|
+
}
|
|
163
|
+
const status = _noteStatus(text);
|
|
164
|
+
if (status === void 0) {
|
|
165
|
+
return true;
|
|
166
|
+
}
|
|
167
|
+
if (status === "superseded" || status === "graduated") {
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
if (want === null || status === want) {
|
|
171
|
+
return true;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
function _phaseGateText(gate) {
|
|
177
|
+
const note = String(gate["note"]);
|
|
178
|
+
if (gate["status"]) {
|
|
179
|
+
const status = String(gate["status"]);
|
|
180
|
+
return `Phase gate: no \`${note}\` note in \`.sandbox/notes/\` has \`status: ${status}\`, so source edits wait for an approved design. Invoke \`sb-research\`, present the design to the user, and when they approve write \`NNN-${note}-<slug>.md\` with \`status: ${status}\` recording what was approved. Then retry this edit. Edits under \`.sandbox/\` are not gated.`;
|
|
181
|
+
}
|
|
182
|
+
return `Phase gate: no \`${note}\` note in \`.sandbox/notes/\` names the root cause yet. Invoke \`sb-research\`, reproduce the problem, and write \`NNN-${note}-<slug>.md\` stating the root cause. Then retry this edit. Edits under \`.sandbox/\` are not gated.`;
|
|
183
|
+
}
|
|
184
|
+
function decide(workspaceStart, sessionId, filePath) {
|
|
185
|
+
if (!sessionId || sessionId.includes("/") || sessionId.includes("..")) {
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
const workspace = findWorkspaceRoot(workspaceStart);
|
|
189
|
+
if (workspace === null) {
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
const manifest = loadManifest(workspace);
|
|
193
|
+
if (manifest === null) {
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
const state = _loadState(workspace, sessionId);
|
|
197
|
+
if (state === null) {
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
const relPath = filePath ? path2.relative(workspace, filePath) : "";
|
|
201
|
+
const phaseGate = manifest["phase_gate"];
|
|
202
|
+
if (_isDict(phaseGate) && relPath && relPath !== ".." && !relPath.startsWith(".." + path2.sep) && !path2.isAbsolute(relPath) && !DOT_DIRS.some((d) => relPath.startsWith(d + path2.sep)) && !_phaseGateSatisfied(workspace, phaseGate)) {
|
|
203
|
+
return _phaseGateText(phaseGate);
|
|
204
|
+
}
|
|
205
|
+
const delivered = new Set(Array.isArray(state["delivered"]) ? state["delivered"] : []);
|
|
206
|
+
const needsPhaseEntry = !state["phase_entry_delivered"];
|
|
207
|
+
let domain = null;
|
|
208
|
+
const domains = _isDict(manifest["domains"]) ? manifest["domains"] : {};
|
|
209
|
+
for (const name of _sorted(Object.keys(domains))) {
|
|
210
|
+
const meta = domains[name];
|
|
211
|
+
if (delivered.has(name) || !_isDict(meta)) {
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
const globs = Array.isArray(meta["applies_to"]) ? meta["applies_to"] : [];
|
|
215
|
+
if (globs.some((glob) => fnmatch(relPath, String(glob)))) {
|
|
216
|
+
domain = name;
|
|
217
|
+
break;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
if (!needsPhaseEntry && domain === null) {
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
const parts = [];
|
|
224
|
+
if (needsPhaseEntry) {
|
|
225
|
+
parts.push(_PHASE_ENTRY_TEXT);
|
|
226
|
+
}
|
|
227
|
+
if (domain !== null) {
|
|
228
|
+
const meta = domains[domain];
|
|
229
|
+
const summary = String(_isDict(meta) && meta["summary"] || "").trim();
|
|
230
|
+
let domainText = `Standards gate: you are editing a path governed by the \`${domain}\` standard, which has not been loaded this session. Invoke the \`sb-${domain}-standard\` skill, then retry this edit.`;
|
|
231
|
+
if (summary) {
|
|
232
|
+
domainText += ` Summary: ${summary}.`;
|
|
233
|
+
}
|
|
234
|
+
parts.push(domainText);
|
|
235
|
+
}
|
|
236
|
+
parts.push(_PRECEDENCE_TEXT);
|
|
237
|
+
const newState = {
|
|
238
|
+
phase_entry_delivered: true,
|
|
239
|
+
delivered: _sorted(domain ? /* @__PURE__ */ new Set([...delivered, domain]) : delivered)
|
|
240
|
+
};
|
|
241
|
+
if (!_saveState(workspace, sessionId, newState)) {
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
return parts.join("\n");
|
|
245
|
+
}
|
|
246
|
+
function main() {
|
|
247
|
+
let data;
|
|
248
|
+
try {
|
|
249
|
+
const raw = process.stdin.isTTY ? "" : fs2.readFileSync(0, "utf8");
|
|
250
|
+
const parsed = raw.trim() ? JSON.parse(raw) : {};
|
|
251
|
+
data = _isDict(parsed) ? parsed : {};
|
|
252
|
+
} catch {
|
|
253
|
+
data = {};
|
|
254
|
+
}
|
|
255
|
+
const sessionId = String(data["session_id"] || "");
|
|
256
|
+
const toolInput = data["tool_input"];
|
|
257
|
+
const filePath = String(_isDict(toolInput) && toolInput["file_path"] || "");
|
|
258
|
+
const start = String(data["cwd"] || process.cwd());
|
|
259
|
+
let reason;
|
|
260
|
+
try {
|
|
261
|
+
reason = decide(start, sessionId, filePath);
|
|
262
|
+
} catch {
|
|
263
|
+
reason = null;
|
|
264
|
+
}
|
|
265
|
+
if (reason) {
|
|
266
|
+
console.log(JSON.stringify({
|
|
267
|
+
hookSpecificOutput: {
|
|
268
|
+
hookEventName: "PreToolUse",
|
|
269
|
+
permissionDecision: "deny",
|
|
270
|
+
permissionDecisionReason: reason
|
|
271
|
+
}
|
|
272
|
+
}));
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// src/lib/gate.ts
|
|
277
|
+
main();
|
|
278
|
+
//# sourceMappingURL=gate.js.map
|