@devrouter/cli 0.0.29 → 0.0.31
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/README.md +48 -11
- package/bin/devrouter-process +274 -0
- package/dist/devrouter.js +1107 -288
- package/package.json +7 -3
- package/upgrade-prompts/0.0.30.md +13 -0
- package/upgrade-prompts/0.0.31.md +23 -0
package/README.md
CHANGED
|
@@ -108,11 +108,18 @@ vars. Use it when you are not (yet) on a devcontainer. Fully supported.
|
|
|
108
108
|
- `devrouter workspace up <branch> [--path <dir>] [--no-devpod] [--open] [--repo <path>]`
|
|
109
109
|
- `devrouter workspace ensure [path] [--open]`
|
|
110
110
|
- `devrouter workspace ls [--repo <path>] [--json]`
|
|
111
|
-
- `devrouter workspace
|
|
111
|
+
- `devrouter workspace stop <workspace|branch> [--repo <path>]`
|
|
112
|
+
- `devrouter workspace down <workspace|branch> [--keep-worktree] [--repo <path>]`
|
|
113
|
+
- `devrouter workspace gc [--repo <path>] [--json] [--yes]`
|
|
112
114
|
|
|
113
115
|
The current `devrouter repo devcontainer write` scaffold is intentionally narrow:
|
|
114
116
|
Node + pnpm + Postgres. Other package managers stop with a JSON diagnostic
|
|
115
117
|
instead of writing files that would need manual repair.
|
|
118
|
+
The generated image extracts `devrouter-process` from the exact Devrouter
|
|
119
|
+
package tarball without installing the CLI dependency tree. Its `post-start.sh`
|
|
120
|
+
uses that helper for locked, owned, idempotent background startup. Application
|
|
121
|
+
commands and environment setup remain repository-owned; route readiness remains
|
|
122
|
+
part of `devrouter workspace ensure`.
|
|
116
123
|
Use `devrouter repo devcontainer verify --json` for read-only PR evidence; add
|
|
117
124
|
`--live --yes` only after the devcontainer is running and route probes should
|
|
118
125
|
mutate local route state.
|
|
@@ -121,12 +128,13 @@ mutate local route state.
|
|
|
121
128
|
|
|
122
129
|
A **workspace token** lets several git worktrees of the same repo run side-by-side without host or route collisions. The token is a single identity spanning three layers: the devpod workspace id, the routes devrouter registers, and the `${WORKSPACE}` placeholder in `.devrouter.yml` proxy upstreams and devcontainer compose network aliases.
|
|
123
130
|
|
|
124
|
-
Each linked worktree
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
from the linked branch/path. Ambiguous identities and DevPods owned by
|
|
129
|
-
worktree fail closed. The primary checkout keeps its plain,
|
|
131
|
+
Each managed linked worktree keeps its local token in Git metadata and a durable
|
|
132
|
+
owner record under the repository's Git common directory. The record survives
|
|
133
|
+
out-of-band linked-worktree removal and ties the exact worktree path to its DevPod
|
|
134
|
+
ID. On first use, devrouter reuses an exact-path DevPod or derives a sanitized
|
|
135
|
+
identity from the linked branch/path. Ambiguous identities and DevPods owned by
|
|
136
|
+
another worktree fail closed. The primary checkout keeps its plain,
|
|
137
|
+
non-namespaced routes.
|
|
130
138
|
|
|
131
139
|
**When a workspace token is active:**
|
|
132
140
|
|
|
@@ -146,15 +154,41 @@ devrouter workspace up feat/my-feature
|
|
|
146
154
|
# Reconcile an existing linked worktree (canonical agent startup command)
|
|
147
155
|
devrouter workspace ensure .
|
|
148
156
|
|
|
149
|
-
# List
|
|
157
|
+
# List ownership, Git, DevPod, and route state
|
|
150
158
|
devrouter workspace ls
|
|
151
159
|
|
|
152
|
-
#
|
|
160
|
+
# Pause runtime while preserving the checkout, owner record, and data
|
|
161
|
+
devrouter workspace stop feat/my-feature
|
|
162
|
+
|
|
163
|
+
# Delete runtime and routes, then remove the clean worktree and owner record
|
|
153
164
|
devrouter workspace down feat/my-feature
|
|
165
|
+
|
|
166
|
+
# Review missing owners; add --yes only after reviewing the dry run
|
|
167
|
+
devrouter workspace gc
|
|
168
|
+
devrouter workspace gc --yes
|
|
154
169
|
```
|
|
155
170
|
|
|
156
171
|
New worktrees default to the repository's ignored `trees/<workspace>` directory; use `--path` only when a repository intentionally follows another layout.
|
|
157
172
|
|
|
173
|
+
| Command | DevPod | Routes | Worktree and owner record |
|
|
174
|
+
| --- | --- | --- | --- |
|
|
175
|
+
| `workspace stop` | stop | remove | keep |
|
|
176
|
+
| `workspace down` | delete | remove | remove only when clean and unlocked |
|
|
177
|
+
| `workspace down --keep-worktree` | delete | remove | keep |
|
|
178
|
+
| `workspace gc` | report only | report only | never removes Git worktrees |
|
|
179
|
+
| `workspace gc --yes` | delete eligible missing owners | remove exact owned routes | remove only the owner record |
|
|
180
|
+
|
|
181
|
+
`workspace ls` reports owner status as `present`, `missing`, `locked`, or
|
|
182
|
+
`conflict`. Full `workspace down` checks ownership, the Git registration, lock,
|
|
183
|
+
and cleanliness before changing runtime or routes; dirty and locked worktrees fail
|
|
184
|
+
with zero teardown side effects.
|
|
185
|
+
|
|
186
|
+
Workspace lifecycle commands require a Git repository. Normal config, app,
|
|
187
|
+
status, and diagnostic commands remain usable from a `.devrouter.yml` directory
|
|
188
|
+
without Git. Git has no worktree-removal hook, so devrouter does not install one:
|
|
189
|
+
after manual removal, use `workspace ls`, `doctor`, or the dry-run
|
|
190
|
+
`workspace gc` report before applying cleanup with `--yes`.
|
|
191
|
+
|
|
158
192
|
**devcontainer integration:** the devcontainer compose service exposes a devnet network alias `${WORKSPACE}-app` (defaulting to the project name in `devcontainer.env`); the proxy app uses `upstream: ${WORKSPACE}-app:<port>`. `.devcontainer/docker-compose.devrouter.yml` passes `WORKSPACE` and `DEVROUTER_WORKSPACE` into the app and bind-mounts `${DEVROUTER_GIT_COMMON_DIR}` to the same absolute container path, so linked-worktree `.git` pointers remain valid. Workspace `feat-a` → alias `feat-a-app`, host `app.feat-a.localhost`.
|
|
159
193
|
|
|
160
194
|
`workspace ensure` is intentionally proof-driven: it verifies exact worktree
|
|
@@ -171,7 +205,10 @@ agent-native devcontainer path end to end — `./run.sh` brings up a DevPod
|
|
|
171
205
|
workspace, registers app/Postgres proxy routes, runs static/live verification,
|
|
172
206
|
and prints the proof. `./run.sh down` tears it down.
|
|
173
207
|
|
|
174
|
-
**
|
|
208
|
+
**Missing-owner detection:** `devrouter doctor` reports ledger-owned workspaces
|
|
209
|
+
whose checkout disappeared or conflicts with live Git/DevPod evidence. It does
|
|
210
|
+
not mutate workspace state and prints the exact dry-run remediation command:
|
|
211
|
+
`devrouter workspace gc --repo <repo>`.
|
|
175
212
|
|
|
176
213
|
## Upgrade metadata and prompts
|
|
177
214
|
|
|
@@ -238,7 +275,7 @@ devrouter doctor --repo /absolute/path/to/repo --json
|
|
|
238
275
|
For host apps that depend on postgres, `devrouter doctor` also checks host command wrapper precedence and warns with `repo.host-command-env-precedence` when `DATABASE_URI`/`DATABASE_URL` is assigned before a `run --` wrapper boundary.
|
|
239
276
|
When TLS is enabled, `devrouter doctor` also checks TLS host coverage and warns with `repo.tls-host-coverage` if configured `.localhost` hosts are not covered by the current cert SANs.
|
|
240
277
|
When `.devcontainer/` exists, `devrouter doctor` checks devnet aliases, published host ports, and proxy upstream alias matches.
|
|
241
|
-
`devrouter doctor` reports stale host routes and
|
|
278
|
+
`devrouter doctor` reports stale host routes and ownership cleanup candidates without mutating route state; workspace findings point to dry-run `devrouter workspace gc --repo <repo>`.
|
|
242
279
|
|
|
243
280
|
## `.devrouter.yml` example
|
|
244
281
|
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
usage() {
|
|
5
|
+
cat <<'EOF'
|
|
6
|
+
Usage:
|
|
7
|
+
devrouter-process ensure --name <name> --match <regex> [options] -- <command> [args...]
|
|
8
|
+
|
|
9
|
+
Options:
|
|
10
|
+
--fingerprint <value> Runtime identity. Defaults to command plus workspace identity.
|
|
11
|
+
--log <path> Log file. Defaults to /tmp/devrouter-process-<name>.log.
|
|
12
|
+
EOF
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
die() {
|
|
16
|
+
echo "[devrouter-process] $*" >&2
|
|
17
|
+
exit 1
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
require_value() {
|
|
21
|
+
[ "$#" -ge 2 ] || die "$1 requires a value."
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
[ "${1:-}" = "ensure" ] || {
|
|
25
|
+
usage >&2
|
|
26
|
+
exit 1
|
|
27
|
+
}
|
|
28
|
+
shift
|
|
29
|
+
|
|
30
|
+
name=""
|
|
31
|
+
process_match=""
|
|
32
|
+
fingerprint=""
|
|
33
|
+
log_file=""
|
|
34
|
+
|
|
35
|
+
while [ "$#" -gt 0 ]; do
|
|
36
|
+
case "$1" in
|
|
37
|
+
--name)
|
|
38
|
+
require_value "$@"
|
|
39
|
+
name="$2"
|
|
40
|
+
shift 2
|
|
41
|
+
;;
|
|
42
|
+
--match)
|
|
43
|
+
require_value "$@"
|
|
44
|
+
process_match="$2"
|
|
45
|
+
shift 2
|
|
46
|
+
;;
|
|
47
|
+
--fingerprint)
|
|
48
|
+
require_value "$@"
|
|
49
|
+
fingerprint="$2"
|
|
50
|
+
shift 2
|
|
51
|
+
;;
|
|
52
|
+
--log)
|
|
53
|
+
require_value "$@"
|
|
54
|
+
log_file="$2"
|
|
55
|
+
shift 2
|
|
56
|
+
;;
|
|
57
|
+
--help|-h)
|
|
58
|
+
usage
|
|
59
|
+
exit 0
|
|
60
|
+
;;
|
|
61
|
+
--)
|
|
62
|
+
shift
|
|
63
|
+
break
|
|
64
|
+
;;
|
|
65
|
+
*)
|
|
66
|
+
die "Unknown option: $1"
|
|
67
|
+
;;
|
|
68
|
+
esac
|
|
69
|
+
done
|
|
70
|
+
|
|
71
|
+
[[ "$name" =~ ^[a-zA-Z0-9][a-zA-Z0-9._-]*$ ]] || die "--name must be a safe identifier."
|
|
72
|
+
[ -n "$process_match" ] || die "--match is required."
|
|
73
|
+
[ "$#" -gt 0 ] || die "A command is required after --."
|
|
74
|
+
[ -r "/proc/$$/environ" ] || die "Linux /proc process metadata is required."
|
|
75
|
+
|
|
76
|
+
for tool in awk cksum flock grep pgrep ps setsid tr; do
|
|
77
|
+
command -v "$tool" >/dev/null 2>&1 || die "Required command is unavailable: $tool"
|
|
78
|
+
done
|
|
79
|
+
|
|
80
|
+
match_status=0
|
|
81
|
+
pgrep -f -- "$process_match" >/dev/null 2>&1 || match_status=$?
|
|
82
|
+
[ "$match_status" -le 1 ] || die "--match is not a valid process regular expression."
|
|
83
|
+
|
|
84
|
+
if [ -z "$fingerprint" ]; then
|
|
85
|
+
fingerprint="$({
|
|
86
|
+
printf '%s\0' "$name" "${WORKSPACE:-}" "${DEVROUTER_WORKSPACE:-}"
|
|
87
|
+
printf '%s\0' "$@"
|
|
88
|
+
} | cksum | awk '{print $1 "-" $2}')"
|
|
89
|
+
fi
|
|
90
|
+
[[ "$fingerprint" =~ ^[a-zA-Z0-9][a-zA-Z0-9._:-]*$ ]] || die "--fingerprint must be a safe identifier."
|
|
91
|
+
|
|
92
|
+
state_dir="${DEVROUTER_PROCESS_STATE_DIR:-/tmp}"
|
|
93
|
+
state_file="$state_dir/devrouter-process-$name.state"
|
|
94
|
+
lock_file="$state_file.lock"
|
|
95
|
+
log_file="${log_file:-/tmp/devrouter-process-$name.log}"
|
|
96
|
+
lock_timeout="${DEVROUTER_PROCESS_LOCK_TIMEOUT_SECONDS:-30}"
|
|
97
|
+
term_timeout="${DEVROUTER_PROCESS_TERM_TIMEOUT_SECONDS:-15}"
|
|
98
|
+
kill_timeout="${DEVROUTER_PROCESS_KILL_TIMEOUT_SECONDS:-5}"
|
|
99
|
+
|
|
100
|
+
process_alive() {
|
|
101
|
+
local pid="$1"
|
|
102
|
+
local state
|
|
103
|
+
|
|
104
|
+
kill -0 "$pid" 2>/dev/null || return 1
|
|
105
|
+
state="$(ps -o stat= -p "$pid" 2>/dev/null | tr -d ' ')"
|
|
106
|
+
[[ "$state" != Z* ]]
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
process_owned() {
|
|
110
|
+
local pid="$1"
|
|
111
|
+
local pgid="$2"
|
|
112
|
+
local expected_fingerprint="$3"
|
|
113
|
+
local actual_pgid
|
|
114
|
+
|
|
115
|
+
[ -r "/proc/$pid/environ" ] || return 1
|
|
116
|
+
actual_pgid="$(ps -o pgid= -p "$pid" 2>/dev/null | tr -d ' ')"
|
|
117
|
+
[ "$actual_pgid" = "$pgid" ] || return 1
|
|
118
|
+
[ "$pgid" = "$pid" ] || return 1
|
|
119
|
+
tr '\0' '\n' <"/proc/$pid/environ" 2>/dev/null |
|
|
120
|
+
grep -Fqx "DEVROUTER_PROCESS_NAME=$name" || return 1
|
|
121
|
+
tr '\0' '\n' <"/proc/$pid/environ" 2>/dev/null |
|
|
122
|
+
grep -Fqx "DEVROUTER_PROCESS_FINGERPRINT=$expected_fingerprint"
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
process_group_alive() {
|
|
126
|
+
local pgid="$1"
|
|
127
|
+
|
|
128
|
+
ps -eo pgid=,stat= | awk -v expected="$pgid" '
|
|
129
|
+
$1 == expected && $2 !~ /^Z/ { found = 1 }
|
|
130
|
+
END { exit(found ? 0 : 1) }
|
|
131
|
+
'
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
is_self_or_ancestor() {
|
|
135
|
+
local candidate="$1"
|
|
136
|
+
local current="$$"
|
|
137
|
+
|
|
138
|
+
while [[ "$current" =~ ^[1-9][0-9]*$ ]]; do
|
|
139
|
+
[ "$candidate" = "$current" ] && return 0
|
|
140
|
+
[ "$current" -le 1 ] && break
|
|
141
|
+
current="$(ps -o ppid= -p "$current" 2>/dev/null | tr -d ' ')"
|
|
142
|
+
done
|
|
143
|
+
return 1
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
is_helper_process() {
|
|
147
|
+
local candidate="$1"
|
|
148
|
+
|
|
149
|
+
[ -r "/proc/$candidate/cmdline" ] || return 1
|
|
150
|
+
tr '\0' '\n' <"/proc/$candidate/cmdline" 2>/dev/null | grep -Fqx "$0"
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
marked_process_exists() {
|
|
154
|
+
local environ
|
|
155
|
+
local pid
|
|
156
|
+
|
|
157
|
+
for environ in /proc/[1-9]*/environ; do
|
|
158
|
+
[ -r "$environ" ] || continue
|
|
159
|
+
pid="${environ#/proc/}"
|
|
160
|
+
pid="${pid%/environ}"
|
|
161
|
+
process_alive "$pid" || continue
|
|
162
|
+
is_self_or_ancestor "$pid" && continue
|
|
163
|
+
if tr '\0' '\n' <"$environ" 2>/dev/null | grep -Fqx "DEVROUTER_PROCESS_NAME=$name"; then
|
|
164
|
+
return 0
|
|
165
|
+
fi
|
|
166
|
+
done
|
|
167
|
+
return 1
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
matching_process_exists() {
|
|
171
|
+
local pid
|
|
172
|
+
|
|
173
|
+
while IFS= read -r pid; do
|
|
174
|
+
[ -n "$pid" ] || continue
|
|
175
|
+
[ -d "/proc/$pid" ] || continue
|
|
176
|
+
process_alive "$pid" || continue
|
|
177
|
+
is_self_or_ancestor "$pid" && continue
|
|
178
|
+
is_helper_process "$pid" && continue
|
|
179
|
+
return 0
|
|
180
|
+
done < <(pgrep -f -- "$process_match" 2>/dev/null || true)
|
|
181
|
+
return 1
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
unknown_process_exists() {
|
|
185
|
+
marked_process_exists || matching_process_exists
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
stop_process_group() {
|
|
189
|
+
local pid="$1"
|
|
190
|
+
local pgid="$2"
|
|
191
|
+
local attempt
|
|
192
|
+
|
|
193
|
+
kill -TERM -- "-$pgid" 2>/dev/null || true
|
|
194
|
+
for ((attempt = 1; attempt <= term_timeout; attempt += 1)); do
|
|
195
|
+
if ! process_group_alive "$pgid"; then
|
|
196
|
+
wait "$pid" 2>/dev/null || true
|
|
197
|
+
return 0
|
|
198
|
+
fi
|
|
199
|
+
sleep 1
|
|
200
|
+
done
|
|
201
|
+
|
|
202
|
+
kill -KILL -- "-$pgid" 2>/dev/null || true
|
|
203
|
+
for ((attempt = 1; attempt <= kill_timeout; attempt += 1)); do
|
|
204
|
+
if ! process_group_alive "$pgid"; then
|
|
205
|
+
wait "$pid" 2>/dev/null || true
|
|
206
|
+
return 0
|
|
207
|
+
fi
|
|
208
|
+
sleep 1
|
|
209
|
+
done
|
|
210
|
+
|
|
211
|
+
echo "[devrouter-process] Could not stop owned process group $pgid." >&2
|
|
212
|
+
return 1
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
mkdir -p "$state_dir" "$(dirname "$log_file")"
|
|
216
|
+
exec 9>"$lock_file"
|
|
217
|
+
flock -w "$lock_timeout" 9 || die "Timed out waiting for the '$name' process lock."
|
|
218
|
+
|
|
219
|
+
pid=""
|
|
220
|
+
pgid=""
|
|
221
|
+
stored_fingerprint=""
|
|
222
|
+
extra=""
|
|
223
|
+
|
|
224
|
+
if [ -f "$state_file" ]; then
|
|
225
|
+
read -r pid pgid stored_fingerprint extra <"$state_file" || true
|
|
226
|
+
if [ -z "$stored_fingerprint" ] ||
|
|
227
|
+
! [[ "$pid" =~ ^[1-9][0-9]*$ ]] ||
|
|
228
|
+
! [[ "$pgid" =~ ^[1-9][0-9]*$ ]] ||
|
|
229
|
+
[ -n "$extra" ]; then
|
|
230
|
+
if unknown_process_exists; then
|
|
231
|
+
die "Invalid state while an unowned '$name' process is running; refusing to start or kill it."
|
|
232
|
+
fi
|
|
233
|
+
rm -f "$state_file"
|
|
234
|
+
elif process_alive "$pid"; then
|
|
235
|
+
process_owned "$pid" "$pgid" "$stored_fingerprint" ||
|
|
236
|
+
die "State points at an unowned '$name' process; refusing to kill it."
|
|
237
|
+
if [ "$stored_fingerprint" = "$fingerprint" ]; then
|
|
238
|
+
echo "[devrouter-process] '$name' already matches this runtime (PID $pid)."
|
|
239
|
+
exit 0
|
|
240
|
+
fi
|
|
241
|
+
|
|
242
|
+
echo "[devrouter-process] '$name' runtime changed; restarting owned process group $pgid."
|
|
243
|
+
stop_process_group "$pid" "$pgid"
|
|
244
|
+
rm -f "$state_file"
|
|
245
|
+
else
|
|
246
|
+
rm -f "$state_file"
|
|
247
|
+
fi
|
|
248
|
+
fi
|
|
249
|
+
|
|
250
|
+
unknown_process_exists &&
|
|
251
|
+
die "Found an unowned '$name' process; refusing to start a duplicate or kill it."
|
|
252
|
+
|
|
253
|
+
echo "[devrouter-process] Starting '$name' (logs: $log_file)..."
|
|
254
|
+
env DEVROUTER_PROCESS_NAME="$name" \
|
|
255
|
+
DEVROUTER_PROCESS_FINGERPRINT="$fingerprint" \
|
|
256
|
+
setsid "$@" 9>&- >"$log_file" 2>&1 </dev/null &
|
|
257
|
+
pid=$!
|
|
258
|
+
pgid="$pid"
|
|
259
|
+
|
|
260
|
+
for attempt in {1..10}; do
|
|
261
|
+
if process_owned "$pid" "$pgid" "$fingerprint"; then
|
|
262
|
+
printf '%s %s %s\n' "$pid" "$pgid" "$fingerprint" >"$state_file.tmp.$BASHPID"
|
|
263
|
+
mv "$state_file.tmp.$BASHPID" "$state_file"
|
|
264
|
+
echo "[devrouter-process] '$name' started (PID $pid)."
|
|
265
|
+
exit 0
|
|
266
|
+
fi
|
|
267
|
+
if ! process_alive "$pid"; then
|
|
268
|
+
die "'$name' exited during startup; see $log_file."
|
|
269
|
+
fi
|
|
270
|
+
sleep 1
|
|
271
|
+
done
|
|
272
|
+
|
|
273
|
+
stop_process_group "$pid" "$pgid" || true
|
|
274
|
+
die "Could not verify ownership of '$name'; stopped it."
|