@nodegx/mcp 0.2.6 → 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.
@@ -32,6 +32,11 @@
32
32
  * (`security.json.before-<time>`) and the backend installs the project's. A restart, or a deploy
33
33
  * with the same rules, leaves `data/security.json` as it is (Richard, 2026-10-23: a reship
34
34
  * brings the project's rules; an edit made on the server lasts until the project's change).
35
+ * 4b. Installs the project's workflows and triggers (`backend/nodegx.automations.json`, P127
36
+ * BKO-001) by the same rule: on a first start, and after a deploy that changed them, the
37
+ * backend's `data/triggers.json` and `data/workflow-defs/` are moved aside (`.before-<time>`) and
38
+ * the backend loads the project's. Webhook secrets stay in `data/secrets.json`, so a sender's
39
+ * secret survives a reship. A restart, or a deploy with the same ones, leaves them as they are.
35
40
  * 5. Refuses rules that are switched off (`"devOpen": true`), which is how a development backend's
36
41
  * data arrives — and the backend's own interlock only refuses that on a public bind, while this
37
42
  * app is usually behind a proxy on 127.0.0.1. Refused before anything in the data is changed.
@@ -58,6 +63,13 @@ const BACKEND_RULES = 'security.json';
58
63
  /** Which rules the last deploy installed (their checksum), in the data folder. */
59
64
  const RULES_MARKER = 'deployed-rules.json';
60
65
  const WORKFLOW_SUFFIX = '.workflow.json';
66
+ /** The project's workflows and triggers (P127 BKO-001). */
67
+ const AUTOMATIONS_FILE = 'nodegx.automations.json';
68
+ /** The backend's own copies of them, in the data folder. */
69
+ const BACKEND_TRIGGERS = 'triggers.json';
70
+ const BACKEND_WORKFLOWS = 'workflow-defs';
71
+ /** Which automations the last deploy installed (their checksum), in the data folder. */
72
+ const AUTOMATIONS_MARKER = 'deployed-automations.json';
61
73
 
62
74
  /** The Node this app needs, and the one property that decides it. */
63
75
  const NODE_NEEDED = '22.13';
@@ -240,6 +252,74 @@ function planRules(dataDir) {
240
252
  return { action, current, shipped, config, sha256 };
241
253
  }
242
254
 
255
+ /**
256
+ * What to do with the project's workflows and triggers: the same four answers as `planRules`. The
257
+ * backend has some when it has a `triggers.json` or any workflow definition.
258
+ */
259
+ function planAutomations(dataDir) {
260
+ const shipped = path.join(BACKEND, AUTOMATIONS_FILE);
261
+ const triggers = path.join(dataDir, BACKEND_TRIGGERS);
262
+ const workflows = path.join(dataDir, BACKEND_WORKFLOWS);
263
+ if (!fs.existsSync(shipped)) return { action: 'none', triggers, workflows };
264
+ const bytes = fs.readFileSync(shipped);
265
+ try {
266
+ JSON.parse(bytes.toString('utf8'));
267
+ } catch (error) {
268
+ fail(
269
+ `This deploy's workflows and triggers (backend/${AUTOMATIONS_FILE}) are not valid JSON: ${error.message}. Deploy the project again.`
270
+ );
271
+ }
272
+ const sha256 = crypto.createHash('sha256').update(bytes).digest('hex');
273
+ let installed = null;
274
+ try {
275
+ installed = JSON.parse(fs.readFileSync(path.join(dataDir, AUTOMATIONS_MARKER), 'utf8')).sha256;
276
+ } catch {
277
+ installed = null;
278
+ }
279
+ const hasSome = fs.existsSync(triggers) || (fs.existsSync(workflows) && fs.readdirSync(workflows).length > 0);
280
+ let action = 'replace';
281
+ if (!hasSome) action = 'install';
282
+ else if (installed === sha256) action = 'unchanged';
283
+ return { action, triggers, workflows, sha256 };
284
+ }
285
+
286
+ /** Carry out the plan. Returns where the previous ones were moved, when they were. */
287
+ function applyAutomations(plan, dataDir) {
288
+ if (plan.action !== 'install' && plan.action !== 'replace') return [];
289
+ const moved = [];
290
+ if (plan.action === 'replace') {
291
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
292
+ for (const from of [plan.triggers, plan.workflows]) {
293
+ if (!fs.existsSync(from)) continue;
294
+ const to = `${from}.before-${stamp}`;
295
+ fs.renameSync(from, to);
296
+ moved.push(to);
297
+ }
298
+ }
299
+ fs.writeFileSync(
300
+ path.join(dataDir, AUTOMATIONS_MARKER),
301
+ JSON.stringify({ sha256: plan.sha256, installedAt: new Date().toISOString() }, null, 2) + '\n'
302
+ );
303
+ return moved;
304
+ }
305
+
306
+ function describeAutomations(plan, moved) {
307
+ switch (plan.action) {
308
+ case 'none':
309
+ return "[start] workflows and triggers: this deploy carries none; the backend's own run\n";
310
+ case 'unchanged':
311
+ return '[start] workflows and triggers: unchanged (this deploy\'s are the ones already installed)\n';
312
+ case 'install':
313
+ return `[start] workflows and triggers: installing this deploy's backend/${AUTOMATIONS_FILE}\n`;
314
+ default:
315
+ return (
316
+ `[start] workflows and triggers: replaced with this deploy's backend/${AUTOMATIONS_FILE}` +
317
+ (moved.length ? `; the previous ones are kept as ${moved.join(' and ')}` : '') +
318
+ '\n'
319
+ );
320
+ }
321
+ }
322
+
243
323
  /** Carry out the plan. Returns where the previous rules were moved, when they were. */
