@zendero/runctl 0.1.1 → 0.1.3

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
@@ -10,22 +10,57 @@ Picks a **free port**, runs your **dev server in the background**, and keeps **P
10
10
 
11
11
  ## Install
12
12
 
13
- **In a project (recommended):**
13
+ The published package on npm is **`@zendero/runctl`**. The CLI binary on your PATH is still **`runctl`**.
14
+
15
+ **From the npm registry (recommended):**
14
16
 
15
17
  ```bash
16
- pnpm add -D runctl # or npm install -D / yarn add -D
18
+ pnpm add -D @zendero/runctl # or npm install -D / yarn add -D
17
19
  ```
18
20
 
19
21
  **Global CLI** (`runctl` on your PATH everywhere):
20
22
 
21
23
  ```bash
22
- pnpm add -g runctl # or npm install -g
24
+ pnpm add -g @zendero/runctl # or npm install -g
25
+ ```
26
+
27
+ **Global install via curl** uses a single script: [`scripts/install-global.sh`](scripts/install-global.sh)
28
+
29
+ ```bash
30
+ curl -fsSL "https://raw.githubusercontent.com/DoctorKhan/runctl/main/scripts/install-global.sh" |
31
+ bash
32
+ ```
33
+
34
+ With no arguments, `install-global.sh` prompts on a TTY; otherwise it defaults to registry install with Git fallback. Pass arguments to force a mode:
35
+
36
+ ```bash
37
+ curl -fsSL "https://raw.githubusercontent.com/DoctorKhan/runctl/main/scripts/install-global.sh" |
38
+ bash -s -- --registry
23
39
  ```
24
40
 
25
- **One-liner (from GitHub):**
41
+ ```bash
42
+ curl -fsSL "https://raw.githubusercontent.com/DoctorKhan/runctl/main/scripts/install-global.sh" |
43
+ bash -s -- --auto
44
+ ```
26
45
 
27
46
  ```bash
28
- curl -fsSL https://raw.githubusercontent.com/DoctorKhan/runctl/main/scripts/install-global.sh | bash
47
+ curl -fsSL "https://raw.githubusercontent.com/DoctorKhan/runctl/main/scripts/install-global.sh" |
48
+ bash -s -- --git --ref main
49
+ ```
50
+
51
+ Optional flags: `--pm pnpm|npm`, `--ref <git-ref>`. Optional env: `RUNCTL_PACKAGE`, `RUNCTL_GIT_BASE`, `RUNCTL_GIT_REF`.
52
+
53
+ **Without curl:**
54
+
55
+ ```bash
56
+ pnpm add -g @zendero/runctl
57
+ pnpm add -g "github:DoctorKhan/runctl#main"
58
+ ```
59
+
60
+ **Project dependency from GitHub** (not global): dependency resolves to **`@zendero/runctl`**. Reinstall to pull the latest `main`:
61
+
62
+ ```bash
63
+ pnpm add -D "github:DoctorKhan/runctl#main"
29
64
  ```
30
65
 
31
66
  ---
@@ -57,14 +92,16 @@ Add scripts to your `package.json`:
57
92
 
58
93
  | Command | What it does |
59
94
  |---------|-------------|
60
- | `runctl start [dir] [--script name]` | Start dev server (picks free port, backgrounds) |
95
+ | `runctl start` \| `runctl dev` | Start dev server (same command; picks free port, backgrounds) |
61
96
  | `runctl stop [dir]` | Stop daemons & release ports |
62
97
  | `runctl status [dir]` | Show `.run` state for this package |
98
+ | `runctl logs [dir] [service]` | Tail `.run/logs/<service>.log` (default service: `web`) |
63
99
  | `runctl ports` | List user-wide port registry (`~/.run`) |
64
100
  | `runctl ports gc` | Clean up stale port claims |
65
101
  | `runctl env expand <manifest> [--out file]` | Generate `.env.local` from manifest |
66
- | `runctl update` | Update to latest version |
67
- | `runctl version` | Print install location |
102
+ | `runctl doctor [dir]` | Check Node 18+, `lsof`, package manager, `package.json` |
103
+ | `runctl update` | Update the global `@zendero/runctl` install |
104
+ | `runctl version` | Print package version and install path |
68
105
 
69
106
  **Monorepo:** `runctl start ./apps/web --script dev:server`
70
107
 
@@ -89,4 +126,14 @@ Add scripts to your `package.json`:
89
126
 
