@patiom/daemon 0.0.3 → 0.0.5

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.
Files changed (2) hide show
  1. package/dist/index.js +178 -90
  2. package/package.json +1 -2
package/dist/index.js CHANGED
@@ -14,9 +14,9 @@ import "adm-zip";
14
14
  import getPort, { portNumbers } from "get-port";
15
15
  import { parse, stringify } from "smol-toml";
16
16
  import dotenv from "dotenv";
17
- import { confirm, input } from "@inquirer/prompts";
17
+ import { spawn } from "node:child_process";
18
18
  //#region package.json
19
- var version = "0.0.3";
19
+ var version = "0.0.5";
20
20
  //#endregion
21
21
  //#region src/config.ts
22
22
  const PATIOM_ROOT = "/var/lib/patiom";
@@ -179,6 +179,19 @@ const swapCurrentSymlink = async (appName, releaseId, log) => {
179
179
  await fs.rename(tempSymlink, currentSymlink);
180
180
  log("Current symlink swapped successfully");
181
181
  };
182
+ const getCurrentRelease = async (appName) => {
183
+ const currentSymlink = getCurrentSymlink(appName);
184
+ try {
185
+ const target = await fs.readlink(currentSymlink);
186
+ return {
187
+ id: path.basename(target),
188
+ path: target,
189
+ createdAt: (await fs.stat(target)).birthtime
190
+ };
191
+ } catch {
192
+ return null;
193
+ }
194
+ };
182
195
  //#endregion
183
196
  //#region src/core/pnpm.ts
