@nodegx/mcp 0.2.5 → 0.2.7

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.
@@ -0,0 +1,235 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # P112 OWN-016 / P26 DEP-002 — put a NodeGX app on a VM you own: the folder `nodegx-deploy --full-stack` writes (the
4
+ # app, its backend, its cloud functions, its access rules and `start.js`), run as a service, plus its function secrets.
5
+ # Modelled on P78's todo-digitalbricks/provision.sh, which put todo.digitalbricks.io on nexus-1.
6
+ #
7
+ # Run as root on the box, after the folder is staged in /tmp/<app>-provision — by @nodegx/mcp's deploy_to_vm, which
8
+ # ships this file in dist/backend/vm/, or by own-016/ship-to-vm.sh from a checkout:
9
+ #
10
+ # APP=lingo DOMAIN=lingo.nodegx.io PORT=8692 bash -s < provision-app.sh
11
+ # APP=lingo PORT=8692 CADDY=off bash -s < provision-app.sh # no public address yet (no DNS): the backend serves
12
+ # # the app on 127.0.0.1:<port> only (ssh -L to see it)
13
+ #
14
+ # SHARED HOST: other live sites may be served here, and Caddy loads the whole config or none of it. So this is
15
+ # strictly additive. It writes only:
16
+ #
17
+ # /opt/<app>/ the folder, replaced wholesale by every ship; read-only to the service
18
+ # /var/lib/<app>/data/ the app's data: records, uploads, the admin credential, and the cloud
19
+ # functions and rules start.js installs from the folder. A ship never
20
+ # writes here.
21
+ # /etc/<app>/secrets.env (written by ship-to-vm.sh over stdin, never here, never in the folder)
22
+ # /etc/systemd/system/<app>-backend.service
23
+ # /etc/caddy/conf.d/<app>.caddy (unless CADDY=off; deleted again if caddy validate fails)
24
+ # /opt/nodegx-native/sharp-<version>/ the image module that makes a photo's small versions, fetched from npm
25
+ # the first time any app ships here and shared by every app after
26
+ #
27
+ # and, once Caddy no longer points at it, removes /srv/<app>/ — the site the earlier layout (a site plus a loose
28
+ # cli.js) served from.
29
+ #
30
+ # The backend binds 127.0.0.1:<port> and serves the app AND its API on that one origin (`--serve-app`, set by
31
+ # start.js); Caddy is the only way in from outside. Behind a proxy every request arrives from loopback, so the admin
32
+ # surface is NOT proxied, and --no-admin is set. Secrets reach cloud functions through the backend's
33
+ # NODEGX_SECRET_<NAME> environment fallback (SecretsStore), from an EnvironmentFile only root and the app user read.
34
+
35
+ set -euo pipefail
36
+
37
+ APP="${APP:?APP=<name>}"
38
+ PORT="${PORT:?PORT=<loopback port>}"
39
+ CADDY="${CADDY:-on}"
40
+ DOMAIN="${DOMAIN:-}"
41
+ [ "$CADDY" = off ] || [ -n "$DOMAIN" ] || { echo "FATAL: DOMAIN=<host> is needed unless CADDY=off" >&2; exit 1; }
42
+ [[ "$APP" =~ ^[a-z][a-z0-9-]{1,30}$ ]] || { echo "FATAL: APP must be a short lowercase name" >&2; exit 1; }
43
+ [[ "$PORT" =~ ^[0-9]{2,5}$ ]] || { echo "FATAL: PORT must be a port number" >&2; exit 1; }
44
+
45
+ APP_USER="$APP"
46
+ FOLDER="/opt/$APP"
47
+ DATA_DIR="/var/lib/$APP/data"
48
+ OLD_SITE="/srv/$APP"
49
+ SECRETS="/etc/$APP/secrets.env"
50
+ UNIT="/etc/systemd/system/$APP-backend.service"
51
+ CADDY_MAIN=/etc/caddy/Caddyfile
52
+ CADDY_DROPIN="/etc/caddy/conf.d/$APP.caddy"
53
+ STAGE="/tmp/$APP-provision"
54
+
55
+ for f in start.js app/index.html backend/cli.js MANIFEST.json; do
56
+ [ -f "$STAGE/$f" ] || { echo "FATAL: $STAGE/$f missing — stage a full-stack folder (run ship-to-vm.sh)" >&2; exit 1; }
57
+ done
58
+ NODE="$(command -v node || true)"
59
+ [ -n "$NODE" ] || { echo "FATAL: this box has no node; the backend needs Node 22.13 or later" >&2; exit 1; }
60
+ "$NODE" -e 'const m=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8")); process.exit(m.layout==="full-stack"?0:1)' \
61
+ "$STAGE/MANIFEST.json" || { echo "FATAL: $STAGE is not a full-stack deploy (MANIFEST.json layout)" >&2; exit 1; }
62
+ # start.js checks this too, but by then the service is installed; say it before anything is written.
63
+ "$NODE" -e 'require("node:sqlite")' 2>/dev/null \
64
+ || { echo "FATAL: the backend needs Node 22.13 or later (node:sqlite); this box has $("$NODE" --version)" >&2; exit 1; }
65
+ if ss -tln | awk '{print $4}' | grep -qE ":${PORT}\$" && ! systemctl is-active --quiet "$APP-backend"; then
66
+ echo "FATAL: port $PORT is taken by something else" >&2; exit 1
67
+ fi
68
+ if [ "$CADDY" != off ]; then
69
+ [ -f "$CADDY_MAIN" ] || { echo "FATAL: Caddy is not set up on this server ($CADDY_MAIN is missing). Install Caddy, or ship with no domain. Nothing was changed." >&2; exit 1; }
70
+ # A freshly installed Caddy's file has no such line, so a stranger's first ship with a domain stops here. Adding it
71
+ # is theirs to do: this script never edits the main file, which other sites on a shared server depend on.
72
+ grep -qE '^\s*import\s+/etc/caddy/conf\.d/' "$CADDY_MAIN" || {
73
+ cat >&2 <<REFUSAL
74
+ FATAL: $CADDY_MAIN does not read /etc/caddy/conf.d/, where each app's site goes (a freshly installed Caddy's
75
+ file does not). Nothing was changed. ON THE SERVER, add the line once, then ship again:
76
+
77
+ mkdir -p /etc/caddy/conf.d && echo 'import /etc/caddy/conf.d/*' >> $CADDY_MAIN && systemctl reload caddy
78
+ REFUSAL
79
+ exit 1
80
+ }
81
+ fi
82
+ # 🔴 P26-VMOPEN: the earlier layout ran `cli.js serve` without --enforce-rules, so its first start minted
83
+ # security.json with "devOpen": true, and on a 127.0.0.1 bind the backend's interlock never refused it — the app was
84
+ # public behind Caddy with every access rule off. The staged start.js decides what a start will do with the rules
85
+ # (the project's replace the server's when this ship brings different ones — Richard, 2026-10-23) and refuses
86
+ # rules that are switched off. Ask it HERE, before anything is written, so a refusal leaves the running app as it is.
87
+ if ! CHECK="$("$NODE" "$STAGE/start.js" --check --data-dir "$DATA_DIR" 2>&1)"; then
88
+ echo "$CHECK" | sed 's/^/ /' >&2
89
+ cat >&2 <<REFUSAL
90
+ FATAL: refused before anything was changed: the app that is running now keeps running as it is.
91
+ If the file named is $DATA_DIR/security.json, either give the project its own rules (nodegx.security.json, which
92
+ a ship installs) or switch the server's on — ON THE SERVER (ssh in first; on a Mac this sed refuses the command):
93
+
94
+ sed -i 's/"devOpen": *true/"devOpen": false/' $DATA_DIR/security.json && systemctl restart $APP-backend
95
+
96
+ then ship again.
97
+ REFUSAL
98
+ exit 1
99
+ fi
100
+ echo "$CHECK" | sed 's/^/ /'
101
+
102
+ id -u "$APP_USER" >/dev/null 2>&1 || useradd --system --home-dir "/var/lib/$APP" --shell /usr/sbin/nologin "$APP_USER"
103
+ mkdir -p "$FOLDER" "$DATA_DIR" "/etc/$APP"
104
+
105
+ # The folder replaces what is there, wholesale (`app/` and `backend/` included). `data/` is excluded in case a folder
106
+ # was ever started by hand: the service's data is $DATA_DIR, never inside the folder.
107
+ rsync -a --delete --exclude=/data/ "$STAGE/" "$FOLDER/"
108
+ chown -R root:root "$FOLDER"; chmod -R u+rwX,go+rX,go-w "$FOLDER"
109
+ [ -f "$SECRETS" ] || install -o root -g "$APP_USER" -m 640 /dev/null "$SECRETS"
110
+ chown root:"$APP_USER" "$SECRETS"; chmod 640 "$SECRETS"
111
+ chown -R "$APP_USER":"$APP_USER" "/var/lib/$APP"; chmod 750 "/var/lib/$APP"
112
+
113
+ # 🔴 P112-OWN024-DEPLOYNOSHARP (Richard, 2026-10-25: the server fetches it once). A photo's small versions (?thumb=)
114
+ # are made by sharp, a native module the folder does not carry, so without it every thumbnail answers 501 and the
115
+ # marketplace draws its photos broken. It is fetched from npm ONCE per server into a place outside /opt/<app> (which
116
+ # every ship replaces), shared by every app here, and found through the unit's NODE_PATH. A server that cannot fetch
117
+ # it still ships: the app runs, and ?thumb= answers 501 until a later ship can. Pinned to the version the editor's
118
+ # backend ships (the lockfile's; tests/p112-own024-deploy-sharp.test.ts holds the two together).
119
+ SHARP_VERSION=0.33.5
120
+ NATIVE=/opt/nodegx-native
121
+ SHARP_DIR="$NATIVE/sharp-$SHARP_VERSION"
122
+ sharp_loads() { "$NODE" -e 'require(process.argv[1])' "$1/node_modules/sharp" >/dev/null 2>&1; }
123
+ if sharp_loads "$SHARP_DIR"; then
124
+ echo " photos: small versions (?thumb=) are made by sharp $SHARP_VERSION, already on this server"
125
+ else
126
+ mkdir -p "$NATIVE"
127
+ FETCH="$(mktemp -d "$NATIVE/.sharp-$SHARP_VERSION-XXXXXX")"
128
+ NPM="$(command -v npm || true)"
129
+ if [ -z "$NPM" ]; then
130
+ WHY="this server has no npm"
131
+ elif ! WHY="$(cd "$FETCH" && npm_config_cache="$FETCH/.cache" timeout 300 "$NPM" install --prefix "$FETCH" --omit=dev \
132
+ --no-audit --no-fund --loglevel=error --fetch-retries=1 "sharp@$SHARP_VERSION" 2>&1)"; then
133
+ # npm's last line points at a log in the cache this deletes; the line before it is the reason.
134
+ WHY="npm could not fetch it ($(echo "$WHY" | grep -v -e '^\s*$' -e 'complete log' | tail -1 | sed 's/^npm error //' || true))"
135
+ elif ! sharp_loads "$FETCH"; then
136
+ WHY="it was fetched but does not load on this server: $("$NODE" -e 'require(process.argv[1])' "$FETCH/node_modules/sharp" 2>&1 | grep -m1 -E '^[A-Za-z]*Error' || true)"
137
+ else
138
+ WHY=""
139
+ fi
140
+ rm -rf "$FETCH/.cache"
141
+ if [ -z "$WHY" ]; then
142
+ rm -rf "$SHARP_DIR"; mv "$FETCH" "$SHARP_DIR"; chmod -R go+rX,go-w "$SHARP_DIR"
143
+ echo " photos: fetched sharp $SHARP_VERSION from npm for this server's small photo versions (?thumb=), once"
144
+ else
145
+ rm -rf "$FETCH"
146
+ echo " photos: NOT AVAILABLE: small photo versions (?thumb=) answer 501 until sharp $SHARP_VERSION is on this server," \
147
+ "and the next ship fetches it again. $WHY. The app runs; uploads and full-size photos work."
148
+ fi
149
+ fi
150
+
151
+ cat > "$UNIT" <<UNIT
152
+ [Unit]
153
+ Description=${APP} (NodeGX full-stack folder, P26 DEP-002)
154
+ After=network-online.target
155
+ Wants=network-online.target
156
+
157
+ [Service]
158
+ Type=simple
159
+ User=${APP_USER}
160
+ Group=${APP_USER}
161
+ WorkingDirectory=/var/lib/${APP}
162
+ EnvironmentFile=${SECRETS}
163
+ Environment=NODE_PATH=${SHARP_DIR}/node_modules
164
+ ExecStart=${NODE} ${FOLDER}/start.js --port ${PORT} --host 127.0.0.1 --data-dir ${DATA_DIR} --backend-id ${APP} --no-admin
165
+ Restart=always
166
+ RestartSec=3
167
+
168
+ NoNewPrivileges=true
169
+ PrivateTmp=true
170
+ ProtectSystem=strict
171
+ ProtectHome=true
172
+ ReadWritePaths=/var/lib/${APP}
173
+ ProtectKernelTunables=true
174
+ ProtectControlGroups=true
175
+ RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
176
+ MemoryMax=384M
177
+
178
+ [Install]
179
+ WantedBy=multi-user.target
180
+ UNIT
181
+
182
+ if [ "$CADDY" != off ]; then
183
+ # Everything but the closed admin surface goes to the backend, which serves the app beside its API and answers
184
+ # /nodegx-config.json with this origin (Host + X-Forwarded-Proto, both of which reverse_proxy passes).
185
+ cat > "$CADDY_DROPIN" <<CADDYFILE
186
+ ${DOMAIN} {
187
+ encode zstd gzip
188
+
189
+ header {
190
+ X-Content-Type-Options nosniff
191
+ Referrer-Policy strict-origin-when-cross-origin
192
+ Strict-Transport-Security "max-age=31536000; includeSubDomains"
193
+ -Server
194
+ }
195
+
196
+ @closed path_regexp closed ^/(_admin|admin|executions|metrics)(/|$)
197
+ handle @closed {
198
+ respond 404
199
+ }
200
+
201
+ handle {
202
+ reverse_proxy 127.0.0.1:${PORT}
203
+ }
204
+ }
205
+ CADDYFILE
206
+ if ! caddy validate --config "$CADDY_MAIN" --adapter caddyfile >/dev/null 2>&1; then
207
+ echo "FATAL: Caddy config invalid with the new drop-in — removing it, running config untouched." >&2
208
+ rm -f "$CADDY_DROPIN"
209
+ caddy validate --config "$CADDY_MAIN" --adapter caddyfile >&2 || true
210
+ exit 1
211
+ fi
212
+ fi
213
+
214
+ systemctl daemon-reload
215
+ systemctl enable "$APP-backend" >/dev/null
216
+ # This start's lines only: the journal also holds every earlier start's, which said other things about the rules.
217
+ STARTED="$(date '+%Y-%m-%d %H:%M:%S')"
218
+ systemctl restart "$APP-backend"
219
+ for i in $(seq 1 20); do
220
+ curl -fsS "http://127.0.0.1:${PORT}/health" >/dev/null 2>&1 && break
221
+ sleep 1
222
+ done
223
+ curl -fsS "http://127.0.0.1:${PORT}/health" >/dev/null || { echo "FATAL: backend not healthy" >&2; journalctl -u "$APP-backend" --since "$STARTED" -n 40 --no-pager >&2; exit 1; }
224
+ curl -fsS -o /dev/null "http://127.0.0.1:${PORT}/" || { echo "FATAL: the backend is up but does not serve the app at /" >&2; exit 1; }
225
+ # What start.js installed and which access rules are in force, in the backend's own words.
226
+ journalctl -u "$APP-backend" --since "$STARTED" --no-pager -o cat | grep -E '^\[start\]|polic|rules' | sed 's/^/ /' || true
227
+
228
+ [ "$CADDY" != off ] && systemctl reload caddy
229
+ # Only once no drop-in can still point at it: CADDY=off over an earlier domain ship leaves that drop-in in place.
230
+ if [ -d "$OLD_SITE" ] && { [ "$CADDY" != off ] || [ ! -f "$CADDY_DROPIN" ]; }; then
231
+ rm -rf "$OLD_SITE"
232
+ echo " removed $OLD_SITE (the earlier layout's site; the backend serves the app now)"
233
+ fi
234
+ rm -rf "$STAGE"
235
+ echo "==> provisioned ${APP} on 127.0.0.1:${PORT}${DOMAIN:+, https://$DOMAIN}"