@zendero/runctl 0.1.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/README.md ADDED
@@ -0,0 +1,92 @@
1
+ # Runctl
2
+
3
+ Picks a **free port**, runs your **dev server in the background**, and keeps **PID + port** state in **`.run/`** and **`~/.run`** so projects don't collide.
4
+
5
+ **Needs Node.js 18+**, **bash**, and **`lsof`** (for free-port detection and `gc`; common on macOS, often `apt install lsof` on Linux).
6
+
7
+ **Platforms:** macOS / Linux / **WSL**. Not aimed at native Windows shells.
8
+
9
+ ---
10
+
11
+ ## Install
12
+
13
+ **In a project (recommended):**
14
+
15
+ ```bash
16
+ pnpm add -D runctl # or npm install -D / yarn add -D
17
+ ```
18
+
19
+ **Global CLI** (`runctl` on your PATH everywhere):
20
+
21
+ ```bash
22
+ pnpm add -g runctl # or npm install -g
23
+ ```
24
+
25
+ **One-liner (from GitHub, pre-publish):**
26
+
27
+ ```bash
28
+ curl -fsSL https://raw.githubusercontent.com/DoctorKhan/devport-kit/main/scripts/install-global.sh | bash
29
+ ```
30
+
31
+ ---
32
+
33
+ ## Quick start
34
+
35
+ Add scripts to your `package.json`:
36
+
37
+ ```json
38
+ {
39
+ "scripts": {
40
+ "dev": "runctl start --script dev:server",
41
+ "dev:server": "next dev",
42
+ "dev:stop": "runctl stop"
43
+ }
44
+ }
45
+ ```
46
+
47
+ - **`pnpm dev`** — start (port in `.run/ports.env`, logs in `.run/logs/`).
48
+ - **`pnpm dev:stop`** — stop and release ports.
49
+
50
+ **Why two scripts?** `runctl start` runs `pnpm run <name>` under the hood. If `dev` called itself, it would loop. The real server lives on `dev:server`; `--script` tells runctl which one to run. Without `--script`, it defaults to running `dev`.
51
+
52
+ **`predev`:** If you define `predev` next to `dev` (e.g. a doctor step) and your script name is `dev:*` or `dev_*` without its own `pre<script>`, runctl runs `predev` once before starting. Set `RUNCTL_SKIP_PREDEV=1` to skip.
53
+
54
+ ---
55
+
56
+ ## Commands
57
+
58
+ | Command | What it does |
59
+ |---------|-------------|
60
+ | `runctl start [dir] [--script name]` | Start dev server (picks free port, backgrounds) |
61
+ | `runctl stop [dir]` | Stop daemons & release ports |
62
+ | `runctl status [dir]` | Show `.run` state for this package |
63
+ | `runctl ports` | List user-wide port registry (`~/.run`) |
64
+ | `runctl ports gc` | Clean up stale port claims |
65
+ | `runctl env expand <manifest> [--out file]` | Generate `.env.local` from manifest |
66
+ | `runctl update` | Update to latest version |
67
+ | `runctl version` | Print install location |
68
+
69
+ **Monorepo:** `runctl start ./apps/web --script dev:server`
70
+
71
+ **Vite:** if `--port` isn't forwarded, set `server.port` from `process.env.PORT` in `vite.config`.
72
+
73
+ ---
74
+
75
+ ## Fits / doesn't fit
76
+
77
+ | Kind of repo | Runctl |
78
+ |--------------|--------|
79
+ | Next.js, Vite, SvelteKit, Nuxt, Astro, Remix | **Good fit** — port flags wired for common stacks. |
80
+ | **pnpm**, **npm**, **yarn**, **bun** lockfiles | **Supported** for `run <script>`. |
81
+ | **`predev`** + split `dev` / `dev:server` | **Supported** — see above. |
82
+ | Monorepo app in a subfolder | Use `runctl start ./apps/web`. |
83
+ | **No `package.json`** (Python, Go, etc.) | **Not a fit** — this tool is for Node package scripts. |
84
+ | Custom Node entry (gateways, CLIs) | **Weak fit** — `PORT`/`HOST` are set, but no framework CLI flags. |
85
+
86
+ ---
87
+
88
+ ## Docs & examples
89
+
90
+ [`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
+
92
+ **Develop this repo:** `pnpm install` → `./run.sh list`
package/bin/runctl ADDED
@@ -0,0 +1,213 @@
1
+ #!/usr/bin/env bash
2
+ # Runctl CLI — installed via pnpm/npm (devDependency .bin or global).
3
+ set -euo pipefail
4
+ RUNCTL_PKG_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")/.." && pwd)"
5
+
6
+ _b="" _d="" _r="" _c="" _y="" _g=""
7
+ if [[ -t 1 ]]; then
8
+ _b=$'\033[1m' _d=$'\033[2m' _r=$'\033[0m'
9
+ _c=$'\033[36m' _y=$'\033[33m' _g=$'\033[32m'
10
+ fi
11
+
12
+ usage() {
13
+ cat <<EOF
14
+ ${_b}runctl${_r} — dev-server orchestrator & port manager
15
+
16
+ ${_y}Usage:${_r} runctl <command> [options]
17
+
18
+ ${_y}Commands${_r}
19
+ ${_c}start${_r} [dir] [--script name] Start dev server ${_d}(picks free port, backgrounds)${_r}
20
+ ${_c}open${_r} [dir] Open running dev server in browser
21
+ ${_c}stop${_r} [dir] Stop daemons & release ports
22
+ ${_c}status${_r} [dir] Show running state
23
+ ${_c}ports${_r} List user-wide port registry (~/.run)
24
+ ${_c}ports gc${_r} Clean up stale port claims
25
+ ${_c}env expand${_r} <manifest> [opts] Generate .env.local from manifest
26
+ ${_c}update${_r} Update runctl to latest version
27
+
28
+ ${_y}Options${_r}
29
+ ${_c}--script${_r} <name> Package script to run ${_d}(default: dev)${_r}
30
+ ${_c}--help${_r}, ${_c}-h${_r} Show this help
31
+
32
+ ${_d}Quick start — add to package.json:
33
+ "dev": "runctl start --script dev:server",
34
+ "dev:server": "next dev",
35
+ "dev:stop": "runctl stop"${_r}
36
+ EOF
37
+ }
38
+
39
+ _resolve_proj() {
40
+ if [[ $# -ge 1 && -d "$1" ]]; then
41
+ (cd "$1" && pwd)
42
+ else
43
+ pwd
44
+ fi
45
+ }
46
+
47
+ cmd_start() {
48
+ local proj script="" args=()
49
+ proj="$(pwd)"
50
+
51
+ while [[ $# -gt 0 ]]; do
52
+ case "$1" in
53
+ --script) script="${2:-}"; shift 2 ;;
54
+ --) shift; args+=("$@"); break ;;
55
+ -h | --help) usage; exit 0 ;;
56
+ -*)
57
+ echo "runctl start: unknown flag: $1" >&2
58
+ exit 1
59
+ ;;
60
+ *)
61
+ if [[ -d "$1" ]]; then
62
+ proj="$(cd "$1" && pwd)"
63
+ shift
64
+ else
65
+ args+=("$1")
66
+ shift
67
+ fi
68
+ ;;
69
+ esac
70
+ done
71
+
72
+ [[ -n "$script" ]] && export RUNCTL_PM_RUN_SCRIPT="$script"
73
+
74
+ # shellcheck source=../lib/run-lib.sh
75
+ source "$RUNCTL_PKG_ROOT/lib/run-lib.sh"
76
+ run_project_init "$proj"
77
+ run_with_lock run_start_package_dev "${args[@]+"${args[@]}"}"
78
+ }
79
+
80
+ cmd_open() {
81
+ local proj
82
+ proj="$(_resolve_proj "$@")"
83
+ local ports_env="$proj/.run/ports.env"
84
+ if [[ ! -f "$ports_env" ]]; then
85
+ echo "runctl open: no running server found (.run/ports.env missing)" >&2
86
+ echo "Start one first: runctl start" >&2
87
+ exit 1
88
+ fi
89
+ local port="" host=""
90
+ while IFS= read -r line; do
91
+ case "$line" in
92
+ PORT=*) port="${line#PORT=}" ;;
93
+ HOST=*) host="${line#HOST=}" ;;
94
+ esac
95
+ done <"$ports_env"
96
+ if [[ -z "$port" ]]; then
97
+ echo "runctl open: PORT not found in $ports_env" >&2
98
+ exit 1
99
+ fi
100
+ [[ -z "$host" || "$host" == "0.0.0.0" || "$host" == "127.0.0.1" ]] && host="localhost"
101
+ local url="http://${host}:${port}"
102
+ echo "runctl open: opening ${url}"
103
+ if command -v open >/dev/null 2>&1; then
104
+ open "$url"
105
+ elif command -v xdg-open >/dev/null 2>&1; then
106
+ xdg-open "$url"
107
+ elif command -v wslview >/dev/null 2>&1; then
108
+ wslview "$url"
109
+ else
110
+ echo "runctl open: no browser opener found (tried open, xdg-open, wslview)" >&2
111
+ echo "Visit: $url" >&2
112
+ exit 1
113
+ fi
114
+ }
115
+
116
+ cmd_stop() {
117
+ local proj
118
+ proj="$(_resolve_proj "$@")"
119
+ # shellcheck source=../lib/run-lib.sh
120
+ source "$RUNCTL_PKG_ROOT/lib/run-lib.sh"
121
+ run_project_init "$proj"
122
+ run_stop_all
123
+ echo "runctl stop: stopped local services and released claimed ports for $proj"
124
+ }
125
+
126
+ cmd_status() {
127
+ local proj
128
+ proj="$(_resolve_proj "$@")"
129
+ # shellcheck source=../lib/run-lib.sh
130
+ source "$RUNCTL_PKG_ROOT/lib/run-lib.sh"
131
+ run_project_init "$proj"
132
+ run_local_status
133
+ }
134
+
135
+ cmd_ports() {
136
+ # shellcheck source=../lib/run-lib.sh
137
+ source "$RUNCTL_PKG_ROOT/lib/run-lib.sh"
138
+ local sub="${1:-list}"
139
+ case "$sub" in
140
+ list | "") run_global_list_ports ;;
141
+ gc) run_global_gc ;;
142
+ *)
143
+ echo "runctl ports: unknown subcommand: $sub (try: ports, ports gc)" >&2
144
+ exit 1
145
+ ;;
146
+ esac
147
+ }
148
+
149
+ cmd_env() {
150
+ local sub="${1:-}"
151
+ shift || true
152
+ case "$sub" in
153
+ expand)
154
+ command -v node >/dev/null 2>&1 || {
155
+ echo "runctl: Node.js >= 18 is required for env expand." >&2
156
+ exit 1
157
+ }
158
+ exec node "$RUNCTL_PKG_ROOT/scripts/expand-env-manifest.mjs" "$@"
159
+ ;;
160
+ *)
161
+ echo "runctl env: unknown subcommand: ${sub:-<none>} (try: env expand)" >&2
162
+ exit 1
163
+ ;;
164
+ esac
165
+ }
166
+
167
+ cmd_update() {
168
+ echo "runctl update: installing latest runctl..."
169
+ if command -v pnpm >/dev/null 2>&1; then
170
+ pnpm add -g runctl@latest
171
+ elif command -v npm >/dev/null 2>&1; then
172
+ npm install -g runctl@latest
173
+ else
174
+ echo "runctl: pnpm or npm required to update." >&2
175
+ exit 1
176
+ fi
177
+ }
178
+
179
+ main() {
180
+ local cmd="${1:-help}"
181
+ shift || true
182
+ case "$cmd" in
183
+ start) cmd_start "$@" ;;
184
+ open) cmd_open "$@" ;;
185
+ stop) cmd_stop "$@" ;;
186
+ status) cmd_status "$@" ;;
187
+ ports) cmd_ports "$@" ;;
188
+ env) cmd_env "$@" ;;
189
+ update) cmd_update ;;
190
+
191
+ # Plumbing
192
+ lib-path) printf '%s\n' "$RUNCTL_PKG_ROOT/lib/run-lib.sh" ;;
193
+ version | -v) printf '%s\n' "runctl @ $RUNCTL_PKG_ROOT" ;;
194
+ help | -h | --help) usage ;;
195
+
196
+ # Hidden backward-compat aliases
197
+ start-dev) cmd_start "$@" ;;
198
+ stop-dev) cmd_stop "$@" ;;
199
+ status-dev) cmd_status "$@" ;;
200
+ list) cmd_ports list ;;
201
+ gc) cmd_ports gc ;;
202
+ expand-env) cmd_env expand "$@" ;;
203
+
204
+ *)
205
+ echo "runctl: unknown command: $cmd" >&2
206
+ echo ""
207
+ usage
208
+ exit 1
209
+ ;;
210
+ esac
211
+ }
212
+
213
+ main "$@"
@@ -0,0 +1,86 @@
1
+ # Environment variables, shared values, and Vercel
2
+
3
+ Vercel’s dashboard stores **flat** name → value pairs per environment. It does **not** offer “alias this key to that key” or `${VAR}` interpolation in the UI. If three keys must hold the same URL, you either **duplicate the value** in the dashboard or **manage one source of truth** outside Vercel and sync.
4
+
5
+ Below is a practical split: **local + CI** use a manifest; **Vercel** uses duplication or a secrets sync product.
6
+
7
+ ## 1. Single source of truth in the repo (recommended baseline)
8
+
9
+ Keep a **manifest** (not loaded directly by Next/Vite) that lists each real value once and expresses aliases with `${…}`:
10
+
11
+ - Example: [`examples/env.manifest.example`](../examples/env.manifest.example)
12
+ - Expand to a generated file that frameworks **do** load:
13
+
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
17
+ ```
18
+
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).
20
+
21
+ **Workflow**
22
+
23
+ 1. Edit `env.manifest` (gitignored) with real secrets.
24
+ 2. Regenerate `.env.local` before dev: same command as above.
25
+ 3. Commit only **`env.manifest.example`** (no secrets) so others know which keys exist.
26
+
27
+ For **multiple keys, same value**, define one canonical key (`APP_ORIGIN`) and set:
28
+
29
+ ```text
30
+ NEXT_PUBLIC_APP_URL=${APP_ORIGIN}
31
+ VITE_PUBLIC_APP_URL=${APP_ORIGIN}
32
+ ```
33
+
34
+ ## 2. Vercel: pulling remote env into local files
35
+
36
+ To align local dev with what is already on Vercel:
37
+
38
+ ```bash
39
+ vercel env pull .env.vercel.local
40
+ ```
41
+
42
+ Merge strategy (typical):
43
+
44
+ - **Generated** `.env.local` from your manifest for **shared-value consistency**.
45
+ - **Vercel-only** keys (e.g. `VERCEL_*`, analytics tokens) via `vercel env pull` into `.env.vercel.local` and load **both** (Next loads multiple `.env*` in priority order—see Next docs).
46
+
47
+ If the same logical value must exist under two names **on Vercel**, the dashboard still needs **two entries** with the same string unless you use option 3 or 4.
48
+
49
+ ## 3. Pushing manifest-derived values to Vercel (scripted duplication)
50
+
51
+ There is still **no native “link”** between keys on Vercel. A script can read the **expanded** map once and run the CLI for each name:
52
+
53
+ ```bash
54
+ # Example pattern (run from app linked to Vercel):
55
+ set -a && source .env.local && set +a
56
+ echo -n "$NEXT_PUBLIC_APP_URL" | vercel env add NEXT_PUBLIC_APP_URL production
57
+ echo -n "$VITE_PUBLIC_APP_URL" | vercel env add VITE_PUBLIC_APP_URL production
58
+ ```
59
+
60
+ Use a **non-interactive token** (`VERCEL_TOKEN`) in CI only; avoid committing tokens. Prefer **Preview** vs **Production** environments explicitly.
61
+
62
+ This is **intentional duplication in the cloud**, but **one edit** locally (manifest → expand → script loop).
63
+
64
+ ## 4. External secret managers (teams)
65
+
66
+ If many projects and environments need DRY + audit:
67
+
68
+ - **Doppler**, **Infisical**, **1Password Secrets Automation**, etc. can sync to Vercel and inject the **same** secret into multiple Vercel variable names (product-specific feature).
69
+
70
+ Use this when shell scripts are not enough.
71
+
72
+ ## 5. Reduce duplication in *code*
73
+
74
+ Sometimes two env vars exist for historical reasons. If both client and server can read the **same** name (e.g. only `NEXT_PUBLIC_*` where acceptable), prefer **one** variable in Vercel and one line in the manifest.
75
+
76
+ ## 6. Relation to Runctl’s `.run/ports.env`
77
+
78
+ [`.run/ports.env`](../README.md) is for **dev server `PORT` / `HOST`**, not for Vercel deployment. Keep deployment secrets in `.env.local` / Vercel / a manifest as above.
79
+
80
+ ## Summary
81
+
82
+ | Goal | Approach |
83
+ |------|-----------|
84
+ | Same value, many names **locally** | `env.manifest` + `expand-env-manifest.mjs` → `.env.local` |
85
+ | Match Vercel → laptop | `vercel env pull` + merge with generated `.env.local` |
86
+ | Same value, many names **on Vercel** | Duplicate in UI, or scripted `vercel env add`, or a secrets manager with Vercel sync |
@@ -0,0 +1,11 @@
1
+ {
2
+ "scripts": {
3
+ "dev": "runctl start --script dev:server",
4
+ "dev:server": "next dev",
5
+ "dev:stop": "runctl stop",
6
+ "dev:status": "runctl status",
7
+ "ports": "runctl ports",
8
+ "ports:gc": "runctl ports gc",
9
+ "env:expand": "runctl env expand env.manifest --out .env.local"
10
+ }
11
+ }
@@ -0,0 +1,13 @@
1
+ # Canonical keys first, then aliases that share the same underlying value.
2
+ # Use ${NAME} or ${NAME:-default}. Lines starting with # are comments.
3
+
4
+ APP_ORIGIN=https://my-app.vercel.app
5
+ API_BASE=https://api.example.com
6
+
7
+ # Framework-specific names pointing at the same logical value:
8
+ NEXT_PUBLIC_APP_URL=${APP_ORIGIN}
9
+ VITE_PUBLIC_APP_URL=${APP_ORIGIN}
10
+ PUBLIC_APP_URL=${APP_ORIGIN}
11
+
12
+ NEXT_PUBLIC_API_URL=${API_BASE}
13
+ VITE_API_URL=${API_BASE}
@@ -0,0 +1,76 @@
1
+ #!/usr/bin/env bash
2
+ # Optional shell entrypoint — most projects only need package.json scripts.
3
+ # Prerequisite: pnpm add -D runctl (Node >= 18)
4
+
5
+ set -euo pipefail
6
+
7
+ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
8
+ cd "$ROOT"
9
+
10
+ if [[ -z "${RUN_LIB:-}" ]]; then
11
+ _rp="${RUNCTL_PACKAGE:-runctl}"
12
+ RUN_LIB="$(
13
+ cd "$ROOT" && RUNCTL_PACKAGE="$_rp" node -e "
14
+ const p = require('path');
15
+ const n = process.env.RUNCTL_PACKAGE;
16
+ console.log(p.join(p.dirname(require.resolve(n + '/package.json')), 'lib', 'run-lib.sh'));
17
+ "
18
+ )" || {
19
+ echo "run.sh: add runctl — pnpm add -D ${_rp}" >&2
20
+ exit 1
21
+ }
22
+ fi
23
+ [[ -f "$RUN_LIB" ]] || {
24
+ echo "run.sh: RUN_LIB missing: $RUN_LIB" >&2
25
+ exit 1
26
+ }
27
+ # shellcheck source=/dev/null
28
+ source "$RUN_LIB"
29
+ run_project_init "$ROOT"
30
+
31
+ usage() {
32
+ cat <<'EOF'
33
+ Usage: ./run.sh <command>
34
+
35
+ dev [name] [auto|PORT] [-- extra args]
36
+ stop Stop daemons + release port claims
37
+ status Local .run state + ports.env
38
+ install pnpm install
39
+
40
+ Examples:
41
+ ./run.sh dev
42
+ ./run.sh dev web auto -- --turbo
43
+ EOF
44
+ }
45
+
46
+ cmd="${1:-dev}"
47
+ shift || true
48
+
49
+ case "$cmd" in
50
+ dev)
51
+ run_with_lock run_start_package_dev "$@"
52
+ ;;
53
+ stop)
54
+ run_stop_all
55
+ ;;
56
+ status)
57
+ run_local_status
58
+ ;;
59
+ install)
60
+ if command -v pnpm >/dev/null 2>&1; then
61
+ (cd "$ROOT" && pnpm install)
62
+ elif command -v npm >/dev/null 2>&1; then
63
+ (cd "$ROOT" && npm install)
64
+ else
65
+ echo "Install pnpm or npm" >&2
66
+ exit 1
67
+ fi
68
+ ;;
69
+ help | -h | --help)
70
+ usage
71
+ ;;
72
+ *)
73
+ usage
74
+ exit 1
75
+ ;;
76
+ esac
package/lib/run-lib.sh ADDED
@@ -0,0 +1,514 @@
1
+ #!/usr/bin/env bash
2
+ # run-lib.sh — npm package `runctl`: project .run/ + user ~/.run registry.
3
+ # Requires bash. Port/dev automation requires Node.js >= 18 (see package.json engines).
4
+ # Intentionally no global `set -e` — this file is usually sourced.
5
+
6
+ if [[ -z "${BASH_VERSION:-}" ]]; then
7
+ echo "run-lib.sh: must be sourced or executed with bash (not zsh)" >&2
8
+ return 1 2>/dev/null || exit 1
9
+ fi
10
+
11
+ run_lib_dir() {
12
+ local _src="${BASH_SOURCE[0]:-$0}"
13
+ cd "$(dirname "$_src")" && pwd
14
+ }
15
+
16
+ # Override with RUN_GLOBAL_STATE=... if needed.
17
+ : "${RUN_GLOBAL_STATE:=$HOME/.run}"
18
+
19
+ run_slug() {
20
+ # Short stable id from absolute project path (no raw path in filenames).
21
+ local h=""
22
+ if command -v shasum >/dev/null 2>&1; then
23
+ h="$(printf '%s' "$1" | shasum -a 256 2>/dev/null | cut -c1-16)"
24
+ fi
25
+ if [[ -z "$h" ]] && command -v sha256sum >/dev/null 2>&1; then
26
+ h="$(printf '%s' "$1" | sha256sum 2>/dev/null | cut -c1-16)"
27
+ fi
28
+ if [[ -z "$h" ]] && command -v openssl >/dev/null 2>&1; then
29
+ h="$(printf '%s' "$1" | openssl dgst -sha256 2>/dev/null | awk '{print $2}' | cut -c1-16)"
30
+ fi
31
+ if [[ -z "$h" ]]; then
32
+ h="$(printf '%s' "${1//\//_}" | cksum 2>/dev/null | awk '{print $1}')"
33
+ fi
34
+ printf '%s' "${h:-unknown}"
35
+ }
36
+
37
+ run_project_init() {
38
+ RUN_PROJECT_ROOT="$(cd "${1:-.}" && pwd)"
39
+ export RUN_PROJECT_ROOT
40
+ RUN_LOCAL_STATE="$RUN_PROJECT_ROOT/.run"
41
+ RUN_PROJECT_SLUG="$(run_slug "$RUN_PROJECT_ROOT")"
42
+ export RUN_LOCAL_STATE RUN_PROJECT_SLUG RUN_GLOBAL_STATE
43
+ mkdir -p "$RUN_LOCAL_STATE/pids" "$RUN_LOCAL_STATE/logs" \
44
+ "$RUN_GLOBAL_STATE/ports" "$RUN_GLOBAL_STATE/projects"
45
+ [[ -f "$RUN_LOCAL_STATE/claimed-ports" ]] || : >"$RUN_LOCAL_STATE/claimed-ports"
46
+ _run_ensure_gitignore
47
+ }
48
+
49
+ _run_ensure_gitignore() {
50
+ local gi="$RUN_PROJECT_ROOT/.gitignore"
51
+ [[ -d "$RUN_PROJECT_ROOT/.git" ]] || return 0
52
+ if [[ ! -f "$gi" ]]; then
53
+ printf '# Runtime state (runctl)\n.run/\n' >"$gi"
54
+ return 0
55
+ fi
56
+ if ! grep -qxF '.run/' "$gi" && ! grep -qxF '.run' "$gi"; then
57
+ printf '\n# Runtime state (runctl)\n.run/\n' >>"$gi"
58
+ fi
59
+ }
60
+
61
+ run_lock_acquire() {
62
+ local lock="$RUN_LOCAL_STATE/lock"
63
+ local wait="${1:-50}"
64
+ local i=0
65
+ while ! mkdir "$lock" 2>/dev/null; do
66
+ sleep 0.05
67
+ i=$((i + 1))
68
+ if (( i > wait * 20 )); then
69
+ echo "run-lib: could not acquire lock: $lock" >&2
70
+ return 1
71
+ fi
72
+ done
73
+ }
74
+
75
+ run_lock_release() {
76
+ rmdir "$RUN_LOCAL_STATE/lock" 2>/dev/null || true
77
+ }
78
+
79
+ run_with_lock() {
80
+ run_lock_acquire 50
81
+ trap 'run_lock_release' EXIT
82
+ "$@"
83
+ local _ec=$?
84
+ trap - EXIT
85
+ run_lock_release
86
+ return "$_ec"
87
+ }
88
+
89
+ run_pid_alive() {
90
+ kill -0 "$1" 2>/dev/null
91
+ }
92
+
93
+ run_port_listening() {
94
+ local port="$1"
95
+ command -v lsof >/dev/null 2>&1 || return 1
96
+ lsof -iTCP:"$port" -sTCP:LISTEN -n -P >/dev/null 2>&1
97
+ }
98
+
99
+ run_find_free_port() {
100
+ local p="${1:-3000}"
101
+ local max="${2:-200}"
102
+ local i=0
103
+ while run_port_listening "$p" && (( i < max )); do
104
+ p=$((p + 1))
105
+ i=$((i + 1))
106
+ done
107
+ if run_port_listening "$p"; then
108
+ echo "run-lib: no free port from base ${1:-3000} after $max tries" >&2
109
+ return 1
110
+ fi
111
+ printf '%s' "$p"
112
+ }
113
+
114
+ # --- JS / Node dev servers (Vite, Next, Nuxt, Astro, etc.) -----------------
115
+
116
+ run_require_node() {
117
+ command -v node >/dev/null 2>&1 || {
118
+ echo "runctl: Node.js is required (install Node >= 18; see runctl package engines)." >&2
119
+ return 1
120
+ }
121
+ node -e 'process.exit(parseInt(process.versions.node,10)>=18?0:1)' 2>/dev/null || {
122
+ echo "runctl: Node.js >= 18 is required (got $(node -v 2>/dev/null || echo none))." >&2
123
+ return 1
124
+ }
125
+ }
126
+
127
+ # True if package.json defines scripts.<name> (Node required).
128
+ run_package_has_script() {
129
+ local name="$1"
130
+ [[ -f "$RUN_PROJECT_ROOT/package.json" ]] || return 1
131
+ node -e '
132
+ const fs = require("fs");
133
+ const path = require("path");
134
+ const root = process.argv[1];
135
+ const s = process.argv[2];
136
+ const pkg = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8"));
137
+ process.exit(pkg.scripts && Object.prototype.hasOwnProperty.call(pkg.scripts, s) ? 0 : 1);
138
+ ' "$RUN_PROJECT_ROOT" "$name" 2>/dev/null
139
+ }
140
+
141
+ run_detect_package_manager() {
142
+ local dir="${1:-$RUN_PROJECT_ROOT}"
143
+ [[ -d "$dir" ]] || {
144
+ echo "run_detect_package_manager: bad directory: $dir" >&2
145
+ return 1
146
+ }
147
+ if [[ -f "$dir/pnpm-lock.yaml" ]] && command -v pnpm >/dev/null 2>&1; then
148
+ printf '%s\n' pnpm
149
+ return 0
150
+ fi
151
+ if [[ -f "$dir/bun.lock" || -f "$dir/bun.lockb" ]] && command -v bun >/dev/null 2>&1; then
152
+ printf '%s\n' bun
153
+ return 0
154
+ fi
155
+ if [[ -f "$dir/yarn.lock" ]] && command -v yarn >/dev/null 2>&1; then
156
+ printf '%s\n' yarn
157
+ return 0
158
+ fi
159
+ if command -v npm >/dev/null 2>&1; then
160
+ printf '%s\n' npm
161
+ return 0
162
+ fi
163
+ echo "run-lib: install pnpm, yarn, or npm (prefer pnpm per repo lockfile)" >&2
164
+ return 1
165
+ }
166
+
167
+ # Prints one of: next | nuxt | astro | vite | remix | generic
168
+ run_js_framework_kind() {
169
+ local pkg="$RUN_PROJECT_ROOT/package.json"
170
+ [[ -f "$pkg" ]] || {
171
+ printf '%s\n' generic
172
+ return 0
173
+ }
174
+ run_require_node || return 1
175
+ node -e "
176
+ const fs = require('fs');
177
+ const f = process.argv[1];
178
+ let pkg = {};
179
+ try { pkg = JSON.parse(fs.readFileSync(f, 'utf8')); } catch { console.log('generic'); process.exit(0); }
180
+ const d = { ...pkg.dependencies, ...pkg.devDependencies };
181
+ const h = (n) => !!d[n];
182
+ if (h('next')) console.log('next');
183
+ else if (h('nuxt')) console.log('nuxt');
184
+ else if (h('astro')) console.log('astro');
185
+ else if (h('vite') || h('@vitejs/plugin-react') || h('@vitejs/plugin-vue') || h('@vitejs/plugin-svelte') || h('@sveltejs/vite-plugin-svelte'))
186
+ console.log('vite');
187
+ else if (h('@remix-run/dev')) console.log('remix');
188
+ else console.log('generic');
189
+ " "$pkg"
190
+ }
191
+
192
+ run_infer_dev_base_port() {
193
+ local k
194
+ k="$(run_js_framework_kind)" || return 1
195
+ case "$k" in
196
+ vite) printf '%s\n' 5173 ;;
197
+ astro) printf '%s\n' 4321 ;;
198
+ next | nuxt | remix | generic) printf '%s\n' 3000 ;;
199
+ *) printf '%s\n' 3000 ;;
200
+ esac
201
+ }
202
+
203
+ # Writes .run/ports.env for tooling and humans: source from repo root after start.
204
+ run_write_ports_env() {
205
+ local port="$1"
206
+ local svc="${2:-web}"
207
+ mkdir -p "$RUN_LOCAL_STATE"
208
+ local host="${HOST:-127.0.0.1}"
209
+ umask 077
210
+ cat >"$RUN_LOCAL_STATE/ports.env" <<EOF
211
+ # Generated by run-lib.sh — do not commit .run/
212
+ # From repo root: set -a; source .run/ports.env; set +a
213
+ PORT=$port
214
+ RUN_DEV_PORT=$port
215
+ RUN_DEV_SERVICE=$svc
216
+ HOST=$host
217
+ EOF
218
+ umask 022
219
+ }
220
+
221
+ # Start \`pnpm|yarn|npm run <script>\` in the background with a free port and register it.
222
+ # Script name defaults to \`dev\`; set RUNCTL_PM_RUN_SCRIPT (e.g. dev:server) when \`dev\` is the runctl wrapper.
223
+ # Usage: run_start_package_dev [service_name=web] [base_port|auto=auto] [extra args after script --]
224
+ run_start_package_dev() {
225
+ run_require_node || return 1
226
+ [[ -f "$RUN_PROJECT_ROOT/package.json" ]] || {
227
+ echo "run_start_package_dev: no package.json in $RUN_PROJECT_ROOT" >&2
228
+ return 1
229
+ }
230
+ local svc="web"
231
+ local base_raw="auto"
232
+ if [[ $# -ge 1 ]]; then svc="$1"; shift; fi
233
+ if [[ $# -ge 1 ]]; then base_raw="$1"; shift; fi
234
+
235
+ local base_port
236
+ if [[ "$base_raw" == "auto" ]]; then
237
+ base_port="$(run_infer_dev_base_port)"
238
+ else
239
+ base_port="$base_raw"
240
+ fi
241
+
242
+ local port
243
+ port="$(run_find_free_port "$base_port")" || return 1
244
+
245
+ local pm kind
246
+ pm="$(run_detect_package_manager)" || return 1
247
+ kind="$(run_js_framework_kind)"
248
+
249
+ local -a dev_extra=()
250
+ case "$kind" in
251
+ next) dev_extra=(-p "$port") ;;
252
+ nuxt) dev_extra=(--port "$port") ;;
253
+ astro) dev_extra=(--port "$port") ;;
254
+ vite) dev_extra=(--port "$port" --strictPort) ;;
255
+ remix) dev_extra=(--port "$port") ;;
256
+ *) dev_extra=() ;;
257
+ esac
258
+
259
+ run_write_ports_env "$port" "$svc"
260
+
261
+ local host="${HOST:-127.0.0.1}"
262
+ local pm_script="${RUNCTL_PM_RUN_SCRIPT:-dev}"
263
+ # Many repos pair "predev" with script "dev". Using dev:server skips predev because only
264
+ # predev:server would run automatically. If predev exists but pre<pm_script> does not, run predev once.
265
+ if [[ "${RUNCTL_SKIP_PREDEV:-}" != "1" && "$pm_script" != "dev" ]]; then
266
+ if [[ "$pm_script" == dev:* || "$pm_script" == dev_* ]]; then
267
+ local pre_for_script="pre${pm_script}"
268
+ if ! run_package_has_script "$pre_for_script" && run_package_has_script "predev"; then
269
+ echo "run-lib: running predev before $pm_script ($pre_for_script not defined)"
270
+ (cd "$RUN_PROJECT_ROOT" && "$pm" run predev) || return 1
271
+ fi
272
+ fi
273
+ fi
274
+ echo "run-lib: [$kind] starting pm run $pm_script on PORT=$port (service=$svc, pm=$pm)"
275
+ local pid
276
+ pid="$(
277
+ run_daemon_start "$svc" \
278
+ bash -c 'cd "$1" && export PORT="$2" HOST="$3" && shift 3 && exec "$@"' \
279
+ _ "$RUN_PROJECT_ROOT" "$port" "$host" \
280
+ "$pm" run "$pm_script" -- "${dev_extra[@]}" "$@"
281
+ )" || return 1
282
+
283
+ run_port_register "$port" "$svc" "$pid"
284
+ printf '%s\n' "$port"
285
+ }
286
+
287
+ # Start a background service: name, then command + args.
288
+ run_daemon_start() {
289
+ local name="$1"
290
+ shift
291
+ [[ $# -ge 1 ]] || {
292
+ echo "run_daemon_start: need name and command" >&2
293
+ return 1
294
+ }
295
+ mkdir -p "$RUN_LOCAL_STATE/pids" "$RUN_LOCAL_STATE/logs"
296
+ local pidf="$RUN_LOCAL_STATE/pids/${name}.pid"
297
+ local logf="$RUN_LOCAL_STATE/logs/${name}.log"
298
+ if [[ -f "$pidf" ]]; then
299
+ local oldpid
300
+ oldpid="$(cat "$pidf")"
301
+ if run_pid_alive "$oldpid"; then
302
+ echo "run_daemon_start: ${name} already running (pid $oldpid)" >&2
303
+ return 1
304
+ fi
305
+ rm -f "$pidf"
306
+ echo "run_daemon_start: cleared stale pid for ${name} (was $oldpid)" >&2
307
+ fi
308
+ nohup "$@" >>"$logf" 2>&1 &
309
+ local pid=$!
310
+ printf '%s\n' "$pid" >"$pidf"
311
+ printf '%s\n' "$pid"
312
+ }
313
+
314
+ run_daemon_stop() {
315
+ local name="$1"
316
+ local pidf="$RUN_LOCAL_STATE/pids/${name}.pid"
317
+ [[ -f "$pidf" ]] || return 0
318
+ local pid
319
+ pid="$(cat "$pidf")"
320
+ if run_pid_alive "$pid"; then
321
+ kill "$pid" 2>/dev/null || true
322
+ local n=0
323
+ while run_pid_alive "$pid" && (( n < 50 )); do
324
+ sleep 0.1
325
+ n=$((n + 1))
326
+ done
327
+ if run_pid_alive "$pid"; then
328
+ kill -9 "$pid" 2>/dev/null || true
329
+ fi
330
+ fi
331
+ rm -f "$pidf"
332
+ }
333
+
334
+ run_stop_all_daemons() {
335
+ local f base
336
+ shopt -s nullglob
337
+ for f in "$RUN_LOCAL_STATE/pids"/*.pid; do
338
+ base="$(basename "$f" .pid)"
339
+ run_daemon_stop "$base"
340
+ done
341
+ shopt -u nullglob
342
+ }
343
+
344
+ # Stop every local daemon and drop this project's port claims under ~/.run/ports/.
345
+ run_stop_all() {
346
+ run_stop_all_daemons
347
+ run_unregister_project_ports
348
+ }
349
+
350
+ # Register a TCP port in the user-wide registry (~/.run/ports/<port>).
351
+ run_port_register() {
352
+ local port="$1"
353
+ local service="${2:-app}"
354
+ local pid="${3:-}"
355
+ [[ -n "$port" ]] || return 1
356
+ local reg="$RUN_GLOBAL_STATE/ports/$port"
357
+ mkdir -p "$RUN_GLOBAL_STATE/ports"
358
+ {
359
+ echo "project_root=$RUN_PROJECT_ROOT"
360
+ echo "slug=$RUN_PROJECT_SLUG"
361
+ echo "service=$service"
362
+ echo "pid=$pid"
363
+ echo "started=$(date +%s)"
364
+ } >"$reg"
365
+ mkdir -p "$RUN_GLOBAL_STATE/projects/$RUN_PROJECT_SLUG"
366
+ printf '%s %s %s\n' "$port" "$service" "${pid:-}" >>"$RUN_GLOBAL_STATE/projects/$RUN_PROJECT_SLUG/ports.log"
367
+ # De-dup claimed-ports for this project
368
+ if ! grep -qx "$port" "$RUN_LOCAL_STATE/claimed-ports" 2>/dev/null; then
369
+ printf '%s\n' "$port" >>"$RUN_LOCAL_STATE/claimed-ports"
370
+ fi
371
+ }
372
+
373
+ run_port_unregister() {
374
+ local port="$1"
375
+ local reg="$RUN_GLOBAL_STATE/ports/$port"
376
+ [[ -f "$reg" ]] || return 0
377
+ # Only remove if it still points at this project
378
+ if grep -q "^project_root=$RUN_PROJECT_ROOT\$" "$reg" 2>/dev/null; then
379
+ rm -f "$reg"
380
+ fi
381
+ }
382
+
383
+ run_unregister_project_ports() {
384
+ local port
385
+ while IFS= read -r port; do
386
+ [[ -z "$port" ]] && continue
387
+ run_port_unregister "$port"
388
+ done <"$RUN_LOCAL_STATE/claimed-ports"
389
+ : >"$RUN_LOCAL_STATE/claimed-ports"
390
+ }
391
+
392
+ # Remove stale entries under ~/.run/ports (dead pid or wrong listener).
393
+ run_global_gc() {
394
+ mkdir -p "$RUN_GLOBAL_STATE/ports"
395
+ local f port pid proot _line _k _v stale
396
+ shopt -s nullglob
397
+ for f in "$RUN_GLOBAL_STATE/ports"/*; do
398
+ [[ -f "$f" ]] || continue
399
+ port="$(basename "$f")"
400
+ [[ "$port" =~ ^[0-9]+$ ]] || continue
401
+ pid=""
402
+ proot=""
403
+ while IFS= read -r _line; do
404
+ [[ -z "$_line" ]] && continue
405
+ _k="${_line%%=*}"
406
+ _v="${_line#*=}"
407
+ case "$_k" in
408
+ pid) pid="$_v" ;;
409
+ project_root) proot="$_v" ;;
410
+ esac
411
+ done <"$f"
412
+ stale=0
413
+ if [[ -n "$pid" ]] && ! run_pid_alive "$pid"; then
414
+ stale=1
415
+ fi
416
+ 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
418
+ stale=1
419
+ fi
420
+ fi
421
+ if [[ "$stale" -eq 1 ]]; then
422
+ rm -f "$f"
423
+ echo "run_global_gc: removed stale ~/.run/ports/$port"
424
+ fi
425
+ done
426
+ shopt -u nullglob
427
+ }
428
+
429
+ run_global_list_ports() {
430
+ mkdir -p "$RUN_GLOBAL_STATE/ports"
431
+ local f port tmp pid svc proot _line _k _v
432
+ tmp="$(mktemp "${TMPDIR:-/tmp}/runlib.XXXXXX")"
433
+ shopt -s nullglob
434
+ for f in "$RUN_GLOBAL_STATE/ports"/*; do
435
+ [[ -f "$f" ]] || continue
436
+ port="$(basename "$f")"
437
+ [[ "$port" =~ ^[0-9]+$ ]] || continue
438
+ pid="" svc="" proot=""
439
+ while IFS= read -r _line; do
440
+ [[ -z "$_line" ]] && continue
441
+ _k="${_line%%=*}"
442
+ _v="${_line#*=}"
443
+ case "$_k" in
444
+ pid) pid="$_v" ;;
445
+ service) svc="$_v" ;;
446
+ project_root) proot="$_v" ;;
447
+ esac
448
+ done <"$f"
449
+ printf '%s\t%s\t%s\t%s\n' "${port}" "${pid:--}" "${svc:--}" "${proot:--}" >>"$tmp"
450
+ done
451
+ shopt -u nullglob
452
+ printf '%-6s %-8s %-10s %s\n' "PORT" "PID" "SERVICE" "PROJECT"
453
+ printf '%-6s %-8s %-10s %s\n' "------" "--------" "----------" "-------"
454
+ if [[ -s "$tmp" ]]; then
455
+ sort -n "$tmp" | while IFS=$'\t' read -r port pid svc proot; do
456
+ printf '%-6s %-8s %-10s %s\n' "$port" "$pid" "$svc" "$proot"
457
+ done
458
+ else
459
+ printf '%s\n' "(no claimed ports)"
460
+ fi
461
+ rm -f "$tmp"
462
+ }
463
+
464
+ run_local_status() {
465
+ printf 'runctl status\n'
466
+ printf ' %-10s %s\n' "project:" "$RUN_PROJECT_ROOT"
467
+ printf ' %-10s %s\n' "slug:" "$RUN_PROJECT_SLUG"
468
+ printf ' %-10s %s\n' "state-dir:" "$RUN_LOCAL_STATE"
469
+ printf '\n'
470
+
471
+ local f base pid state
472
+ local has_services=0
473
+ printf 'services\n'
474
+ printf ' %-14s %-8s %-8s %s\n' "name" "pid" "state" "log"
475
+ printf ' %-14s %-8s %-8s %s\n' "--------------" "--------" "--------" "---"
476
+ shopt -s nullglob
477
+ for f in "$RUN_LOCAL_STATE/pids"/*.pid; do
478
+ has_services=1
479
+ base="$(basename "$f" .pid)"
480
+ pid="$(cat "$f")"
481
+ if run_pid_alive "$pid"; then
482
+ state="running"
483
+ else
484
+ state="stale"
485
+ fi
486
+ printf ' %-14s %-8s %-8s %s\n' "$base" "$pid" "$state" "$RUN_LOCAL_STATE/logs/${base}.log"
487
+ done
488
+ shopt -u nullglob
489
+ if [[ "$has_services" -eq 0 ]]; then
490
+ printf ' %s\n' "(none)"
491
+ fi
492
+
493
+ if [[ -s "$RUN_LOCAL_STATE/claimed-ports" ]]; then
494
+ printf '\n'
495
+ printf 'claimed ports\n'
496
+ sed 's/^/ - /' "$RUN_LOCAL_STATE/claimed-ports"
497
+ fi
498
+ if [[ -f "$RUN_LOCAL_STATE/ports.env" ]]; then
499
+ local env_port="" env_host="" env_service=""
500
+ while IFS= read -r line; do
501
+ case "$line" in
502
+ PORT=*) env_port="${line#PORT=}" ;;
503
+ HOST=*) env_host="${line#HOST=}" ;;
504
+ RUN_DEV_SERVICE=*) env_service="${line#RUN_DEV_SERVICE=}" ;;
505
+ esac
506
+ done <"$RUN_LOCAL_STATE/ports.env"
507
+ printf '\n'
508
+ printf 'last dev allocation\n'
509
+ [[ -n "$env_service" ]] && printf ' %-10s %s\n' "service:" "$env_service"
510
+ [[ -n "$env_port" ]] && printf ' %-10s %s\n' "port:" "$env_port"
511
+ [[ -n "$env_host" ]] && printf ' %-10s %s\n' "host:" "$env_host"
512
+ printf ' %-10s %s\n' "env-file:" "$RUN_LOCAL_STATE/ports.env"
513
+ fi
514
+ }
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "@zendero/runctl",
3
+ "version": "0.1.0",
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
+ "author": "DoctorKhan",
6
+ "homepage": "https://github.com/DoctorKhan/devport-kit#readme",
7
+ "bugs": "https://github.com/DoctorKhan/devport-kit/issues",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "https://github.com/DoctorKhan/devport-kit.git"
11
+ },
12
+ "license": "MIT",
13
+ "keywords": [
14
+ "runctl",
15
+ "dev",
16
+ "dev-server",
17
+ "ports",
18
+ "pid",
19
+ "vercel",
20
+ "dotenv",
21
+ "vite",
22
+ "next"
23
+ ],
24
+ "engines": {
25
+ "node": ">=18"
26
+ },
27
+ "bin": {
28
+ "runctl": "bin/runctl"
29
+ },
30
+ "files": [
31
+ "bin",
32
+ "lib",
33
+ "scripts",
34
+ "examples",
35
+ "docs",
36
+ "README.md"
37
+ ],
38
+ "scripts": {
39
+ "run": "bash ./run.sh",
40
+ "env:expand": "node ./scripts/expand-env-manifest.mjs",
41
+ "link-global": "pnpm link --global"
42
+ }
43
+ }
@@ -0,0 +1,140 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Expand a manifest of KEY=value lines where values may reference ${OTHER} or ${OTHER:-default}.
4
+ * Single source of truth for values shared across NEXT_PUBLIC_*, VITE_*, etc.
5
+ *
6
+ * Usage:
7
+ * node scripts/expand-env-manifest.mjs path/to/env.manifest [--out .env.local] [--check]
8
+ *
9
+ * --check: verify all placeholders resolve (exit 1 if not); no write.
10
+ */
11
+
12
+ import fs from "node:fs";
13
+ import path from "node:path";
14
+
15
+ const re = /\$\{([^}:]+)(:-([^}]*))?\}/g;
16
+
17
+ function parse(content) {
18
+ const raw = {};
19
+ for (const line of content.split("\n")) {
20
+ const t = line.replace(/\r$/, "").trimEnd();
21
+ const trimmed = t.trim();
22
+ if (!trimmed || trimmed.startsWith("#")) continue;
23
+ const eq = trimmed.indexOf("=");
24
+ if (eq === -1) continue;
25
+ const k = trimmed.slice(0, eq).trim();
26
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(k)) continue;
27
+ let v = trimmed.slice(eq + 1);
28
+ if (
29
+ (v.startsWith('"') && v.endsWith('"')) ||
30
+ (v.startsWith("'") && v.endsWith("'"))
31
+ ) {
32
+ v = v.slice(1, -1);
33
+ }
34
+ raw[k] = v;
35
+ }
36
+ return raw;
37
+ }
38
+
39
+ function expand(raw) {
40
+ const out = { ...raw };
41
+ const max = 64;
42
+ for (let pass = 0; pass < max; pass++) {
43
+ let changed = false;
44
+ for (const k of Object.keys(out)) {
45
+ const next = out[k].replace(re, (_, name, __, def) => {
46
+ if (Object.prototype.hasOwnProperty.call(out, name))
47
+ return String(out[name]);
48
+ if (def !== undefined) return def;
49
+ throw new Error(`Unresolved \${${name}} while expanding ${k}`);
50
+ });
51
+ if (next !== out[k]) {
52
+ out[k] = next;
53
+ changed = true;
54
+ }
55
+ }
56
+ if (!changed) {
57
+ for (const k of Object.keys(out)) {
58
+ re.lastIndex = 0;
59
+ if (re.test(out[k])) {
60
+ throw new Error(
61
+ `Unresolved or circular interpolation in ${k}: ${out[k]}`,
62
+ );
63
+ }
64
+ }
65
+ return out;
66
+ }
67
+ }
68
+ throw new Error("Circular or too-deep variable references in manifest");
69
+ }
70
+
71
+ function quoteForDotenv(val) {
72
+ if (/[\r\n#"'\\]/.test(val) || /\s/.test(val)) {
73
+ const esc = val.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
74
+ return `"${esc}"`;
75
+ }
76
+ return val;
77
+ }
78
+
79
+ function formatEnv(out) {
80
+ const lines = [];
81
+ lines.push("# Generated by expand-env-manifest.mjs — do not edit by hand");
82
+ for (const k of Object.keys(out).sort()) {
83
+ lines.push(`${k}=${quoteForDotenv(out[k])}`);
84
+ }
85
+ lines.push("");
86
+ return lines.join("\n");
87
+ }
88
+
89
+ function main() {
90
+ const major = Number.parseInt(process.versions.node.split(".")[0], 10);
91
+ if (major < 18) {
92
+ console.error("runctl: Node.js >= 18 is required (expand-env).");
93
+ process.exit(2);
94
+ }
95
+ const args = process.argv.slice(2);
96
+ let outPath = null;
97
+ let checkOnly = false;
98
+ const files = [];
99
+ for (let i = 0; i < args.length; i++) {
100
+ if (args[i] === "--out") {
101
+ outPath = args[++i];
102
+ continue;
103
+ }
104
+ if (args[i] === "--check") {
105
+ checkOnly = true;
106
+ continue;
107
+ }
108
+ files.push(args[i]);
109
+ }
110
+ if (files.length !== 1) {
111
+ console.error(
112
+ "Usage: node expand-env-manifest.mjs <manifest> [--out <file>] [--check]",
113
+ );
114
+ process.exit(2);
115
+ }
116
+ const manifestPath = path.resolve(files[0]);
117
+ const text = fs.readFileSync(manifestPath, "utf8");
118
+ const raw = parse(text);
119
+ let out;
120
+ try {
121
+ out = expand(raw);
122
+ } catch (e) {
123
+ console.error(String(e.message || e));
124
+ process.exit(1);
125
+ }
126
+ re.lastIndex = 0;
127
+ if (checkOnly) {
128
+ console.log("OK — all references resolved.");
129
+ return;
130
+ }
131
+ const body = formatEnv(out);
132
+ if (outPath) {
133
+ fs.writeFileSync(path.resolve(outPath), body, "utf8");
134
+ console.error(`Wrote ${outPath}`);
135
+ } else {
136
+ process.stdout.write(body);
137
+ }
138
+ }
139
+
140
+ main();
@@ -0,0 +1,13 @@
1
+ #!/usr/bin/env sh
2
+ # Install runctl globally (needs Node 18+ and npm or pnpm).
3
+ # curl -fsSL https://raw.githubusercontent.com/DoctorKhan/devport-kit/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"