184
197
  const hasLockfile = async (releaseDir) => {
@@ -223,6 +236,10 @@ const enable = (name) => execa("systemctl", ["enable", name]);
223
236
  const start = (name) => execa("systemctl", ["start", name]);
224
237
  const stop = (name) => execa("systemctl", ["stop", name]);
225
238
  const restart = (name) => execa("systemctl", ["restart", name]);
239
+ const parseUnits = (stdout, appName) => stdout.split("\n").filter((line) => line.includes(`${appName}@`)).map((line) => {
240
+ const match = line.match(`${appName}@([0-9]+)\\.service`);
241
+ return match ? match[1] : null;
242
+ }).filter((port) => port !== null);
226
243
  const listRunningInstances = async (appName) => {
227
244
  try {
228
245
  const { stdout } = await execa("systemctl", [
@@ -233,10 +250,22 @@ const listRunningInstances = async (appName) => {
233
250
  "--plain",
234
251
  `${appName}@*`
235
252
  ]);
236
- return stdout.split("\n").filter((line) => line.includes(`${appName}@`)).map((line) => {
237
- const match = line.match(`${appName}@([0-9]+)\\.service`);
238
- return match ? match[1] : null;
239
- }).filter((port) => port !== null);
253
+ return parseUnits(stdout, appName);
254
+ } catch {
255
+ return [];
256
+ }
257
+ };
258
+ const listAllInstances = async (appName) => {
259
+ try {
260
+ const { stdout } = await execa("systemctl", [
261
+ "list-units",
262
+ "--type=service",
263
+ "--all",
264
+ "--no-pager",
265
+ "--plain",
266
+ `${appName}@*`
267
+ ]);
268
+ return parseUnits(stdout, appName);
240
269
  } catch {
241
270
  return [];
242
271
  }
@@ -375,7 +404,7 @@ After=network.target
375
404
  [Service]
376
405
  Type=exec
377
406
  WorkingDirectory=${PATIOM_ROOT}/apps/%p/current
378
- ExecStart=${nodeBinPath}/pnpm run ${startScript}
407
+ ExecStart=/usr/local/bin/pnpm run ${startScript}
379
408
  Restart=always
380
409
  EnvironmentFile=${PATIOM_ROOT}/apps/%p/shared/.env
381
410
  Environment=PORT=%i
@@ -462,9 +491,18 @@ const requireScope = (scope) => {
462
491
  //#region src/core/validation.ts
463
492
  const SAFE_NAME_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/u;
464
493
  const ULID_PATTERN = /^[0-9A-HJKMNP-TV-Z]{26}$/u;
494
+ const RESERVED_NAMES = new Set([
495
+ "rpxy",
496
+ "daemon",
497
+ "patiom",
498
+ "patiom-server",
499
+ "system",
500
+ "status"
501
+ ]);
465
502
  const validateAppName = (name) => {
466
503
  if (!name || !SAFE_NAME_PATTERN.test(name)) throw new Error("Invalid app name. Use only letters, numbers, hyphens, underscores, and dots.");
467
504
  if (name.includes("..") || name.includes("/") || name.includes("\\")) throw new Error("Invalid app name. Path traversal characters are not allowed.");
505
+ if (RESERVED_NAMES.has(name.toLowerCase())) throw new Error(`'${name}' is a reserved name and cannot be used as an app name.`);
468
506
  };
469
507
  const validateReleaseId = (releaseId) => {
470
508
  if (!releaseId || !ULID_PATTERN.test(releaseId)) throw new Error("Invalid release ID. Must be a 26-character ULID.");
@@ -791,6 +829,38 @@ dbRoute.delete("/:name", requireScope("rw"), async (c) => {
791
829
  });
792
830
  });
793
831
  //#endregion
832
+ //#region src/core/diagnostics.ts
833
+ const getServiceState = async (name) => {
834
+ try {
835
+ const { stdout } = await execa("systemctl", ["is-active", name]);
836
+ return stdout.trim();
837
+ } catch {
838
+ return "unknown";
839
+ }
840
+ };
841
+ const getServiceLogs = async (name, lines = 20) => {
842
+ try {
843
+ const { stdout } = await execa("journalctl", [
844
+ "-u",
845
+ name,
846
+ "--no-pager",
847
+ "-n",
848
+ String(lines)
849
+ ]);
850
+ return stdout.split("\n").filter(Boolean);
851
+ } catch {
852
+ return [];
853
+ }
854
+ };
855
+ const getListeningPorts = async () => {
856
+ try {
857
+ const { stdout } = await execa("ss", ["-tlnp"]);
858
+ return stdout.split("\n").filter(Boolean);
859
+ } catch {
860
+ return [];
861
+ }
862
+ };
863
+ //#endregion
794
864
  //#region src/routes/apps.ts
795
865
  const appsRoute = new Hono();
796
866
  appsRoute.get("/", async (c) => {
@@ -808,6 +878,52 @@ appsRoute.get("/", async (c) => {
808
878
  return c.json([]);
809
879
  }
810
880
  });
881
+ appsRoute.get("/:name/status", async (c) => {
882
+ const name = c.req.param("name");
883
+ const lines = Math.min(parseInt(c.req.query("lines") || "20", 10), 100);
884
+ try {
885
+ validateAppName(name);
886
+ } catch (err) {
887
+ return c.json({ error: err instanceof Error ? err.message : "Invalid input" }, 400);
888
+ }
889
+ const currentRelease = await getCurrentRelease(name);
890
+ const ports = await listAllInstances(name);
891
+ const lastDeployStatus = currentRelease ? await fs.readFile(path.join(currentRelease.path, "status"), "utf-8").catch(() => null) : null;
892
+ const instances = await Promise.all(ports.map(async (port) => {
893
+ const state = await getServiceState(`${name}@${port}`);
894
+ const logs = await getServiceLogs(`${name}@${port}`, lines);
895
+ return {
896
+ port: Number(port),
897
+ state,
898
+ logs
899
+ };
900
+ }));
901
+ return c.json({
902
+ name,
903
+ currentRelease: currentRelease?.id ?? null,
904
+ lastDeploy: lastDeployStatus ? {
905
+ releaseId: currentRelease.id,
906
+ status: lastDeployStatus.trim()
907
+ } : null,
908
+ instances
909
+ });
910
+ });
911
+ appsRoute.post("/:name/restart", requireScope("rw"), async (c) => {
912
+ const name = c.req.param("name");
913
+ try {
914
+ validateAppName(name);
915
+ } catch (err) {
916
+ return c.json({ error: err instanceof Error ? err.message : "Invalid input" }, 400);
917
+ }
918
+ const ports = await listAllInstances(name);
919
+ if (ports.length === 0) return c.json({ error: "No instances found for this app" }, 404);
920
+ await Promise.all(ports.map((port) => stop(`${name}@${port}`)));
921
+ await Promise.all(ports.map((port) => start(`${name}@${port}`)));
922
+ return c.json({
923
+ success: true,
924
+ restarted: ports.map(Number)
925
+ });
926
+ });
811
927
  //#endregion
812
928
  //#region src/routes/logs.ts
813
929
  const logsRoute = new Hono();
@@ -854,6 +970,48 @@ tokensRoute.delete("/:id", async (c) => {
854
970
  return c.json({ success: true });
855
971
  });
856
972
  //#endregion
973
+ //#region src/routes/status.ts
974
+ const statusRoute = new Hono();
975
+ statusRoute.get("/", async (c) => {
976
+ const [rpxyState, rpxyLogs, ports] = await Promise.all([
977
+ getServiceState("rpxy"),
978
+ getServiceLogs("rpxy", 20),
979
+ getListeningPorts()
980
+ ]);
981
+ return c.json({
982
+ daemon: {
983
+ version,
984
+ uptime: process.uptime(),
985
+ port: Number(process.env.PORT) || 4e3
986
+ },
987
+ rpxy: {
988
+ state: rpxyState,
989
+ logs: rpxyLogs
990
+ },
991
+ ports
992
+ });
993
+ });
994
+ //#endregion
995
+ //#region src/routes/system.ts
996
+ const systemRoute = new Hono();
997
+ systemRoute.post("/rpxy/restart", requireScope("rw"), async (c) => {
998
+ await restart("rpxy");
999
+ return c.json({
1000
+ success: true,
1001
+ message: "rpxy restarted"
1002
+ });
1003
+ });
1004
+ systemRoute.post("/daemon/restart", requireScope("rw"), (c) => {
1005
+ spawn("bash", ["-c", "sleep 1 && systemctl restart patiom-daemon"], {
1006
+ detached: true,
1007
+ stdio: "ignore"
1008
+ }).unref();
1009
+ return c.json({
1010
+ success: true,
1011
+ message: "Daemon restarting..."
1012
+ });
1013
+ });
1014
+ //#endregion
857
1015
  //#region src/server.ts
858
1016
  const app = new Hono();
859
1017
  app.onError((err, c) => {
@@ -872,6 +1030,8 @@ app.route("/db", dbRoute);
872
1030
  app.route("/apps", appsRoute);
873
1031
  app.route("/logs", logsRoute);
874
1032
  app.route("/tokens", tokensRoute);
1033
+ app.route("/status", statusRoute);
1034
+ app.route("/system", systemRoute);
875
1035
  const startServer = () => {
876
1036
  serve({
877
1037
  fetch: app.fetch,
@@ -897,81 +1057,6 @@ const detectIP = async () => {
897
1057
  return "unknown";
898
1058
  }
899
1059
  };
900
- const configureFirewall = async (os, port) => {
901
- if (!await confirm({
902
- message: "Configure firewall?",
903
- default: true
904
- })) {
905
- consola.info("Skipping firewall configuration");
906
- return;
907
- }
908
- if (os === "ubuntu" || os === "debian") {
909
- consola.start("Configuring UFW...");
910
- await execa("ufw", [
911
- "default",
912
- "deny",
913
- "incoming"
914
- ]);
915
- await execa("ufw", [
916
- "default",
917
- "allow",
918
- "outgoing"
919
- ]);
920
- await execa("ufw", ["allow", "22/tcp"]);
921
- await execa("ufw", ["allow", "80/tcp"]);
922
- await execa("ufw", ["allow", "443/tcp"]);
923
- await execa("ufw", ["allow", `${port}/tcp`]);
924
- await execa("ufw", ["--force", "enable"]);
925
- consola.success("UFW configured");
926
- } else if ([
927
- "almalinux",
928
- "rocky",
929
- "centos",
930
- "fedora",
931
- "rhel"
932
- ].includes(os)) {
933
- consola.start("Configuring Firewalld...");
934
- await execa("systemctl", [
935
- "enable",
936
- "--now",
937
- "firewalld"
938
- ]);
939
- await execa("firewall-cmd", [
940
- "--permanent",
941
- "--zone=public",
942
- "--add-port=22/tcp"
943
- ]);
944
- await execa("firewall-cmd", [
945
- "--permanent",
946
- "--zone=public",
947
- "--add-port=80/tcp"
948
- ]);
949
- await execa("firewall-cmd", [
950
- "--permanent",
951
- "--zone=public",
952
- "--add-port=443/tcp"
953
- ]);
954
- await execa("firewall-cmd", [
955
- "--permanent",
956
- "--zone=public",
957
- "--add-port",
958
- `${port}/tcp`
959
- ]);
960
- await execa("firewall-cmd", ["--reload"]);
961
- await execa("setsebool", [
962
- "-P",
963
- "httpd_can_network_connect",
964
- "1"
965
- ]);
966
- consola.success("Firewalld configured");
967
- } else consola.warn(`Unsupported OS for firewall: ${os}`);
968
- };
969
- const configureACME = async () => {
970
- return { email: await input({
971
- message: "Email for Let's Encrypt certificates:",
972
- validate: (v) => v.includes("@") ? true : "Please enter a valid email"
973
- }) };
974
- };
975
1060
  const setupPatiomDirs = async () => {
976
1061
  await fs.mkdir(PATIOM_ROOT, { recursive: true });
977
1062
  await fs.mkdir(path.join(PATIOM_ROOT, "apps"), { recursive: true });
@@ -1010,7 +1095,7 @@ const installServices = async (nodeBinPath) => {
1010
1095
  await start("patiom-daemon");
1011
1096
  consola.success("Patiom daemon started");
1012
1097
  };
1013
- const setup = async () => {
1098
+ const setup = async (email) => {
1014
1099
  console.log("");
1015
1100
  consola.info("Patiom Server Setup");
1016
1101
  console.log("");
@@ -1020,9 +1105,6 @@ const setup = async () => {
1020
1105
  consola.error("Unsupported OS. Please use Ubuntu, Debian, AlmaLinux, Rocky, CentOS, Fedora, or RHEL.");
1021
1106
  process.exit(1);
1022
1107
  }
1023
- await configureFirewall(os, DAEMON_PORT);
1024
- console.log("");
1025
- const { email } = await configureACME();
1026
1108
  console.log("");
1027
1109
  consola.start("Setting up Patiom...");
1028
1110
  await setupPatiomDirs();
@@ -1042,10 +1124,12 @@ const setup = async () => {
1042
1124
  consola.info("Next steps:");
1043
1125
  console.log(` patiom login --url http://${ip}:${DAEMON_PORT} --token ${token}`);
1044
1126
  console.log("");
1127
+ consola.info("Firewall: ensure ports 22 (SSH), 80 (HTTP), 443 (HTTPS), and 4000 (daemon) are open");
1128
+ console.log("");
1045
1129
  };
1046
- const runSetup = async () => {
1130
+ const runSetup = async (email) => {
1047
1131
  try {
1048
- await setup();
1132
+ await setup(email);
1049
1133
  } catch (err) {
1050
1134
  consola.error("Setup failed:", err);
1051
1135
  process.exit(1);
@@ -1067,9 +1151,13 @@ program.command("serve").description("Start the daemon HTTP server").option(skip
1067
1151
  checkRoot(options.devSkipRootCheck);
1068
1152
  startServer();
1069
1153
  });
1070
- program.command("setup").description("Interactive first-time server setup").option(skipRootOpt, "Skip root check for development").action((options) => {
1154
+ program.command("setup").description("First-time server setup").requiredOption("--email <email>", "Email for Let's Encrypt certificates").option(skipRootOpt, "Skip root check for development").action((options) => {
1071
1155
  checkRoot(options.devSkipRootCheck);
1072
- return runSetup();
1156
+ if (!/^\S+@\S+\.\S+$/u.test(options.email)) {
1157
+ consola.error("Invalid email format. Provide a valid email for Let's Encrypt certificates.");
1158
+ process.exit(1);
1159
+ }
1160
+ return runSetup(options.email);
1073
1161
  });
1074
1162
  program.command("upgrade").description("Update the daemon package and restart the service").option(skipRootOpt, "Skip root check for development").action(async (options) => {
1075
1163
  checkRoot(options.devSkipRootCheck);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@patiom/daemon",
3
- "version": "0.0.3",
3
+ "version": "0.0.5",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "patiom-server": "dist/index.js"
@@ -10,7 +10,6 @@
10
10
  ],
11
11
  "dependencies": {
12
12
  "@hono/node-server": "^2.0.4",
13
- "@inquirer/prompts": "^7.8.2",
14
13
  "adm-zip": "^0.5.17",
15
14
  "commander": "^15.0.0",
16
15
  "consola": "^3.4.2",