@nodegx/mcp 0.2.5 → 0.2.6

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,4 @@
1
+ @echo off
2
+ rem Starts this NodeGX app - see DEPLOY.md. Options: --port <n> (default: the first free port from 3000).
3
+ where node >nul 2>nul || (echo This app needs Node.js 22.13 or later: https://nodejs.org & exit /b 1)
4
+ node "%~dp0start.js" %*
@@ -0,0 +1,378 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Starts this NodeGX app: its backend, which also serves the app, on one port.
4
+ *
5
+ * node start.js first free port from 3000 up
6
+ * node start.js --port 8090 exactly 8090, or stop and say it is taken
7
+ * PORT=8090 node start.js the same, for hosts that set PORT
8
+ * node start.js --data-dir /var/lib/my-app/data keep the data outside this folder (a server
9
+ * whose app folder is read-only; a mounted volume)
10
+ * node start.js --check --data-dir <dir> say what a start would do with the rules, or refuse
11
+ * as a start would; writes nothing and starts nothing (a server's
12
+ * provisioning asks this before it replaces a running app)
13
+ *
14
+ * Every other option goes to the backend as it is (`node backend/cli.js help` lists them).
15
+ *
16
+ * Written by a NodeGX full-stack deploy (P26 DEP-002), and rewritten by the next one: change the
17
+ * project and deploy again rather than editing this file.
18
+ *
19
+ * What it does, in order:
20
+ *
21
+ * 1. Checks this Node can keep the data (`node:sqlite`, built in from Node 22.13), and says so in
22
+ * a sentence when it cannot — never a stack trace about a missing built-in module.
23
+ * 2. Picks the port by `app/serve.js`'s rule: the one asked for or a refusal naming it, else the
24
+ * first free from 3000, where free means 127.0.0.1 AND ::1 can both be bound, so
25
+ * `localhost:<port>` reaches this app and not another program.
26
+ * 3. Installs the cloud functions this deploy carries (`backend/workflows/`) into `data/`,
27
+ * replacing the ones the previous deploy installed. The deploy is their source, as a push from
28
+ * the editor is; a function deleted from the project is gone after the next deploy.
29
+ * 4. Installs the project's access rules (`backend/nodegx.security.json`) when this deploy brings
30
+ * rules other than the ones the last deploy installed: on the first start, and after a deploy
31
+ * that changed them, the backend's `data/security.json` is moved aside
32
+ * (`security.json.before-<time>`) and the backend installs the project's. A restart, or a deploy
33
+ * with the same rules, leaves `data/security.json` as it is (Richard, 2026-10-23: a reship
34
+ * brings the project's rules; an edit made on the server lasts until the project's change).
35
+ * 5. Refuses rules that are switched off (`"devOpen": true`), which is how a development backend's
36
+ * data arrives — and the backend's own interlock only refuses that on a public bind, while this
37
+ * app is usually behind a proxy on 127.0.0.1. Refused before anything in the data is changed.
38
+ * 6. Runs the backend in this process with the app beside its API (`--serve-app`) and the access
39
+ * rules enforced from the first record (`--enforce-rules`).
40
+ *
41
+ * `data/` (or the `--data-dir` given) is the app's: its database, uploads, the admin credential. A
42
+ * deploy never writes there; this script writes only the functions and the rules described above.
43
+ */
44
+ 'use strict';
45
+
46
+ const crypto = require('crypto');
47
+ const fs = require('fs');
48
+ const net = require('net');
49
+ const path = require('path');
50
+
51
+ const HERE = __dirname;
52
+ const APP = path.join(HERE, 'app');
53
+ const BACKEND = path.join(HERE, 'backend');
54
+ const DEFAULT_DATA = path.join(HERE, 'data');
55
+ const POLICY_FILE = 'nodegx.security.json';
56
+ /** The backend's own rules file, in the data folder. */
57
+ const BACKEND_RULES = 'security.json';
58
+ /** Which rules the last deploy installed (their checksum), in the data folder. */
59
+ const RULES_MARKER = 'deployed-rules.json';
60
+ const WORKFLOW_SUFFIX = '.workflow.json';
61
+
62
+ /** The Node this app needs, and the one property that decides it. */
63
+ const NODE_NEEDED = '22.13';
64
+
65
+ function fail(message) {
66
+ process.stderr.write(`${message}\n`);
67
+ process.exit(1);
68
+ }
69
+
70
+ /**
71
+ * 🔴 The property, not a version table: Node 22.5–22.12 has `node:sqlite` behind a flag, 20 has
72
+ * none, and a version comparison would have to know both. Loading it answers for every one.
73
+ */
74
+ function checkNode() {
75
+ try {
76
+ require('node:sqlite');
77
+ } catch {
78
+ fail(
79
+ `This app needs Node.js ${NODE_NEEDED} or later. It keeps its data with node:sqlite, which is built into ` +
80
+ `Node from ${NODE_NEEDED}, and this computer has Node ${process.version}.\n` +
81
+ 'Install a current Node from https://nodejs.org and run this again.'
82
+ );
83
+ }
84
+ }
85
+
86
+ /** The options this script reads itself; everything else is the backend's. */
87
+ const OWN_OPTIONS = ['--port', '-p', '--data-dir'];
88
+
89
+ /** `--port N`, `--port=N`, `-p N` and `--data-dir` (either form) are this script's. */
90
+ function withoutOwnArgs(argv) {
91
+ const rest = [];
92
+ for (let i = 0; i < argv.length; i++) {
93
+ if (OWN_OPTIONS.includes(argv[i])) {
94
+ i++;
95
+ continue;
96
+ }
97
+ if (OWN_OPTIONS.some((option) => argv[i].startsWith(`${option}=`))) continue;
98
+ rest.push(argv[i]);
99
+ }
100
+ return rest;
101
+ }
102
+
103
+ /**
104
+ * `--data-dir <dir>` or `--data-dir=<dir>`, resolved from where the command was run; `data/` beside
105
+ * this file when it is not given. The functions are installed THERE and the backend keeps its
106
+ * records there, so a folder whose data lives elsewhere never grows a second `data/`.
107
+ */
108
+ function readDataDir(argv) {
109
+ for (let i = 0; i < argv.length; i++) {
110
+ const value = argv[i] === '--data-dir' ? argv[i + 1] : argv[i].startsWith('--data-dir=') ? argv[i].slice(11) : null;
111
+ if (value === null) continue;
112
+ if (!value) throw new Error('--data-dir wants a folder, and got nothing.');
113
+ return path.resolve(value);
114
+ }
115
+ return DEFAULT_DATA;
116
+ }
117
+
118
+ // ── The port: `serve.js`'s rule, copied ─────────────────────────────────────────────────────────
119
+ // ⚠️ A copy of `listenOnFreePort` in the deploy runtime's `serve.js` (noodl-viewer-react
120
+ // `static/deploy/`), not a require of `app/serve.js`. This file ships with the BACKEND, and an app
121
+ // deployed by an older runtime has no `serve.js` with that function in it; a launcher that needs the
122
+ // app's runtime to be new enough fails for a reason that has nothing to do with the backend. Same
123
+ // rule, same sentences. Change one, change both.
124
+
125
+ const DEFAULT_PORT = 3000;
126
+ const PORTS_TO_TRY = 50;
127
+
128
+ /** `--port N`, `--port=N`, `-p N`, or `PORT`; null when nothing was asked. A flag wins. */
129
+ function readPortArg(argv, env) {
130
+ for (let i = 0; i < argv.length; i++) {
131
+ const arg = argv[i];
132
+ if (arg === '--port' || arg === '-p') return parsePort(argv[i + 1], arg);
133
+ if (arg.startsWith('--port=')) return parsePort(arg.slice('--port='.length), '--port');
134
+ }
135
+ if (env.PORT !== undefined && env.PORT !== '') return parsePort(env.PORT, 'PORT');
136
+ return null;
137
+ }
138
+
139
+ function parsePort(value, from) {
140
+ const port = Number(value);
141
+ if (value === undefined || !/^\d+$/.test(String(value)) || port > 65535) {
142
+ throw new Error(`${from} wants a port number between 1 and 65535, and got "${value ?? ''}".`);
143
+ }
144
+ return { port, from };
145
+ }
146
+
147
+ /** Whether `host:port` can be bound right now. A host this machine does not have counts as free. */
148
+ function canBind(port, host) {
149
+ return new Promise((resolve) => {
150
+ const probe = net.createServer();
151
+ probe.once('error', (error) => resolve(error.code === 'EADDRNOTAVAIL' || error.code === 'EAFNOSUPPORT'));
152
+ probe.once('listening', () => probe.close(() => resolve(true)));
153
+ probe.listen({ port, host, exclusive: true });
154
+ });
155
+ }
156
+
157
+ async function loopbackFree(port) {
158
+ return (await canBind(port, '127.0.0.1')) && (await canBind(port, '::1'));
159
+ }
160
+
161
+ function takenMessage(asked) {
162
+ const how = asked.from === 'PORT' ? `PORT=${asked.port}` : `${asked.from} ${asked.port}`;
163
+ return (
164
+ `Port ${asked.port} is already in use by another program (you asked for it with ${how}).\n` +
165
+ `Leave the port out to use the first free one from ${DEFAULT_PORT}, or choose another, e.g. --port ${
166
+ asked.port + 1
167
+ }.`
168
+ );
169
+ }
170
+
171
+ /**
172
+ * The port the backend is to listen on. Asked for one: that one, or an error naming it — never a
173
+ * different one, because a host that set `PORT` sends its traffic to exactly that port. Asked for
174
+ * nothing: the first free from 3000, else 0 (the system picks; the backend prints what it got).
175
+ */
176
+ async function choosePort(argv, env) {
177
+ const asked = readPortArg(argv, env);
178
+ if (asked) {
179
+ if (asked.port !== 0 && !(await loopbackFree(asked.port))) throw new Error(takenMessage(asked));
180
+ return asked.port;
181
+ }
182
+ for (let port = DEFAULT_PORT; port < DEFAULT_PORT + PORTS_TO_TRY && port <= 65535; port++) {
183
+ if (await loopbackFree(port)) return port;
184
+ }
185
+ return 0;
186
+ }
187
+
188
+ /**
189
+ * Replace the installed cloud functions with the ones this deploy carries. Returns the files: one
190
+ * per project (`<project>.workflow.json`), each holding all of its functions.
191
+ */
192
+ function installFunctions(dataDir) {
193
+ const from = path.join(BACKEND, 'workflows');
194
+ const to = path.join(dataDir, 'workflows');
195
+ const carried = fs.existsSync(from) ? fs.readdirSync(from).filter((f) => f.endsWith(WORKFLOW_SUFFIX)) : [];
196
+ if (carried.length === 0 && !fs.existsSync(to)) return carried;
197
+ fs.mkdirSync(to, { recursive: true });
198
+ for (const old of fs.readdirSync(to).filter((f) => f.endsWith(WORKFLOW_SUFFIX))) {
199
+ fs.unlinkSync(path.join(to, old));
200
+ }
201
+ for (const file of carried) fs.copyFileSync(path.join(from, file), path.join(to, file));
202
+ return carried;
203
+ }
204
+
205
+ /**
206
+ * What to do with the access rules, decided before anything is written:
207
+ *
208
+ * none the deploy ships no rules; the backend keeps (or mints) its own
209
+ * unchanged the rules this deploy ships are the ones the last deploy installed
210
+ * install the backend has no rules yet (a first start): it installs the project's
211
+ * replace the backend has rules, and this deploy ships different ones (or no deploy recorded
212
+ * which it installed — a box from before this file existed)
213
+ *
214
+ * A rules file that is not JSON stops here, before the backend's rules are moved; the backend
215
+ * validates the rest when it installs them, in its own words.
216
+ */
217
+ function planRules(dataDir) {
218
+ const shipped = path.join(BACKEND, POLICY_FILE);
219
+ const current = path.join(dataDir, BACKEND_RULES);
220
+ if (!fs.existsSync(shipped)) return { action: 'none', current };
221
+ const bytes = fs.readFileSync(shipped);
222
+ let config;
223
+ try {
224
+ config = JSON.parse(bytes.toString('utf8'));
225
+ } catch (error) {
226
+ fail(
227
+ `This deploy's access rules (backend/${POLICY_FILE}) are not valid JSON: ${error.message}. Deploy the project again.`
228
+ );
229
+ }
230
+ const sha256 = crypto.createHash('sha256').update(bytes).digest('hex');
231
+ let installed = null;
232
+ try {
233
+ installed = JSON.parse(fs.readFileSync(path.join(dataDir, RULES_MARKER), 'utf8')).sha256;
234
+ } catch {
235
+ installed = null;
236
+ }
237
+ let action = 'replace';
238
+ if (!fs.existsSync(current)) action = 'install';
239
+ else if (installed === sha256) action = 'unchanged';
240
+ return { action, current, shipped, config, sha256 };
241
+ }
242
+
243
+ /** Carry out the plan. Returns where the previous rules were moved, when they were. */
244
+ function applyRules(plan, dataDir) {
245
+ if (plan.action !== 'install' && plan.action !== 'replace') return null;
246
+ let movedTo = null;
247
+ if (plan.action === 'replace') {
248
+ movedTo = `${plan.current}.before-${new Date().toISOString().replace(/[:.]/g, '-')}`;
249
+ fs.renameSync(plan.current, movedTo);
250
+ }
251
+ fs.writeFileSync(
252
+ path.join(dataDir, RULES_MARKER),
253
+ JSON.stringify({ sha256: plan.sha256, installedAt: new Date().toISOString() }, null, 2) + '\n'
254
+ );
255
+ return movedTo;
256
+ }
257
+
258
+ /**
259
+ * 🔴 A data folder with `"devOpen": true` is refused. Dev-open turns every access rule off, and the
260
+ * backend's interlock (BAK-003) refuses it only when the bind is not loopback — but a deployed
261
+ * folder normally binds 127.0.0.1 with a proxy in front, so the interlock never fires and the app
262
+ * is public with no rules at all (P26-VMOPEN: OWN-016's VM layout ran exactly that way). A first
263
+ * start mints `devOpen: false` (`--enforce-rules`), so only data brought from elsewhere trips this.
264
+ * The rules checked are the ones that WILL be enforced: the project's when this start installs
265
+ * them, else the backend's. An unreadable file is left to the backend, which refuses it.
266
+ */
267
+ function refuseDevOpen(plan) {
268
+ const installing = plan.action === 'install' || plan.action === 'replace';
269
+ if (installing) {
270
+ if (plan.config && plan.config.devOpen === true) {
271
+ fail(
272
+ `This deploy's access rules are switched off: backend/${POLICY_FILE} has "devOpen": true. With them off, ` +
273
+ 'anyone who can reach the app reads and changes every record, every account included, so a deployed app ' +
274
+ 'does not start that way.\n' +
275
+ `Set "devOpen": false in the project's ${POLICY_FILE} and deploy again.`
276
+ );
277
+ }
278
+ return;
279
+ }
280
+ let config;
281
+ try {
282
+ config = JSON.parse(fs.readFileSync(plan.current, 'utf8'));
283
+ } catch {
284
+ return;
285
+ }
286
+ if (!config || config.devOpen !== true) return;
287
+ fail(
288
+ `This app's access rules are switched off: ${plan.current} has "devOpen": true. With them off, anyone who can ` +
289
+ 'reach the app reads and changes every record, every account included, so a deployed app does not start ' +
290
+ 'that way.\n' +
291
+ 'That data folder most likely came from a development backend. Set "devOpen": false in that file and ' +
292
+ "start this again (or ship the project's own rules); the rules written in the file then apply."
293
+ );
294
+ }
295
+
296
+ /** The `[start]` line for the rules. */
297
+ function describeRules(plan, movedTo) {
298
+ switch (plan.action) {
299
+ case 'none':
300
+ return "[start] access rules: this deploy carries none; the backend's own apply\n";
301
+ case 'unchanged':
302
+ return `[start] access rules: data/${BACKEND_RULES}, unchanged (this deploy's are the ones already installed)\n`;
303
+ case 'install':
304
+ return `[start] access rules: installing this deploy's backend/${POLICY_FILE}\n`;
305
+ default:
306
+ return (
307
+ `[start] access rules: replaced with this deploy's backend/${POLICY_FILE}; the previous ones are kept ` +
308
+ `as ${movedTo}\n`
309
+ );
310
+ }
311
+ }
312
+
313
+ /** The project's name, as the deploy wrote it into `package.json`; `/health` reports it. */
314
+ function appName() {
315
+ try {
316
+ const pkg = JSON.parse(fs.readFileSync(path.join(HERE, 'package.json'), 'utf8'));
317
+ return pkg.nodegx && typeof pkg.nodegx.name === 'string' && pkg.nodegx.name ? pkg.nodegx.name : null;
318
+ } catch {
319
+ return null;
320
+ }
321
+ }
322
+
323
+ async function main() {
324
+ checkNode();
325
+ if (!fs.existsSync(path.join(APP, 'index.html'))) {
326
+ fail(`This folder has no app/index.html, so there is no app to serve. Deploy the project again.`);
327
+ }
328
+ if (!fs.existsSync(path.join(BACKEND, 'cli.js'))) {
329
+ fail(`This folder has no backend/cli.js, so there is no backend to start. Deploy the project again.`);
330
+ }
331
+
332
+ const argv = process.argv.slice(2);
333
+ let port;
334
+ let dataDir;
335
+ try {
336
+ dataDir = readDataDir(argv);
337
+ if (argv.includes('--check')) {
338
+ // The port is not asked about: the app this would replace is usually the one holding it.
339
+ const plan = planRules(dataDir);
340
+ refuseDevOpen(plan);
341
+ process.stdout.write(describeRules(plan, `${plan.current}.before-<time>`).replace('[start]', '[check]'));
342
+ process.exit(0);
343
+ }
344
+ port = await choosePort(argv, process.env);
345
+ } catch (error) {
346
+ fail(error && error.message ? error.message : String(error));
347
+ }
348
+
349
+ fs.mkdirSync(dataDir, { recursive: true });
350
+ const rules = planRules(dataDir);
351
+ refuseDevOpen(rules);
352
+ const movedTo = applyRules(rules, dataDir);
353
+ process.stdout.write(describeRules(rules, movedTo));
354
+ const functions = installFunctions(dataDir);
355
+ process.stdout.write(
356
+ functions.length
357
+ ? `[start] cloud functions: installed ${functions.map((f) => `backend/workflows/${f}`).join(', ')}\n`
358
+ : '[start] cloud functions: none in this deploy\n'
359
+ );
360
+
361
+ const args = ['serve', '--data-dir', dataDir, '--port', String(port), '--serve-app', APP, '--enforce-rules'];
362
+ // The backend installs the project's rules only when it has none, and says so loudly when it
363
+ // does not — so it is told about them only on a start that installs them.
364
+ if (rules.action === 'install' || rules.action === 'replace') args.push('--project-dir', BACKEND);
365
+ const name = appName();
366
+ if (name) args.push('--backend-name', name);
367
+ args.push(...withoutOwnArgs(argv));
368
+
369
+ const backend = require(path.join(BACKEND, 'cli.js'));
370
+ await backend.main(args);
371
+ }
372
+
373
+ main().catch((error) => {
374
+ // The backend's own words for a refusal (a taken port, a bad policy file) — the same line
375
+ // `node backend/cli.js serve` prints.
376
+ process.stderr.write(`[nodegx-backend] FATAL: ${error && error.message ? error.message : error}\n`);
377
+ process.exit(1);
378
+ });
@@ -0,0 +1,4 @@
1
+ #!/bin/sh
2
+ # Starts this NodeGX app — see DEPLOY.md. Options: --port <n> (default: the first free port from 3000).
3
+ command -v node >/dev/null 2>&1 || { echo "This app needs Node.js 22.13 or later: https://nodejs.org" >&2; exit 1; }
4
+ exec node "$(dirname "$0")/start.js" "$@"
@@ -0,0 +1,194 @@
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
+ #
25
+ # and, once Caddy no longer points at it, removes /srv/<app>/ — the site the earlier layout (a site plus a loose
26
+ # cli.js) served from.
27
+ #
28
+ # The backend binds 127.0.0.1:<port> and serves the app AND its API on that one origin (`--serve-app`, set by
29
+ # start.js); Caddy is the only way in from outside. Behind a proxy every request arrives from loopback, so the admin
30
+ # surface is NOT proxied, and --no-admin is set. Secrets reach cloud functions through the backend's
31
+ # NODEGX_SECRET_<NAME> environment fallback (SecretsStore), from an EnvironmentFile only root and the app user read.
32
+
33
+ set -euo pipefail
34
+
35
+ APP="${APP:?APP=<name>}"
36
+ PORT="${PORT:?PORT=<loopback port>}"
37
+ CADDY="${CADDY:-on}"
38
+ DOMAIN="${DOMAIN:-}"
39
+ [ "$CADDY" = off ] || [ -n "$DOMAIN" ] || { echo "FATAL: DOMAIN=<host> is needed unless CADDY=off" >&2; exit 1; }
40
+ [[ "$APP" =~ ^[a-z][a-z0-9-]{1,30}$ ]] || { echo "FATAL: APP must be a short lowercase name" >&2; exit 1; }
41
+ [[ "$PORT" =~ ^[0-9]{2,5}$ ]] || { echo "FATAL: PORT must be a port number" >&2; exit 1; }
42
+
43
+ APP_USER="$APP"
44
+ FOLDER="/opt/$APP"
45
+ DATA_DIR="/var/lib/$APP/data"
46
+ OLD_SITE="/srv/$APP"
47
+ SECRETS="/etc/$APP/secrets.env"
48
+ UNIT="/etc/systemd/system/$APP-backend.service"
49
+ CADDY_MAIN=/etc/caddy/Caddyfile
50
+ CADDY_DROPIN="/etc/caddy/conf.d/$APP.caddy"
51
+ STAGE="/tmp/$APP-provision"
52
+
53
+ for f in start.js app/index.html backend/cli.js MANIFEST.json; do
54
+ [ -f "$STAGE/$f" ] || { echo "FATAL: $STAGE/$f missing — stage a full-stack folder (run ship-to-vm.sh)" >&2; exit 1; }
55
+ done
56
+ NODE="$(command -v node || true)"
57
+ [ -n "$NODE" ] || { echo "FATAL: this box has no node; the backend needs Node 22.13 or later" >&2; exit 1; }
58
+ "$NODE" -e 'const m=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8")); process.exit(m.layout==="full-stack"?0:1)' \
59
+ "$STAGE/MANIFEST.json" || { echo "FATAL: $STAGE is not a full-stack deploy (MANIFEST.json layout)" >&2; exit 1; }
60
+ # start.js checks this too, but by then the service is installed; say it before anything is written.
61
+ "$NODE" -e 'require("node:sqlite")' 2>/dev/null \
62
+ || { echo "FATAL: the backend needs Node 22.13 or later (node:sqlite); this box has $("$NODE" --version)" >&2; exit 1; }
63
+ if ss -tln | awk '{print $4}' | grep -qE ":${PORT}\$" && ! systemctl is-active --quiet "$APP-backend"; then
64
+ echo "FATAL: port $PORT is taken by something else" >&2; exit 1
65
+ fi
66
+ if [ "$CADDY" != off ]; then
67
+ [ -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; }
68
+ # A freshly installed Caddy's file has no such line, so a stranger's first ship with a domain stops here. Adding it
69
+ # is theirs to do: this script never edits the main file, which other sites on a shared server depend on.
70
+ grep -qE '^\s*import\s+/etc/caddy/conf\.d/' "$CADDY_MAIN" || {
71
+ cat >&2 <<REFUSAL
72
+ FATAL: $CADDY_MAIN does not read /etc/caddy/conf.d/, where each app's site goes (a freshly installed Caddy's
73
+ file does not). Nothing was changed. ON THE SERVER, add the line once, then ship again:
74
+
75
+ mkdir -p /etc/caddy/conf.d && echo 'import /etc/caddy/conf.d/*' >> $CADDY_MAIN && systemctl reload caddy
76
+ REFUSAL
77
+ exit 1
78
+ }
79
+ fi
80
+ # 🔴 P26-VMOPEN: the earlier layout ran `cli.js serve` without --enforce-rules, so its first start minted
81
+ # security.json with "devOpen": true, and on a 127.0.0.1 bind the backend's interlock never refused it — the app was
82
+ # public behind Caddy with every access rule off. The staged start.js decides what a start will do with the rules
83
+ # (the project's replace the server's when this ship brings different ones — Richard, 2026-10-23) and refuses
84
+ # rules that are switched off. Ask it HERE, before anything is written, so a refusal leaves the running app as it is.
85
+ if ! CHECK="$("$NODE" "$STAGE/start.js" --check --data-dir "$DATA_DIR" 2>&1)"; then
86
+ echo "$CHECK" | sed 's/^/ /' >&2
87
+ cat >&2 <<REFUSAL
88
+ FATAL: refused before anything was changed: the app that is running now keeps running as it is.
89
+ If the file named is $DATA_DIR/security.json, either give the project its own rules (nodegx.security.json, which
90
+ a ship installs) or switch the server's on — ON THE SERVER (ssh in first; on a Mac this sed refuses the command):
91
+
92
+ sed -i 's/"devOpen": *true/"devOpen": false/' $DATA_DIR/security.json && systemctl restart $APP-backend
93
+
94
+ then ship again.
95
+ REFUSAL
96
+ exit 1
97
+ fi
98
+ echo "$CHECK" | sed 's/^/ /'
99
+
100
+ id -u "$APP_USER" >/dev/null 2>&1 || useradd --system --home-dir "/var/lib/$APP" --shell /usr/sbin/nologin "$APP_USER"
101
+ mkdir -p "$FOLDER" "$DATA_DIR" "/etc/$APP"
102
+
103
+ # The folder replaces what is there, wholesale (`app/` and `backend/` included). `data/` is excluded in case a folder
104
+ # was ever started by hand: the service's data is $DATA_DIR, never inside the folder.
105
+ rsync -a --delete --exclude=/data/ "$STAGE/" "$FOLDER/"
106
+ chown -R root:root "$FOLDER"; chmod -R u+rwX,go+rX,go-w "$FOLDER"
107
+ [ -f "$SECRETS" ] || install -o root -g "$APP_USER" -m 640 /dev/null "$SECRETS"
108
+ chown root:"$APP_USER" "$SECRETS"; chmod 640 "$SECRETS"
109
+ chown -R "$APP_USER":"$APP_USER" "/var/lib/$APP"; chmod 750 "/var/lib/$APP"
110
+
111
+ cat > "$UNIT" <<UNIT
112
+ [Unit]
113
+ Description=${APP} (NodeGX full-stack folder, P26 DEP-002)
114
+ After=network-online.target
115
+ Wants=network-online.target
116
+
117
+ [Service]
118
+ Type=simple
119
+ User=${APP_USER}
120
+ Group=${APP_USER}
121
+ WorkingDirectory=/var/lib/${APP}
122
+ EnvironmentFile=${SECRETS}
123
+ ExecStart=${NODE} ${FOLDER}/start.js --port ${PORT} --host 127.0.0.1 --data-dir ${DATA_DIR} --backend-id ${APP} --no-admin
124
+ Restart=always
125
+ RestartSec=3
126
+
127
+ NoNewPrivileges=true
128
+ PrivateTmp=true
129
+ ProtectSystem=strict
130
+ ProtectHome=true
131
+ ReadWritePaths=/var/lib/${APP}
132
+ ProtectKernelTunables=true
133
+ ProtectControlGroups=true
134
+ RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
135
+ MemoryMax=384M
136
+
137
+ [Install]
138
+ WantedBy=multi-user.target
139
+ UNIT
140
+
141
+ if [ "$CADDY" != off ]; then
142
+ # Everything but the closed admin surface goes to the backend, which serves the app beside its API and answers
143
+ # /nodegx-config.json with this origin (Host + X-Forwarded-Proto, both of which reverse_proxy passes).
144
+ cat > "$CADDY_DROPIN" <<CADDYFILE
145
+ ${DOMAIN} {
146
+ encode zstd gzip
147
+
148
+ header {
149
+ X-Content-Type-Options nosniff
150
+ Referrer-Policy strict-origin-when-cross-origin
151
+ Strict-Transport-Security "max-age=31536000; includeSubDomains"
152
+ -Server
153
+ }
154
+
155
+ @closed path_regexp closed ^/(_admin|admin|executions|metrics)(/|$)
156
+ handle @closed {
157
+ respond 404
158
+ }
159
+
160
+ handle {
161
+ reverse_proxy 127.0.0.1:${PORT}
162
+ }
163
+ }
164
+ CADDYFILE
165
+ if ! caddy validate --config "$CADDY_MAIN" --adapter caddyfile >/dev/null 2>&1; then
166
+ echo "FATAL: Caddy config invalid with the new drop-in — removing it, running config untouched." >&2
167
+ rm -f "$CADDY_DROPIN"
168
+ caddy validate --config "$CADDY_MAIN" --adapter caddyfile >&2 || true
169
+ exit 1
170
+ fi
171
+ fi
172
+
173
+ systemctl daemon-reload
174
+ systemctl enable "$APP-backend" >/dev/null
175
+ # This start's lines only: the journal also holds every earlier start's, which said other things about the rules.
176
+ STARTED="$(date '+%Y-%m-%d %H:%M:%S')"
177
+ systemctl restart "$APP-backend"
178
+ for i in $(seq 1 20); do
179
+ curl -fsS "http://127.0.0.1:${PORT}/health" >/dev/null 2>&1 && break
180
+ sleep 1
181
+ done
182
+ 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; }
183
+ 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; }
184
+ # What start.js installed and which access rules are in force, in the backend's own words.
185
+ journalctl -u "$APP-backend" --since "$STARTED" --no-pager -o cat | grep -E '^\[start\]|polic|rules' | sed 's/^/ /' || true
186
+
187
+ [ "$CADDY" != off ] && systemctl reload caddy
188
+ # Only once no drop-in can still point at it: CADDY=off over an earlier domain ship leaves that drop-in in place.
189
+ if [ -d "$OLD_SITE" ] && { [ "$CADDY" != off ] || [ ! -f "$CADDY_DROPIN" ]; }; then
190
+ rm -rf "$OLD_SITE"
191
+ echo " removed $OLD_SITE (the earlier layout's site; the backend serves the app now)"
192
+ fi
193
+ rm -rf "$STAGE"
194
+ echo "==> provisioned ${APP} on 127.0.0.1:${PORT}${DOMAIN:+, https://$DOMAIN}"