@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 +92 -0
- package/bin/runctl +213 -0
- package/docs/vercel-and-env.md +86 -0
- package/examples/consumer-package.json +11 -0
- package/examples/env.manifest.example +13 -0
- package/examples/run.sh.example +76 -0
- package/lib/run-lib.sh +514 -0
- package/package.json +43 -0
- package/scripts/expand-env-manifest.mjs +140 -0
- package/scripts/install-global.sh +13 -0
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"
|