90
127
  [`examples/consumer-package.json`](examples/consumer-package.json) · [`docs/vercel-and-env.md`](docs/vercel-and-env.md) · [`examples/env.manifest.example`](examples/env.manifest.example)
91
128
 
92
- **Develop this repo:** `pnpm install` → `./run.sh ports`
129
+ **Develop this repo:** `pnpm install` → `./run.sh` (default **doctor**, like `elata-bio-sdk/run.sh`) → `./run.sh ports`
130
+
131
+ **Publish (maintainers)** — workflow similar to elata’s release preflight, scaled for one package:
132
+
133
+ | Step | Command |
134
+ |------|--------|
135
+ | Preflight | `./run.sh release-check` or `pnpm run release-check` |
136
+ | Publish | `./run.sh release latest` or `pnpm run release` |
137
+ | Promote dist-tag | After publishing under `next`, `./run.sh promote` sets **latest** for the version in `package.json` |
138
+
139
+ Put `NPM_TOKEN` in `.env`. `release` / `npm-whoami` use a **temporary `NPM_CONFIG_USERCONFIG`** so a stale `~/.npmrc` token does not override `.env` (npm 10+ / pnpm). Token lines can use `NPM_TOKEN=` or `npm_token=`; quoted values are supported without `source`-ing secrets as shell code first.
package/bin/runctl CHANGED
@@ -16,13 +16,15 @@ ${_b}runctl${_r} — dev-server orchestrator & port manager
16
16
  ${_y}Usage:${_r} runctl <command> [options]
17
17
 
18
18
  ${_y}Commands${_r}
19
- ${_c}start${_r} [dir] [--script name] Start dev server ${_d}(picks free port, backgrounds)${_r}
19
+ ${_c}start${_r} | ${_c}dev${_r} [dir] [--script name] Start dev server ${_d}(alias: dev = start)${_r}
20
20
  ${_c}open${_r} [dir] Open running dev server in browser
21
21
  ${_c}stop${_r} [dir] Stop daemons & release ports
22
22
  ${_c}status${_r} [dir] Show running state
23
+ ${_c}logs${_r} [dir] [service] Tail .run/logs/<service>.log ${_d}(default service: web)${_r}
23
24
  ${_c}ports${_r} List user-wide port registry (~/.run)
24
25
  ${_c}ports gc${_r} Clean up stale port claims
25
26
  ${_c}env expand${_r} <manifest> [opts] Generate .env.local from manifest
27
+ ${_c}doctor${_r} Check Node, tooling, and project basics
26
28
  ${_c}update${_r} Update runctl to latest version
27
29
 
28
30
  ${_y}Options${_r}
