4bnode 4.1.9 → 4.2.1

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/add-bonjour.js CHANGED
@@ -2,7 +2,8 @@
2
2
 
3
3
  import fs from "fs";
4
4
  import path from "path";
5
- import { input } from "@inquirer/prompts";
5
+ import { input, select } from "@inquirer/prompts";
6
+ import { pathToFileURL } from "url";
6
7
  import { getProjectRoot } from "./lib/project.js";
7
8
  import { updateEnvFile } from "./lib/env.js";
8
9
  import { readIndex, writeIndex } from "./lib/indexFile.js";
@@ -45,6 +46,30 @@ async function main() {
45
46
  fs.writeFileSync(discoveryPath, buildDiscoveryModule(), "utf8");
46
47
  showFileAction(existed ? "updated" : "created", "src/discovery.js");
47
48
 
49
+ // 1b. Which network to advertise on. Load the module just written so the CLI
50
+ // detects networks exactly like the running app and the dashboard do.
51
+ let iface = "";
52
+ try {
53
+ const { listNetworkInterfaces } = await import(pathToFileURL(discoveryPath).href);
54
+ const nets = listNetworkInterfaces();
55
+ if (nets.length > 1) {
56
+ iface = await select({
57
+ message: "Several networks are connected. Which one should Bonjour advertise on?",
58
+ choices: [
59
+ ...nets.map((n) => ({
60
+ name: `${n.label} ${n.address}${n.selfAssigned ? " (no network: self-assigned IP)" : ""}`,
61
+ value: n.name,
62
+ })),
63
+ { name: "All networks", value: "" },
64
+ ],
65
+ });
66
+ } else if (nets.length === 1) {
67
+ showInfo(`Network: ${nets[0].label} ${nets[0].address}`);
68
+ }
69
+ } catch {
70
+ // Detection is best-effort; blank means "all networks".
71
+ }
72
+
48
73
  // 2. Wire it into index.js (idempotent — a no-op if already wired).
49
74
  try {
50
75
  const before = readIndex();
@@ -66,6 +91,7 @@ async function main() {
66
91
 
67
92
  // 3. Configuration in .env.
68
93
  updateEnvFile(envPath, "BONJOUR_ENABLED", "on");
94
+ updateEnvFile(envPath, "BONJOUR_INTERFACE", iface);
69
95
  if (name) updateEnvFile(envPath, "BONJOUR_NAME", name);
70
96
  if (type && type !== "http") updateEnvFile(envPath, "BONJOUR_TYPE", type);
71
97
  showFileAction("updated", ".env");
@@ -76,8 +102,10 @@ async function main() {
76
102
  showTaskDone("Bonjour / mDNS discovery enabled", [
77
103
  name ? `Service name: ${name}` : "Service name: app name + hostname",
78
104
  `Service type: _${type}._tcp`,
105
+ `Network: ${iface || "all networks"} (change with BONJOUR_INTERFACE or in the dashboard)`,
79
106
  "Advertises once the server is listening; sends goodbye packets on clean shutdown",
80
107
  "Browse peers on the network from the /_dev dashboard → Discovery",
108
+ "Bonjour is off by default in new projects; this command turned it on (BONJOUR_ENABLED=on)",
81
109
  "Disable anytime with BONJOUR_ENABLED=off in .env",
82
110
  ]);
83
111
  }
package/add-email.js CHANGED
@@ -3,6 +3,7 @@
3
3
  import fs from "fs";
4
4
  import path from "path";
5
5
  import os from "os";
6
+ import crypto from "crypto";
6
7
  import { createRequire } from "module";
7
8
  import { pathToFileURL } from "url";
8
9
  import { input, select, confirm, password } from "@inquirer/prompts";
@@ -15,6 +16,8 @@ import {
15
16
  isOfficialMailDomain,
16
17
  OFFICIAL_MAIL_DOMAIN_PASSWORD,
17
18
  OFFICIAL_MAIL_NOTIFY,
19
+ SANCHARAK_API_URL,
20
+ sancharakKeyProblem,
18
21
  } from "./lib/codegen.js";
19
22
 
20
23
  // The 4brains.in domain can't be configured directly — require the shared
@@ -48,11 +51,18 @@ async function sendOfficialNotify(root, ctx) {
48
51
  "Time: " + new Date().toISOString(),
49
52
  ].join("\n");
50
53
 
51
- if (ctx.kind === "resend") {
52
- const { Resend } = await import(pathToFileURL(require.resolve("resend")).href);
53
- const resend = new Resend(ctx.apiKey);
54
- const { error } = await resend.emails.send({ from: ctx.from, to: OFFICIAL_MAIL_NOTIFY, subject, text });
55
- if (error) throw new Error(error.message || "Resend error");
54
+ if (ctx.kind === "sancharak") {
55
+ const res = await fetch(SANCHARAK_API_URL, {
56
+ method: "POST",
57
+ headers: {
58
+ Authorization: "Bearer " + ctx.apiKey,
59
+ "Content-Type": "application/json",
60
+ "Idempotency-Key": crypto.randomUUID(),
61
+ },
62
+ body: JSON.stringify({ from: ctx.from, to: [OFFICIAL_MAIL_NOTIFY], subject, text, stream: "tx" }),
63
+ signal: AbortSignal.timeout(15000),
64
+ });
65
+ if (!res.ok) throw new Error("Sancharak " + res.status + ": " + (await res.text()).slice(0, 200));
56
66
  return;
57
67
  }
58
68
  const nodemailer = (await import(pathToFileURL(require.resolve("nodemailer")).href)).default;
@@ -86,17 +96,23 @@ async function main() {
86
96
  let dep;
87
97
  let notifyCtx = null; // set when an authorized 4brains.in sender is configured
88
98
 
89
- if (preset.type === "resend") {
90
- // IntraApp(Postmaster) → Resend API. Only an API key + from address.
91
- const apiKey = (await password({ message: "Resend API key (re_...):", mask: "*" })).trim();
92
- const from = (await input({ message: "From address:", default: "" })).trim();
99
+ if (preset.type === "sancharak") {
100
+ // Sancharak HTTP API: an API key + From (+ optional Reply-to). No package needed.
101
+ const apiKey = (await password({
102
+ message: "Sancharak API key (snk_...):",
103
+ mask: "*",
104
+ validate: (v) => (!v.trim() ? true : sancharakKeyProblem(v) || true),
105
+ })).trim();
106
+ const from = (await input({ message: "From address (e.g. Your App <hello@yourdomain.com>):", default: "" })).trim();
107
+ const replyTo = (await input({ message: "Reply-to address (optional):", default: "" })).trim();
93
108
  await ensureOfficialAllowed(from);
94
109
  if (isOfficialMailDomain(from)) updateEnvFile(envPath, "OFFICIAL_MAIL_AUTHORIZED", "1");
95
- if (apiKey) updateEnvFile(envPath, "RESEND_API_KEY", apiKey);
110
+ if (apiKey) updateEnvFile(envPath, "SANCHARAK_API_KEY", apiKey);
96
111
  if (from) updateEnvFile(envPath, "MAIL_FROM", from);
97
- dep = "resend";
112
+ if (replyTo) updateEnvFile(envPath, "MAIL_REPLY_TO", replyTo);
113
+ dep = null;
98
114
  summary.push(from ? `From: ${from}` : "From: (set MAIL_FROM in .env)");
99
- if (isOfficialMailDomain(from) && apiKey) notifyCtx = { kind: "resend", from, apiKey };
115
+ if (isOfficialMailDomain(from) && apiKey) notifyCtx = { kind: "sancharak", from, apiKey };
100
116
  } else {
101
117
  // Custom SMTP → nodemailer. TLS selects the port (465 vs 587).
102
118
  const host = (await input({ message: "SMTP host:", default: preset.host || "" })).trim();
@@ -123,15 +139,17 @@ async function main() {
123
139
  showFileAction("updated", ".env");
124
140
 
125
141
  fs.mkdirSync(dir, { recursive: true });
126
- // Rewrite the mailer only if missing or on the old single-backend version.
127
- if (!fs.existsSync(mailerPath) || !fs.readFileSync(mailerPath, "utf8").includes("MAIL_PROVIDER")) {
142
+ // Rewrite the mailer if missing, on the old single-backend version, or on the
143
+ // old Postmaster (Resend) version that Sancharak replaces.
144
+ const existingMailer = fs.existsSync(mailerPath) ? fs.readFileSync(mailerPath, "utf8") : "";
145
+ if (!existingMailer || !existingMailer.includes("MAIL_PROVIDER") || existingMailer.includes("import('resend')")) {
128
146
  fs.writeFileSync(mailerPath, buildMailerService(), "utf8");
129
147
  showFileAction(fs.existsSync(mailerPath) ? "updated" : "created", "src/services/mailer.js");
130
148
  } else {
131
149
  showInfo("src/services/mailer.js already up to date — keeping it.");
132
150
  }
133
151
 
134
- await installDeps(dep);
152
+ if (dep) await installDeps(dep);
135
153
 
136
154
  if (notifyCtx) {
137
155
  try {
package/add-serialport.js CHANGED
@@ -1,71 +1,163 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ import fs from "fs";
3
4
  import path from "path";
4
- import { input } from "@inquirer/prompts";
5
+ import { pathToFileURL } from "url";
6
+ import { input, select, confirm } from "@inquirer/prompts";
5
7
  import { getProjectRoot } from "./lib/project.js";
6
- import { updateEnvFile } from "./lib/env.js";
7
- import { addImport, insertBeforeListen } from "./lib/indexFile.js";
8
+ import { readIndex, writeIndex } from "./lib/indexFile.js";
8
9
  import {
9
10
  showHeader,
10
11
  showTaskDone,
11
12
  showError,
13
+ showInfo,
12
14
  showFileAction,
13
15
  installDeps,
14
16
  } from "./lib/ui.js";
17
+ import {
18
+ buildSerialDeviceModule,
19
+ buildSerialDevicesModule,
20
+ buildSerialRouter,
21
+ normalizeSerialDevice,
22
+ wireSerialIntoIndex,
23
+ SERIAL_DEPS,
24
+ SERIAL_BAUD_RATES,
25
+ } from "./lib/codegen.js";
15
26
 
16
27
  async function main() {
17
- showHeader("add-serialport", "Add serial port communication");
28
+ showHeader("add-serialport", "Talk to Arduino / ESP32 boards over USB serial");
18
29
 
19
- const serialPortPath = await input({
20
- message: "Serial port path:",
21
- default: process.platform === "win32" ? "COM3" : "/dev/tty-usbserial1",
22
- });
30
+ showInfo(
31
+ "Devices are found by USB serial number, USB vendor id, or a firmware handshake " +
32
+ "(ID?), never a fixed COM port. They reconnect by themselves after a reset or unplug.",
33
+ );
23
34
 
24
- const baudRate = await input({
25
- message: "Baud rate:",
26
- default: "9600",
27
- validate: (v) => (!isNaN(parseInt(v, 10)) ? true : "Enter a valid number."),
28
- });
35
+ const root = getProjectRoot();
36
+ const serialDir = path.join(root, "src", "serial");
37
+ const configFile = path.join(serialDir, "devices.json");
29
38
 
30
- console.log();
39
+ // 1. Dependencies first, so the port scan below can run.
40
+ await installDeps(SERIAL_DEPS);
31
41
 
32
- addImport("import { SerialPort } from 'serialport';");
33
- addImport("import { ReadlineParser } from '@serialport/parser-readline';");
34
- showFileAction("updated", "index.js");
42
+ // 2. Generated files (never overwrite ones the user may have edited).
43
+ const files = [
44
+ [path.join(serialDir, "SerialDevice.js"), buildSerialDeviceModule()],
45
+ [path.join(serialDir, "devices.js"), buildSerialDevicesModule()],
46
+ [path.join(root, "src", "routes", "serial.js"), buildSerialRouter()],
47
+ [configFile, "[]\n"],
48
+ ];
49
+ for (const [file, content] of files) {
50
+ if (fs.existsSync(file)) continue;
51
+ fs.mkdirSync(path.dirname(file), { recursive: true });
52
+ fs.writeFileSync(file, content, "utf8");
53
+ showFileAction("created", path.relative(root, file));
54
+ }
35
55
 
36
- const serialportSetup = `
37
- try {
38
- const serialPort = new SerialPort({
39
- baudRate: parseInt(process.env.BAUDRATE, 10) || ${baudRate},
40
- path: process.env.SERIALPORT_PATH || '${serialPortPath}',
41
- });
56
+ // 3. Wire index.js (idempotent).
57
+ const before = readIndex();
58
+ const after = wireSerialIntoIndex(before);
59
+ if (after !== before) {
60
+ writeIndex(after);
61
+ showFileAction("updated", "index.js");
62
+ }
42
63
 
43
- const parser = serialPort.pipe(new ReadlineParser({ delimiter: "\\r\\n" }));
64
+ // 4. Describe the first device, using the boards plugged in right now.
65
+ console.log();
66
+ const mod = await import(pathToFileURL(path.join(serialDir, "SerialDevice.js")).href);
67
+ let ports = [];
68
+ try {
69
+ ports = (await mod.listPorts()).filter((p) => p.usb);
70
+ } catch (e) {
71
+ showInfo("Could not scan ports (" + e.message + "). You can enter the details by hand.");
72
+ }
73
+
74
+ // Auto-detect: nothing plugged in yet? Wait for a board to appear.
75
+ if (!ports.length && (await confirm({ message: "No USB serial board found. Wait while you plug one in?", default: true }))) {
76
+ showInfo("Plug in your board now (waiting up to 60s)…");
77
+ for (let i = 0; i < 60 && !ports.length; i++) {
78
+ await new Promise((r) => setTimeout(r, 1000));
79
+ try {
80
+ ports = (await mod.listPorts()).filter((p) => p.usb);
81
+ } catch {}
82
+ }
83
+ if (ports.length) showInfo(`Detected ${ports.map((p) => `${p.path} (${p.vendor || p.manufacturer || "unknown chip"})`).join(", ")}`);
84
+ else showInfo("Still nothing. Check the cable (some are charge-only) and the driver, or enter the details by hand.");
85
+ }
86
+
87
+ let port = null;
88
+ if (ports.length === 1 && ports[0]) {
89
+ port = ports[0];
90
+ showInfo(`Using ${port.path}`);
91
+ } else if (ports.length) {
92
+ port = await select({
93
+ message: "Which board?",
94
+ choices: [
95
+ ...ports.map((p) => ({
96
+ name: `${p.path} ${p.vendor || p.manufacturer || "unknown chip"} ${p.vendorId}:${p.productId || "????"}${p.serialNumber ? " SN " + p.serialNumber : " (no serial number)"}`,
97
+ value: p,
98
+ })),
99
+ { name: "It's not plugged in / enter details by hand", value: null },
100
+ ],
101
+ });
102
+ }
44
103
 
45
- parser.on("data", async (data) => {
46
- console.log('Received data:', data);
104
+ const baudRate = await select({
105
+ message: "Baud rate (must match the firmware's Serial.begin):",
106
+ default: 115200,
107
+ choices: SERIAL_BAUD_RATES.map((b) => ({ name: String(b), value: b })),
47
108
  });
48
109
 
49
- serialPort.on("error", function (err) {
50
- console.error("SerialPort Error: ", err.message);
110
+ let identity = null;
111
+ if (port && (await confirm({ message: `Ask ${port.path} for its identity (sends ID?, resets most boards)?`, default: !port.serialNumber }))) {
112
+ identity = await mod.probeIdentity(port.path, { baudRate });
113
+ showInfo(identity ? `Board says: ${identity}` : "No reply to ID?. Check the baud rate and firmware.");
114
+ }
115
+
116
+ const strategy = await select({
117
+ message: "Find this device by:",
118
+ default: port && port.serialNumber ? "serialNumber" : identity ? "identity" : port ? "vendorId" : "identity",
119
+ choices: [
120
+ { name: "USB serial number (best for identical boards)", value: "serialNumber" },
121
+ { name: "Firmware identity (reply to ID?)", value: "identity" },
122
+ { name: "USB vendor id (one board of that type)", value: "vendorId" },
123
+ { name: "Fixed USB port (last resort: identical boards with no serial number or ID reply)", value: "path" },
124
+ ],
51
125
  });
52
- } catch (error) {
53
- console.error("Error initializing SerialPort:", error);
54
- }`;
55
126
 
56
- insertBeforeListen(serialportSetup);
127
+ const match = {};
128
+ if (strategy === "serialNumber") {
129
+ match.serialNumber = await input({ message: "USB serial number:", default: (port && port.serialNumber) || "" });
130
+ } else if (strategy === "identity") {
131
+ match.identity = await input({ message: "Reply contains:", default: identity ? identity.split(":")[0] : "" });
132
+ if (port && port.vendorId) match.vendorId = port.vendorId;
133
+ } else if (strategy === "vendorId") {
134
+ match.vendorId = await input({ message: "USB vendor id (4 hex digits):", default: (port && port.vendorId) || "" });
135
+ if (port && port.productId) match.productId = port.productId;
136
+ } else {
137
+ match.path = await input({ message: "Port path:", default: (port && port.path) || (process.platform === "win32" ? "COM3" : "/dev/ttyUSB0") });
138
+ }
57
139
 
58
- const projectRoot = getProjectRoot();
59
- updateEnvFile(path.join(projectRoot, ".env"), "SERIALPORT_PATH", serialPortPath);
60
- updateEnvFile(path.join(projectRoot, ".env"), "BAUDRATE", baudRate);
61
- showFileAction("updated", ".env");
140
+ const name = await input({
141
+ message: "Device name (used in code and URLs):",
142
+ default: identity ? identity.split(":")[0].toLowerCase().replace(/[^a-z0-9_-]/g, "-").replace(/^-+|-+$/g, "") || "device" : "device",
143
+ validate: (v) => (/^[a-zA-Z][a-zA-Z0-9_-]{0,39}$/.test(v.trim()) ? true : "Start with a letter; letters, numbers, - or _ only."),
144
+ });
62
145
 
63
- await installDeps(["serialport", "@serialport/parser-readline"]);
146
+ const device = normalizeSerialDevice({ name: name.trim(), match, baudRate });
147
+ const list = (() => {
148
+ try { const l = JSON.parse(fs.readFileSync(configFile, "utf8")); return Array.isArray(l) ? l : []; } catch { return []; }
149
+ })().filter((d) => d.name !== device.name);
150
+ list.push(device);
151
+ fs.writeFileSync(configFile, JSON.stringify(list, null, 2) + "\n", "utf8");
152
+ showFileAction("updated", "src/serial/devices.json");
64
153
 
65
- showTaskDone("SerialPort integration complete", [
66
- `Path: ${serialPortPath}`,
67
- `Baud rate: ${baudRate}`,
68
- "Data logged to console on receive",
154
+ showTaskDone("Serial device added", [
155
+ `Device "${device.name}" found by ${Object.entries(device.match).map(([k, v]) => k + "=" + v).join(", ")} at ${device.baudRate} baud`,
156
+ "Connects at startup and reconnects automatically after a reset or unplug",
157
+ `HTTP: POST /api/serial/devices/${device.name}/send {"command": "ID?"} (needs x-api-key)`,
158
+ `Code: await getDevice('${device.name}').send('LED:ON') from ./src/serial/devices.js`,
159
+ "New boards are detected when plugged in and added automatically (SERIAL_AUTO_ADD=off to disable)",
160
+ "Manage devices and watch live data in the /_dev dashboard → SerialPort",
69
161
  ]);
70
162
  }
71
163
 
package/index.js CHANGED
@@ -245,7 +245,7 @@ const addOptions = [
245
245
  script: "add-websocket.js",
246
246
  },
247
247
  {
248
- name: "SerialPort Serial port communication",
248
+ name: "SerialPort Arduino/ESP32 devices (auto-detect + reconnect)",
249
249
  value: "serialport",
250
250
  script: "add-serialport.js",
251
251
  },
@@ -255,7 +255,7 @@ const addOptions = [
255
255
  script: "add-email.js",
256
256
  },
257
257
  {
258
- name: "Bonjour/mDNS Advertise this app on the local network",
258
+ name: "Bonjour/mDNS Advertise on the LAN (off by default; pick the network)",
259
259
  value: "bonjour",
260
260
  script: "add-bonjour.js",
261
261
  },
@@ -348,7 +348,7 @@ function showHelp() {
348
348
  console.log(` ${c("add security")} Helmet, rate-limit, RBAC, validation`);
349
349
  console.log(` ${c("add socket")} Socket.IO integration`);
350
350
  console.log(` ${c("add websocket")} Native WebSocket support`);
351
- console.log(` ${c("add serialport")} Serial port communication`);
351
+ console.log(` ${c("add serialport")} Arduino/ESP32 serial devices (auto-detect + reconnect)`);
352
352
  console.log(` ${c("add email")} Transactional email (Brevo, SendGrid, SMTP)`);
353
353
  console.log(` ${c("add bonjour")} Advertise on the local network (Bonjour/mDNS)`);
354
354
  console.log();