244
324
  function applyRules(plan, dataDir) {
245
325
  if (plan.action !== 'install' && plan.action !== 'replace') return null;
@@ -339,6 +419,10 @@ async function main() {
339
419
  const plan = planRules(dataDir);
340
420
  refuseDevOpen(plan);
341
421
  process.stdout.write(describeRules(plan, `${plan.current}.before-<time>`).replace('[start]', '[check]'));
422
+ const automationsPlan = planAutomations(dataDir);
423
+ process.stdout.write(
424
+ describeAutomations(automationsPlan, [`${BACKEND_TRIGGERS}.before-<time>`, `${BACKEND_WORKFLOWS}.before-<time>`]).replace('[start]', '[check]')
425
+ );
342
426
  process.exit(0);
343
427
  }
344
428
  port = await choosePort(argv, process.env);
@@ -349,8 +433,10 @@ async function main() {
349
433
  fs.mkdirSync(dataDir, { recursive: true });
350
434
  const rules = planRules(dataDir);
351
435
  refuseDevOpen(rules);
436
+ const automations = planAutomations(dataDir);
352
437
  const movedTo = applyRules(rules, dataDir);
353
438
  process.stdout.write(describeRules(rules, movedTo));
439
+ process.stdout.write(describeAutomations(automations, applyAutomations(automations, dataDir)));
354
440
  const functions = installFunctions(dataDir);
355
441
  process.stdout.write(
356
442
  functions.length
@@ -361,7 +447,9 @@ async function main() {
361
447
  const args = ['serve', '--data-dir', dataDir, '--port', String(port), '--serve-app', APP, '--enforce-rules'];
362
448
  // The backend installs the project's rules only when it has none, and says so loudly when it
363
449
  // 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);
450
+ // The same for the workflows and triggers, which it loads only into a backend that has none.
451
+ const installs = (plan) => plan.action === 'install' || plan.action === 'replace';
452
+ if (installs(rules) || installs(automations)) args.push('--project-dir', BACKEND);
365
453
  const name = appName();
366
454
  if (name) args.push('--backend-name', name);
367
455
  args.push(...withoutOwnArgs(argv));
@@ -21,6 +21,8 @@
21
21
  # /etc/<app>/secrets.env (written by ship-to-vm.sh over stdin, never here, never in the folder)
22
22
  # /etc/systemd/system/<app>-backend.service
23
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
24
26
  #
25
27
  # and, once Caddy no longer points at it, removes /srv/<app>/ — the site the earlier layout (a site plus a loose
26
28
  # cli.js) served from.
@@ -108,6 +110,44 @@ chown -R root:root "$FOLDER"; chmod -R u+rwX,go+rX,go-w "$FOLDER"
108
110
  chown root:"$APP_USER" "$SECRETS"; chmod 640 "$SECRETS"
109
111
  chown -R "$APP_USER":"$APP_USER" "/var/lib/$APP"; chmod 750 "/var/lib/$APP"
110
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
+
111
151
  cat > "$UNIT" <<UNIT
112
152
  [Unit]
113
153
  Description=${APP} (NodeGX full-stack folder, P26 DEP-002)
@@ -120,6 +160,7 @@ User=${APP_USER}
120
160
  Group=${APP_USER}
121
161
  WorkingDirectory=/var/lib/${APP}
122
162
  EnvironmentFile=${SECRETS}
163
+ Environment=NODE_PATH=${SHARP_DIR}/node_modules
123
164
  ExecStart=${NODE} ${FOLDER}/start.js --port ${PORT} --host 127.0.0.1 --data-dir ${DATA_DIR} --backend-id ${APP} --no-admin
124
165
  Restart=always
125
166
  RestartSec=3