@@ -80,12 +82,20 @@ cmd_start() {
80
82
  cmd_open() {
81
83
  local proj
82
84
  proj="$(_resolve_proj "$@")"
85
+ # shellcheck source=../lib/run-lib.sh
86
+ source "$RUNCTL_PKG_ROOT/lib/run-lib.sh"
87
+ run_project_init "$proj"
83
88
  local ports_env="$proj/.run/ports.env"
84
89
  if [[ ! -f "$ports_env" ]]; then
85
90
  echo "runctl open: no running server found (.run/ports.env missing)" >&2
86
91
  echo "Start one first: runctl start" >&2
87
92
  exit 1
88
93
  fi
94
+ if ! run_local_has_live_service; then
95
+ echo "runctl open: no running server found (saved port is stale)" >&2
96
+ echo "Start one first: runctl start" >&2
97
+ exit 1
98
+ fi
89
99
  local port="" host=""
90
100
  while IFS= read -r line; do
91
101
  case "$line" in
@@ -165,32 +175,123 @@ cmd_env() {
165
175
  }
166
176
 
167
177
  cmd_update() {
168
- echo "runctl update: installing latest runctl..."
178
+ echo "runctl update: installing latest @zendero/runctl..."
169
179
  if command -v pnpm >/dev/null 2>&1; then
170
- pnpm add -g runctl@latest
180
+ pnpm add -g @zendero/runctl@latest
171
181
  elif command -v npm >/dev/null 2>&1; then
172
- npm install -g runctl@latest
182
+ npm install -g @zendero/runctl@latest
173
183
  else
174
184
  echo "runctl: pnpm or npm required to update." >&2
175
185
  exit 1
176
186
  fi
177
187
  }
178
188
 
189
+ cmd_doctor() {
190
+ local proj
191
+ proj="$(pwd)"
192
+ [[ $# -ge 1 && -d "$1" ]] && proj="$(cd "$1" && pwd)"
193
+ echo "runctl doctor"
194
+ local ec=0
195
+ if command -v node >/dev/null 2>&1 && node -e 'process.exit(parseInt(process.versions.node,10)>=18?0:1)' 2>/dev/null; then
196
+ printf ' %-12s %s\n' "node:" "$(node -v) (ok)"
197
+ else
198
+ printf ' %-12s %s\n' "node:" "missing or < 18 (required)" >&2
199
+ ec=1
200
+ fi
201
+ if command -v lsof >/dev/null 2>&1; then
202
+ printf ' %-12s %s\n' "lsof:" "present (port detection / gc)"
203
+ else
204
+ printf ' %-12s %s\n' "lsof:" "missing — install for free-port + ports gc" >&2
205
+ fi
206
+ local pm=""
207
+ if command -v pnpm >/dev/null 2>&1; then pm="pnpm $(pnpm -v 2>/dev/null || echo '')"
208
+ elif command -v npm >/dev/null 2>&1; then pm="npm $(npm -v 2>/dev/null || echo '')"
209
+ elif command -v bun >/dev/null 2>&1; then pm="bun $(bun -v 2>/dev/null || echo '')"
210
+ elif command -v yarn >/dev/null 2>&1; then pm="yarn $(yarn -v 2>/dev/null || echo '')"
211
+ else
212
+ pm="none found"
213
+ ec=1
214
+ fi
215
+ printf ' %-12s %s\n' "package mgr:" "$pm"
216
+ if [[ -f "$proj/package.json" ]]; then
217
+ printf ' %-12s %s\n' "package.json:" "$proj (ok)"
218
+ else
219
+ printf ' %-12s %s\n' "package.json:" "not found in $proj" >&2
220
+ ec=1
221
+ fi
222
+ return "$ec"
223
+ }
224
+
225
+ cmd_logs() {
226
+ local proj lines="${RUNCTL_LOG_LINES:-80}"
227
+ proj="$(pwd)"
228
+ local -a pos=()
229
+ while [[ $# -gt 0 ]]; do
230
+ case "$1" in
231
+ -n | --lines)
232
+ lines="${2:-80}"
233
+ shift 2
234
+ ;;
235
+ -h | --help)
236
+ echo "Usage: runctl logs [dir] [service] (default service: web)" >&2
237
+ echo " runctl logs [--lines N]" >&2
238
+ exit 0
239
+ ;;
240
+ *)
241
+ pos+=("$1")
242
+ shift
243
+ ;;
244
+ esac
245
+ done
246
+ if [[ ${#pos[@]} -ge 1 && -d "${pos[0]}" ]]; then
247
+ proj="$(cd "${pos[0]}" && pwd)"
248
+ pos=("${pos[@]:1}")
249
+ fi
250
+ local svc="${pos[0]:-web}"
251
+ # shellcheck source=../lib/run-lib.sh
252
+ source "$RUNCTL_PKG_ROOT/lib/run-lib.sh"
253
+ run_project_init "$proj"
254
+ local logf="$RUN_LOCAL_STATE/logs/${svc}.log"
255
+ if [[ ! -f "$logf" ]]; then
256
+ echo "runctl logs: no file at $logf" >&2
257
+ if [[ -d "$RUN_LOCAL_STATE/logs" ]]; then
258
+ echo "Available logs:" >&2
259
+ ls -1 "$RUN_LOCAL_STATE/logs" 2>/dev/null | sed 's/^/ /' >&2 || true
260
+ fi
261
+ exit 1
262
+ fi
263
+ echo "runctl logs: $logf (last $lines lines)"
264
+ tail -n "$lines" "$logf"
265
+ }
266
+
267
+ cmd_version_print() {
268
+ local ver="?"
269
+ if command -v node >/dev/null 2>&1; then
270
+ ver="$(
271
+ node -e 'const fs=require("fs"); console.log(JSON.parse(fs.readFileSync(process.argv[1],"utf8")).version);' \
272
+ "$RUNCTL_PKG_ROOT/package.json" 2>/dev/null || echo "?"
273
+ )"
274
+ fi
275
+ printf '%s\n' "runctl $ver — $RUNCTL_PKG_ROOT"
276
+ }
277
+
179
278
  main() {
180
279
  local cmd="${1:-help}"
181
280
  shift || true
182
281
  case "$cmd" in
183
- start) cmd_start "$@" ;;
282
+ start | dev) cmd_start "$@" ;;
184
283
  open) cmd_open "$@" ;;
185
284
  stop) cmd_stop "$@" ;;
186
285
  status) cmd_status "$@" ;;
286
+ logs) cmd_logs "$@" ;;
187
287
  ports) cmd_ports "$@" ;;
