@devrouter/cli 0.0.28 → 0.0.30

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 CHANGED
@@ -113,6 +113,11 @@ vars. Use it when you are not (yet) on a devcontainer. Fully supported.
113
113
  The current `devrouter repo devcontainer write` scaffold is intentionally narrow:
114
114
  Node + pnpm + Postgres. Other package managers stop with a JSON diagnostic
115
115
  instead of writing files that would need manual repair.
116
+ The generated image extracts `devrouter-process` from the exact Devrouter
117
+ package tarball without installing the CLI dependency tree. Its `post-start.sh`
118
+ uses that helper for locked, owned, idempotent background startup. Application
119
+ commands and environment setup remain repository-owned; route readiness remains
120
+ part of `devrouter workspace ensure`.
116
121
  Use `devrouter repo devcontainer verify --json` for read-only PR evidence; add
117
122
  `--live --yes` only after the devcontainer is running and route probes should
118
123
  mutate local route state.
@@ -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."
package/dist/devrouter.js CHANGED
@@ -1787,7 +1787,7 @@ function loadRepoConfig(repoPath) {
1787
1787
  const config = parseConfig(parsed ?? {}, configPath);
1788
1788
  const requiredVersion = config.devrouter?.version;
1789
1789
  if (requiredVersion && !hasWarnedVersionMismatch) {
1790
- const cliVersion = true ? "0.0.28" : "0.0.0-dev";
1790
+ const cliVersion = true ? "0.0.30" : "0.0.0-dev";
1791
1791
  if (cliVersion !== "0.0.0-dev" && compareSemver(requiredVersion, cliVersion) > 0) {
1792
1792
  hasWarnedVersionMismatch = true;
1793
1793
  process.stderr.write(
@@ -4390,7 +4390,7 @@ async function buildDoctorReport(options = {}) {
4390
4390
  const config = runtimeConfig.config;
4391
4391
  loadedConfig = config;
4392
4392
  loadedWorkspace = runtimeConfig.workspace;
4393
- const cliVersion = true ? "0.0.28" : "0.0.0-dev";
4393
+ const cliVersion = true ? "0.0.30" : "0.0.0-dev";
4394
4394
  const configVersion = config.devrouter?.version;
4395
4395
  if (configVersion && cliVersion !== "0.0.0-dev" && compareSemver(configVersion, cliVersion) > 0) {
4396
4396
  addCheck(checks, {
@@ -5984,27 +5984,39 @@ function majorVersion(value, fallback) {
5984
5984
  function devrouterVersion(value) {
5985
5985
  return value && /^\d+\.\d+\.\d+$/.test(value) ? value : DEFAULT_DEVROUTER_VERSION;
5986
5986
  }
5987
- function inferDevScript(repo) {
5987
+ function escapeExtendedRegex(value) {
5988
+ return value.replace(/[\\.^$|?*+()[\]{}]/g, "\\$&");
5989
+ }
5990
+ function inferDevProcess(repo) {
5988
5991
  const script = repo.scripts.find((entry) => entry.name === "dev") ?? repo.scripts.find((entry) => entry.name.endsWith(":dev"));
5989
- if (!script) {
5990
- return "pnpm dev";
5991
- }
5992
- return script.name === "dev" ? "pnpm dev" : `pnpm run -- ${shellSingleQuote(script.name)}`;
5992
+ const scriptName = script?.name ?? "dev";
5993
+ return {
5994
+ command: scriptName === "dev" ? "pnpm dev" : `pnpm run -- ${shellSingleQuote(scriptName)}`,
5995
+ match: `pnpm(\\.cjs)? .*${escapeExtendedRegex(scriptName)}`
5996
+ };
5993
5997
  }
5994
5998
  function inferPort2(repo) {
5995
5999
  return repo.apps.find((app) => app.port)?.port ?? 3e3;
5996
6000
  }
5997
- function renderDockerfile(nodeMajor, pnpmVersion) {
6001
+ function renderDockerfile(nodeMajor, pnpmVersion, version) {
5998
6002
  const pnpmPackageSpec = `pnpm@${pnpmVersion}`;
6003
+ const devrouterPackageSpec = `@devrouter/cli@${version}`;
6004
+ const devrouterTarball = `devrouter-cli-${version}.tgz`;
5999
6005
  return `# ${MANAGED_MARKER}
6000
6006
  FROM node:${nodeMajor}-bookworm-slim
6001
6007
 
6002
6008
  RUN apt-get update \\
6003
- && apt-get install -y --no-install-recommends git ca-certificates curl procps openssl \\
6009
+ && apt-get install -y --no-install-recommends git ca-certificates curl procps openssl tar util-linux \\
6004
6010
  && rm -rf /var/lib/apt/lists/*
6005
6011
 
6006
6012
  RUN npm install -g ${shellSingleQuote(pnpmPackageSpec)}
6007
6013
 
6014
+ RUN npm pack --silent ${shellSingleQuote(devrouterPackageSpec)} \\
6015
+ && tar -xzf ${shellSingleQuote(devrouterTarball)} --strip-components=2 \\
6016
+ -C /usr/local/bin package/bin/devrouter-process \\
6017
+ && chmod +x /usr/local/bin/devrouter-process \\
6018
+ && rm ${shellSingleQuote(devrouterTarball)}
6019
+
6008
6020
  WORKDIR /workspaces/app
6009
6021
  `;
6010
6022
  }
@@ -6140,7 +6152,7 @@ fi
6140
6152
  function shellSingleQuote(value) {
6141
6153
  return `'${value.replace(/'/g, `'"'"'`)}'`;
6142
6154
  }
6143
- function renderPostStart(devCommand) {
6155
+ function renderPostStart(devProcess) {
6144
6156
  return `#!/usr/bin/env bash
6145
6157
  # ${MANAGED_MARKER}
6146
6158
  set -euo pipefail
@@ -6151,11 +6163,11 @@ set -a
6151
6163
  . .devcontainer/devcontainer.env
6152
6164
  set +a
6153
6165
 
6154
- if pgrep -f ${shellSingleQuote(devCommand)} >/dev/null 2>&1; then
6155
- exit 0
6156
- fi
6157
-
6158
- setsid bash -lc ${shellSingleQuote(devCommand)} >/tmp/devrouter-app.log 2>&1 </dev/null &
6166
+ devrouter-process ensure \\
6167
+ --name app \\
6168
+ --match ${shellSingleQuote(devProcess.match)} \\
6169
+ --log /tmp/devrouter-app.log \\
6170
+ -- bash -lc ${shellSingleQuote(devProcess.command)}
6159
6171
  `;
6160
6172
  }
6161
6173
  function renderDevrouter(projectName, port, version) {
@@ -6272,7 +6284,7 @@ function plannedFiles(repoPath, version) {
6272
6284
  const nodeMajor = majorVersion(repo.node?.version, "24");
6273
6285
  const pnpmVersion = repo.packageManager?.name === "pnpm" && repo.packageManager.version ? repo.packageManager.version : DEFAULT_PNPM_VERSION;
6274
6286
  const port = inferPort2(repo);
6275
- const devCommand = inferDevScript(repo);
6287
+ const devProcess = inferDevProcess(repo);
6276
6288
  const issues = packageManagerIssues(repo);
6277
6289
  return {
6278
6290
  projectName,
@@ -6280,7 +6292,7 @@ function plannedFiles(repoPath, version) {
6280
6292
  files: [
6281
6293
  {
6282
6294
  relativePath: ".devcontainer/Dockerfile",
6283
- content: renderDockerfile(nodeMajor, pnpmVersion)
6295
+ content: renderDockerfile(nodeMajor, pnpmVersion, version)
6284
6296
  },
6285
6297
  { relativePath: ".devcontainer/docker-compose.yml", content: renderCompose(projectName) },
6286
6298
  {
@@ -6304,7 +6316,7 @@ function plannedFiles(repoPath, version) {
6304
6316
  },
6305
6317
  {
6306
6318
  relativePath: ".devcontainer/post-start.sh",
6307
- content: renderPostStart(devCommand),
6319
+ content: renderPostStart(devProcess),
6308
6320
  executable: true
6309
6321
  },
6310
6322
  { relativePath: ".devcontainer/README.md", content: renderReadme(projectName) },
@@ -8189,6 +8201,7 @@ async function workspaceEnsure(requestedRepoPath, options = {}) {
8189
8201
  await waitForContainerPreflight(repoPath, commonDir, workspace, upstreamHosts, 0);
8190
8202
  } catch {
8191
8203
  await recreateAndWait();
8204
+ recreated = true;
8192
8205
  }
8193
8206
  }
8194
8207
  ensureRouterFiles();
@@ -8205,8 +8218,19 @@ async function workspaceEnsure(requestedRepoPath, options = {}) {
8205
8218
  }
8206
8219
  }
8207
8220
  startRouterStack();
8208
- replaceHostRoutesForRepo(repoPath, routeInputs(repoPath, workspace, apps));
8209
- await waitForHttpRoutes(apps, options.httpTimeoutMs ?? DEFAULT_READINESS_TIMEOUT_MS);
8221
+ const routes = routeInputs(repoPath, workspace, apps);
8222
+ replaceHostRoutesForRepo(repoPath, routes);
8223
+ try {
8224
+ await waitForHttpRoutes(apps, options.httpTimeoutMs ?? DEFAULT_READINESS_TIMEOUT_MS);
8225
+ } catch (error) {
8226
+ replaceHostRoutesForRepo(repoPath, []);
8227
+ if (!hadExactDevpod || recreated) {
8228
+ throw error;
8229
+ }
8230
+ await recreateAndWait();
8231
+ replaceHostRoutesForRepo(repoPath, routes);
8232
+ await waitForHttpRoutes(apps, options.httpTimeoutMs ?? DEFAULT_READINESS_TIMEOUT_MS);
8233
+ }
8210
8234
  const urls = apps.map(
8211
8235
  (app) => app.protocol === "tcp" ? `${app.tcpProtocol}://${app.host}:${String(TCP_PROTOCOL_REGISTRY[app.tcpProtocol].port)}` : routeUrl3(app.host)
8212
8236
  );
@@ -8553,7 +8577,7 @@ var init_version = __esm({
8553
8577
 
8554
8578
  // src/cli.ts
8555
8579
  var import_commander = require("commander");
8556
- var CLI_VERSION = true ? "0.0.28" : "0.0.0-dev";
8580
+ var CLI_VERSION = true ? "0.0.30" : "0.0.0-dev";
8557
8581
  var VERSION_FLAGS = /* @__PURE__ */ new Set(["-V", "--version"]);
8558
8582
  function withErrorHandling(action2) {
8559
8583
  return async (...args) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@devrouter/cli",
3
- "version": "0.0.28",
3
+ "version": "0.0.30",
4
4
  "description": "Local dev routing CLI with shared Traefik reverse proxy",
5
5
  "author": "Roland Schlaefli",
6
6
  "homepage": "https://github.com/rschlaefli/devrouter#readme",
@@ -10,6 +10,7 @@
10
10
  "url": "https://github.com/rschlaefli/devrouter.git"
11
11
  },
12
12
  "files": [
13
+ "bin",
13
14
  "dist",
14
15
  "upgrade-prompts"
15
16
  ],
@@ -18,7 +19,8 @@
18
19
  "node": ">=24"
19
20
  },
20
21
  "bin": {
21
- "devrouter": "dist/devrouter.js"
22
+ "devrouter": "dist/devrouter.js",
23
+ "devrouter-process": "bin/devrouter-process"
22
24
  },
23
25
  "dependencies": {
24
26
  "commander": "^12.1.0",
@@ -42,7 +44,8 @@
42
44
  "scripts": {
43
45
  "build": "tsup",
44
46
  "dev": "tsx src/cli.ts",
45
- "test": "vitest run",
47
+ "test": "vitest run && pnpm test:process",
48
+ "test:process": "bash scripts/test-devrouter-process.sh",
46
49
  "check": "biome check .",
47
50
  "check:fix": "biome check --write .",
48
51
  "check:docs-policy": "./scripts/check-docs-policy.sh",
@@ -0,0 +1,12 @@
1
+ # Upgrade to devrouter 0.0.29
2
+
3
+ ## What changed
4
+
5
+ `workspace ensure` now uses the one-total-recreate budget for HTTP readiness failures on existing exact workspaces. Routes are removed before recovery, verified after recreation, and removed again if the retry fails. No new CLI flags, config schema, or dependencies.
6
+
7
+ ## Steps
8
+
9
+ 1. Open `.devrouter.yml` and bump `devrouter.version` from `0.0.28` to `0.0.29`.
10
+ 2. Run `devrouter -V --repo <repo>` and confirm the reported version is `0.0.29`.
11
+ 3. From an existing linked worktree, run `devrouter workspace ensure .`.
12
+ 4. Report the route results.
@@ -0,0 +1,13 @@
1
+ # Upgrade to devrouter 0.0.30
2
+
3
+ ## What changed
4
+
5
+ Devrouter now ships `devrouter-process ensure`, a Linux devcontainer helper for locked, fingerprinted, process-group-owned application startup. Generated devcontainers extract only this helper from the exact Devrouter package tarball instead of installing the CLI dependency tree or carrying their own PID, locking, and restart implementation. HTTP readiness and the single container-recreate budget remain in `devrouter workspace ensure`.
6
+
7
+ ## Steps
8
+
9
+ 1. Install `@devrouter/cli@0.0.30` on the host and bump `.devrouter.yml` to `devrouter.version: 0.0.30`.
10
+ 2. If the repository uses the managed scaffold, run `devrouter repo devcontainer write --dry-run --json`, review the plan, then run `devrouter repo devcontainer write --yes`.
11
+ 3. For a custom devcontainer, extract `bin/devrouter-process` from the exact package tarball into its image and replace repository-specific background-process lifecycle code with one `devrouter-process ensure` call. Keep only the application's command and environment setup in the repository.
12
+ 4. Rebuild or recreate the devcontainer so the helper is installed in the image.
13
+ 5. From a linked worktree, run `devrouter workspace ensure .` twice and confirm the second run reuses both the container and managed process.