188
288
  env) cmd_env "$@" ;;
289
+ doctor) cmd_doctor "$@" ;;
189
290
  update) cmd_update ;;
190
291
 
191
292
  # Plumbing
192
293
  lib-path) printf '%s\n' "$RUNCTL_PKG_ROOT/lib/run-lib.sh" ;;
193
- version | -v) printf '%s\n' "runctl @ $RUNCTL_PKG_ROOT" ;;
294
+ version | -v) cmd_version_print ;;
194
295
  help | -h | --help) usage ;;
195
296
 
196
297
  # Hidden backward-compat aliases
@@ -12,8 +12,8 @@ Keep a **manifest** (not loaded directly by Next/Vite) that lists each real valu
12
12
  - Expand to a generated file that frameworks **do** load:
13
13
 
14
14
  ```bash
15
- pnpm exec runctl expand-env env.manifest --out .env.local
16
- # or: node node_modules/runctl/scripts/expand-env-manifest.mjs env.manifest --out .env.local
15
+ pnpm exec runctl env expand env.manifest --out .env.local
16
+ # or: node node_modules/@zendero/runctl/scripts/expand-env-manifest.mjs env.manifest --out .env.local
17
17
  ```
18
18
 
19
19
  Add **`.env.local`** (and optionally `.env.manifest` if it contains no secrets—usually it *does*, so keep the manifest **gitignored** or use a **`.env.manifest.example`** without real values).
@@ -4,6 +4,8 @@
4
4
  "dev:server": "next dev",
5
5
  "dev:stop": "runctl stop",
6
6
  "dev:status": "runctl status",
7
+ "dev:logs": "runctl logs",
8
+ "doctor": "runctl doctor",
7
9
  "ports": "runctl ports",
8
10
  "ports:gc": "runctl ports gc",
9
11
  "env:expand": "runctl env expand env.manifest --out .env.local"
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env bash
2
2
  # Optional shell entrypoint — most projects only need package.json scripts.
3
- # Prerequisite: pnpm add -D runctl (Node >= 18)
3
+ # Prerequisite: pnpm add -D @zendero/runctl (Node >= 18)
4
4
 
5
5
  set -euo pipefail
6
6
 
@@ -8,7 +8,7 @@ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
8
8
  cd "$ROOT"
9
9
 
10
10
  if [[ -z "${RUN_LIB:-}" ]]; then
11
- _rp="${RUNCTL_PACKAGE:-runctl}"
11
+ _rp="${RUNCTL_PACKAGE:-@zendero/runctl}"
12
12
  RUN_LIB="$(
13
13
  cd "$ROOT" && RUNCTL_PACKAGE="$_rp" node -e "
14
14
  const p = require('path');
package/lib/run-lib.sh CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env bash
2
- # run-lib.sh — npm package `runctl`: project .run/ + user ~/.run registry.
2
+ # run-lib.sh — npm package `@zendero/runctl`: project .run/ + user ~/.run registry.
3
3
  # Requires bash. Port/dev automation requires Node.js >= 18 (see package.json engines).
4
4
  # Intentionally no global `set -e` — this file is usually sourced.
5
5
 
@@ -90,6 +90,28 @@ run_pid_alive() {
90
90
  kill -0 "$1" 2>/dev/null
91
91
  }
92
92
 
93
+ run_pid_listens_on_port() {
94
+ local pid="$1"
95
+ local port="$2"
96
+ [[ -n "$pid" && -n "$port" ]] || return 1
97
+ command -v lsof >/dev/null 2>&1 || return 1
98
+ lsof -iTCP:"$port" -sTCP:LISTEN -n -P 2>/dev/null | awk 'NR>1 {print $2}' | sort -u | grep -qx "$pid"
99
+ }
100
+
101
+ run_local_has_live_service() {
102
+ local f pid
103
+ shopt -s nullglob
104
+ for f in "$RUN_LOCAL_STATE/pids"/*.pid; do
105
+ pid="$(cat "$f" 2>/dev/null || true)"
106
+ if [[ -n "$pid" ]] && run_pid_alive "$pid"; then
107
+ shopt -u nullglob
108
+ return 0
109
+ fi
110
+ done
111
+ shopt -u nullglob
112
+ return 1
113
+ }
114
+
93
115
  run_port_listening() {
94
116
  local port="$1"
95
117
  command -v lsof >/dev/null 2>&1 || return 1
@@ -297,13 +319,31 @@ run_daemon_start() {
297
319
  local logf="$RUN_LOCAL_STATE/logs/${name}.log"
298
320
  if [[ -f "$pidf" ]]; then
299
321
  local oldpid
322
+ local port=""
323
+ local port_service=""
300
324
  oldpid="$(cat "$pidf")"
325
+ if [[ -f "$RUN_LOCAL_STATE/ports.env" ]]; then
326
+ while IFS='=' read -r k v; do
327
+ case "$k" in
328
+ PORT) port="$v" ;;
329
+ RUN_DEV_SERVICE) port_service="$v" ;;
330
+ esac
331
+ done <"$RUN_LOCAL_STATE/ports.env"
332
+ fi
301
333
  if run_pid_alive "$oldpid"; then
302
- echo "run_daemon_start: ${name} already running (pid $oldpid)" >&2
303
- return 1
334
+ if [[ -n "$port" ]] && [[ -z "$port_service" || "$port_service" == "$name" ]] && run_pid_listens_on_port "$oldpid" "$port"; then
335
+ echo "run_daemon_start: ${name} already running (pid $oldpid)" >&2
336
+ return 1
337
+ fi
338
+ if [[ -z "$port" ]]; then
339
+ echo "run_daemon_start: ${name} already running (pid $oldpid)" >&2
340
+ return 1
341
+ fi
342
+ echo "run_daemon_start: cleared stale pid for ${name} (pid $oldpid not listening on port $port)" >&2
343
+ else
344
+ echo "run_daemon_start: cleared stale pid for ${name} (was $oldpid)" >&2
304
345
  fi
305
346
  rm -f "$pidf"
306
- echo "run_daemon_start: cleared stale pid for ${name} (was $oldpid)" >&2
307
347
  fi
308
348
  nohup "$@" >>"$logf" 2>&1 &
309
349
  local pid=$!
@@ -414,7 +454,7 @@ run_global_gc() {
414
454
  stale=1
415
455
  fi
416
456
  if [[ "$stale" -eq 0 ]] && [[ -n "$pid" ]] && command -v lsof >/dev/null 2>&1; then
417
- if ! lsof -iTCP:"$port" -sTCP:LISTEN -n -P 2>/dev/null | awk 'NR>1 {print $2}' | sort -u | grep -qx "$pid"; then
457
+ if ! run_pid_listens_on_port "$pid" "$port"; then
418
458
  stale=1
419
459
  fi
420
460
  fi
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zendero/runctl",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Picks a free port, runs your dev server in the background, and keeps PID + port state in .run/ so projects don't collide.",
5
5
  "author": "DoctorKhan",
6
6
  "homepage": "https://github.com/DoctorKhan/runctl#readme",
@@ -37,6 +37,10 @@
37
37
  ],
38
38
  "scripts": {
39
39
  "run": "bash ./run.sh",
40
+ "doctor": "bash ./run.sh doctor",
41
+ "release-check": "bash ./run.sh release-check",
42
+ "promote": "bash ./run.sh promote",
43
+ "release": "bash ./run.sh release latest",
40
44
  "env:expand": "node ./scripts/expand-env-manifest.mjs",
41
45
  "link-global": "pnpm link --global"
42
46
  }
@@ -1,13 +1,248 @@
1
- #!/usr/bin/env sh
2
- # Install runctl globally (needs Node 18+ and npm or pnpm).
1
+ #!/usr/bin/env bash
2
+ # Global installer for @zendero/runctl.
3
+ #
3
4
  # curl -fsSL https://raw.githubusercontent.com/DoctorKhan/runctl/main/scripts/install-global.sh | bash
4
- # Registry: RUNCTL_PACKAGE=runctl curl -fsSL … | bash
5
- # Scoped: RUNCTL_PACKAGE=@your-org/runctl curl -fsSL … | bash
6
- set -eu
7
- PKG="${RUNCTL_PACKAGE:-runctl}"
8
- if command -v pnpm >/dev/null 2>&1; then
9
- pnpm add -g "$PKG"
10
- else
11
- npm install -g "$PKG"
12
- fi
13
- printf '\nInstalled %s — run: runctl help\n' "$PKG"
5
+ #
6
+ # Modes:
7
+ # --interactive prompt for package manager + install source
8
+ # --registry install from npm only
9
+ # --git install from Git only
10
+ # --auto install from npm, then fall back to Git
11
+ #
12
+ # Flags:
13
+ # --pm <pnpm|npm>
14
+ # --ref <git-ref>
15
+ # --help
16
+ #
17
+ # Optional env:
18
+ # RUNCTL_PACKAGE npm package name (default: @zendero/runctl)
19
+ # RUNCTL_GIT_BASE git URL without ref (default: git+https://github.com/DoctorKhan/runctl.git)
20
+ # RUNCTL_GIT_REF git ref for Git installs (default: main)
21
+ set -euo pipefail
22
+
23
+ PKG="${RUNCTL_PACKAGE:-@zendero/runctl}"
24
+ GIT_BASE="${RUNCTL_GIT_BASE:-git+https://github.com/DoctorKhan/runctl.git}"
25
+ DEFAULT_GIT_REF="${RUNCTL_GIT_REF:-main}"
26
+
27
+ MODE=""
28
+ PM=""
29
+ GIT_REF="$DEFAULT_GIT_REF"
30
+
31
+ say() {
32
+ printf '%s\n' "$*"
33
+ }
34
+
35
+ usage() {
36
+ cat <<'EOF'
37
+ Usage: install-global.sh [mode] [flags]
38
+
39
+ Modes:
40
+ --interactive Prompt for install options when a TTY is available.
41
+ --registry Install from the npm registry only.
42
+ --git Install from Git only.
43
+ --auto Install from the npm registry, then fall back to Git.
44
+
45
+ Flags:
46
+ --pm <pnpm|npm> Force a package manager.
47
+ --ref <git-ref> Git ref to use with --git or --auto fallback.
48
+ --help Show this help.
49
+
50
+ Default behavior:
51
+ If no mode is provided, use --interactive when a TTY is available.
52
+ Otherwise default to --auto.
53
+ EOF
54
+ }
55
+
56
+ has_tty() {
57
+ [[ -t 1 ]] && [[ -r /dev/tty ]]
58
+ }
59
+
60
+ prompt_choice() {
61
+ local prompt="$1"
62
+ local default_value="$2"
63
+ shift 2
64
+ local options=("$@")
65
+ local answer
66
+
67
+ while true; do
68
+ printf '%s ' "$prompt" > /dev/tty
69
+ IFS= read -r answer < /dev/tty || answer=""
70
+ answer="${answer:-$default_value}"
71
+
72
+ for option in "${options[@]}"; do
73
+ if [[ "$answer" == "$option" ]]; then
74
+ printf '%s' "$answer"
75
+ return 0
76
+ fi
77
+ done
78
+
79
+ say "Please choose one of: ${options[*]}" > /dev/tty
80
+ done
81
+ }
82
+
83
+ prompt_text() {
84
+ local prompt="$1"
85
+ local default_value="$2"
86
+ local answer
87
+
88
+ printf '%s ' "$prompt" > /dev/tty
89
+ IFS= read -r answer < /dev/tty || answer=""
90
+ printf '%s' "${answer:-$default_value}"
91
+ }
92
+
93
+ pick_package_manager() {
94
+ if [[ -n "$PM" ]]; then
95
+ if [[ "$PM" != "pnpm" && "$PM" != "npm" ]]; then
96
+ say "runctl: unsupported package manager: $PM" >&2
97
+ exit 1
98
+ fi
99
+ if ! command -v "$PM" >/dev/null 2>&1; then
100
+ say "runctl: requested package manager not found on PATH: $PM" >&2
101
+ exit 1
102
+ fi
103
+ printf '%s' "$PM"
104
+ return 0
105
+ fi
106
+
107
+ if command -v pnpm >/dev/null 2>&1 && command -v npm >/dev/null 2>&1; then
108
+ if [[ "$MODE" == "interactive" ]]; then
109
+ prompt_choice "Package manager? [pnpm/npm] (default: pnpm)" "pnpm" "pnpm" "npm"
110
+ else
111
+ printf 'pnpm'
112
+ fi
113
+ return 0
114
+ fi
115
+
116
+ if command -v pnpm >/dev/null 2>&1; then
117
+ printf 'pnpm'
118
+ return 0
119
+ fi
120
+
121
+ if command -v npm >/dev/null 2>&1; then
122
+ printf 'npm'
123
+ return 0
124
+ fi
125
+
126
+ say "runctl: neither pnpm nor npm was found on PATH." >&2
127
+ exit 1
128
+ }
129
+
130
+ run_install() {
131
+ local manager="$1"
132
+ local source_kind="$2"
133
+ local target="$3"
134
+
135
+ if [[ "$manager" == "pnpm" ]]; then
136
+ pnpm add -g "$target"
137
+ else
138
+ npm install -g "$target"
139
+ fi
140
+
141
+ say
142
+ if [[ "$source_kind" == "git" ]]; then
143
+ say "Installed @zendero/runctl from Git — run: runctl help"
144
+ else
145
+ say "Installed $PKG — run: runctl help"
146
+ fi
147
+ }
148
+
149
+ fallback_install() {
150
+ local manager="$1"
151
+ local git_target="$2"
152
+
153
+ say "runctl: registry install failed; trying Git ($git_target)..." >&2
154
+ run_install "$manager" "git" "$git_target"
155
+ }
156
+
157
+ parse_args() {
158
+ while [[ $# -gt 0 ]]; do
159
+ case "$1" in
160
+ --interactive)
161
+ MODE="interactive"
162
+ ;;
163
+ --registry)
164
+ MODE="registry"
165
+ ;;
166
+ --git)
167
+ MODE="git"
168
+ ;;
169
+ --auto)
170
+ MODE="auto"
171
+ ;;
172
+ --pm)
173
+ [[ $# -ge 2 ]] || { say "runctl: --pm requires a value" >&2; exit 1; }
174
+ PM="$2"
175
+ shift
176
+ ;;
177
+ --ref)
178
+ [[ $# -ge 2 ]] || { say "runctl: --ref requires a value" >&2; exit 1; }
179
+ GIT_REF="$2"
180
+ shift
181
+ ;;
182
+ --help|-h)
183
+ usage
184
+ exit 0
185
+ ;;
186
+ *)
187
+ say "runctl: unknown argument: $1" >&2
188
+ usage >&2
189
+ exit 1
190
+ ;;
191
+ esac
192
+ shift
193
+ done
194
+ }
195
+
196
+ main() {
197
+ local manager git_target
198
+
199
+ parse_args "$@"
200
+
201
+ if [[ -z "$MODE" ]]; then
202
+ if has_tty && [[ "${CI:-}" != "1" ]]; then
203
+ MODE="interactive"
204
+ else
205
+ MODE="auto"
206
+ fi
207
+ fi
208
+
209
+ manager="$(pick_package_manager)"
210
+ git_target="${GIT_BASE}#${GIT_REF}"
211
+
212
+ if [[ "$MODE" == "interactive" ]] && (! has_tty || [[ "${CI:-}" == "1" ]]); then
213
+ say "runctl: no interactive TTY detected; defaulting to --auto." >&2
214
+ MODE="auto"
215
+ fi
216
+
217
+ case "$MODE" in
218
+ interactive)
219
+ say "runctl interactive installer"
220
+ say
221
+ say "This will install the global \`runctl\` CLI."
222
+ say
223
+ MODE="$(prompt_choice "Install source? [registry/git/auto] (default: auto)" "auto" "registry" "git" "auto")"
224
+ if [[ "$MODE" == "git" || "$MODE" == "auto" ]]; then
225
+ GIT_REF="$(prompt_text "Git ref to use? (default: ${GIT_REF})" "$GIT_REF")"
226
+ git_target="${GIT_BASE}#${GIT_REF}"
227
+ fi
228
+ ;;
229
+ esac
230
+
231
+ case "$MODE" in
232
+ registry)
233
+ run_install "$manager" "registry" "$PKG"
234
+ ;;
235
+ git)
236
+ say "runctl: installing from Git ($git_target)..." >&2
237
+ run_install "$manager" "git" "$git_target"
238
+ ;;
239
+ auto)
240
+ if run_install "$manager" "registry" "$PKG"; then
241
+ return 0
242
+ fi
243
+ fallback_install "$manager" "$git_target"
244
+ ;;
245
+ esac
246
+ }
247
+
248
+ main "$@"