@patiom/daemon 0.0.4 → 0.0.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.
Files changed (2) hide show
  1. package/dist/index.js +168 -6
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -14,8 +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 { spawn } from "node:child_process";
17
18
  //#region package.json
18
- var version = "0.0.4";
19
+ var version = "0.0.6";
19
20
  //#endregion
20
21
  //#region src/config.ts
21
22
  const PATIOM_ROOT = "/var/lib/patiom";
@@ -178,6 +179,19 @@ const swapCurrentSymlink = async (appName, releaseId, log) => {
178
179
  await fs.rename(tempSymlink, currentSymlink);
179
180
  log("Current symlink swapped successfully");
180
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
+ };
181
195
  //#endregion
182
196
  //#region src/core/pnpm.ts
183
197
  const hasLockfile = async (releaseDir) => {
@@ -222,6 +236,10 @@ const enable = (name) => execa("systemctl", ["enable", name]);
222
236
  const start = (name) => execa("systemctl", ["start", name]);
223
237
  const stop = (name) => execa("systemctl", ["stop", name]);
224
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);
225
243
  const listRunningInstances = async (appName) => {
226
244
  try {
227
245
  const { stdout } = await execa("systemctl", [
@@ -232,10 +250,22 @@ const listRunningInstances = async (appName) => {
232
250
  "--plain",
233
251
  `${appName}@*`
234
252
  ]);
235
- return stdout.split("\n").filter((line) => line.includes(`${appName}@`)).map((line) => {
236
- const match = line.match(`${appName}@([0-9]+)\\.service`);
237
- return match ? match[1] : null;
238
- }).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);
239
269
  } catch {
240
270
  return [];
241
271
  }
@@ -374,10 +404,11 @@ After=network.target
374
404
  [Service]
375
405
  Type=exec
376
406
  WorkingDirectory=${PATIOM_ROOT}/apps/%p/current
377
- ExecStart=${nodeBinPath}/pnpm run ${startScript}
407
+ ExecStart=/usr/local/bin/pnpm run ${startScript}
378
408
  Restart=always
379
409
  EnvironmentFile=${PATIOM_ROOT}/apps/%p/shared/.env
380
410
  Environment=PORT=%i
411
+ Environment=npm_config_verifyDepsBeforeRun=false
381
412
  Environment=PATH=${nodeBinPath}:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
382
413
 
383
414
  DynamicUser=yes
@@ -461,9 +492,18 @@ const requireScope = (scope) => {
461
492
  //#region src/core/validation.ts
462
493
  const SAFE_NAME_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/u;
463
494
  const ULID_PATTERN = /^[0-9A-HJKMNP-TV-Z]{26}$/u;
495
+ const RESERVED_NAMES = new Set([
496
+ "rpxy",
497
+ "daemon",
498
+ "patiom",
499
+ "patiom-server",
500
+ "system",
501
+ "status"
502
+ ]);
464
503
  const validateAppName = (name) => {
465
504
  if (!name || !SAFE_NAME_PATTERN.test(name)) throw new Error("Invalid app name. Use only letters, numbers, hyphens, underscores, and dots.");
466
505
  if (name.includes("..") || name.includes("/") || name.includes("\\")) throw new Error("Invalid app name. Path traversal characters are not allowed.");
506
+ if (RESERVED_NAMES.has(name.toLowerCase())) throw new Error(`'${name}' is a reserved name and cannot be used as an app name.`);
467
507
  };
468
508
  const validateReleaseId = (releaseId) => {
469
509
  if (!releaseId || !ULID_PATTERN.test(releaseId)) throw new Error("Invalid release ID. Must be a 26-character ULID.");
@@ -790,6 +830,38 @@ dbRoute.delete("/:name", requireScope("rw"), async (c) => {
790
830
  });
791
831
  });
792
832
  //#endregion
833
+ //#region src/core/diagnostics.ts
834
+ const getServiceState = async (name) => {
835
+ try {
836
+ const { stdout } = await execa("systemctl", ["is-active", name]);
837
+ return stdout.trim();
838
+ } catch {
839
+ return "unknown";
840
+ }
841
+ };
842
+ const getServiceLogs = async (name, lines = 20) => {
843
+ try {
844
+ const { stdout } = await execa("journalctl", [
845
+ "-u",
846
+ name,
847
+ "--no-pager",
848
+ "-n",
849
+ String(lines)
850
+ ]);
851
+ return stdout.split("\n").filter(Boolean);
852
+ } catch {
853
+ return [];
854
+ }
855
+ };
856
+ const getListeningPorts = async () => {
857
+ try {
858
+ const { stdout } = await execa("ss", ["-tlnp"]);
859
+ return stdout.split("\n").filter(Boolean);
860
+ } catch {
861
+ return [];
862
+ }
863
+ };
864
+ //#endregion
793
865
  //#region src/routes/apps.ts
794
866
  const appsRoute = new Hono();
795
867
  appsRoute.get("/", async (c) => {
@@ -807,6 +879,52 @@ appsRoute.get("/", async (c) => {
807
879
  return c.json([]);
808
880
  }
809
881
  });
882
+ appsRoute.get("/:name/status", async (c) => {
883
+ const name = c.req.param("name");
884
+ const lines = Math.min(parseInt(c.req.query("lines") || "20", 10), 100);
885
+ try {
886
+ validateAppName(name);
887
+ } catch (err) {
888
+ return c.json({ error: err instanceof Error ? err.message : "Invalid input" }, 400);
889
+ }
890
+ const currentRelease = await getCurrentRelease(name);
891
+ const ports = await listAllInstances(name);
892
+ const lastDeployStatus = currentRelease ? await fs.readFile(path.join(currentRelease.path, "status"), "utf-8").catch(() => null) : null;
893
+ const instances = await Promise.all(ports.map(async (port) => {
894
+ const state = await getServiceState(`${name}@${port}`);
895
+ const logs = await getServiceLogs(`${name}@${port}`, lines);
896
+ return {
897
+ port: Number(port),
898
+ state,
899
+ logs
900
+ };
901
+ }));
902
+ return c.json({
903
+ name,
904
+ currentRelease: currentRelease?.id ?? null,
905
+ lastDeploy: lastDeployStatus ? {
906
+ releaseId: currentRelease.id,
907
+ status: lastDeployStatus.trim()
908
+ } : null,
909
+ instances
910
+ });
911
+ });
912
+ appsRoute.post("/:name/restart", requireScope("rw"), async (c) => {
913
+ const name = c.req.param("name");
914
+ try {
915
+ validateAppName(name);
916
+ } catch (err) {
917
+ return c.json({ error: err instanceof Error ? err.message : "Invalid input" }, 400);
918
+ }
919
+ const ports = await listAllInstances(name);
920
+ if (ports.length === 0) return c.json({ error: "No instances found for this app" }, 404);
921
+ await Promise.all(ports.map((port) => stop(`${name}@${port}`)));
922
+ await Promise.all(ports.map((port) => start(`${name}@${port}`)));
923
+ return c.json({
924
+ success: true,
925
+ restarted: ports.map(Number)
926
+ });
927
+ });
810
928
  //#endregion
811
929
  //#region src/routes/logs.ts
812
930
  const logsRoute = new Hono();
@@ -853,6 +971,48 @@ tokensRoute.delete("/:id", async (c) => {
853
971
  return c.json({ success: true });
854
972
  });
855
973
  //#endregion
974
+ //#region src/routes/status.ts
975
+ const statusRoute = new Hono();
976
+ statusRoute.get("/", async (c) => {
977
+ const [rpxyState, rpxyLogs, ports] = await Promise.all([
978
+ getServiceState("rpxy"),
979
+ getServiceLogs("rpxy", 20),
980
+ getListeningPorts()
981
+ ]);
982
+ return c.json({
983
+ daemon: {
984
+ version,
985
+ uptime: process.uptime(),
986
+ port: Number(process.env.PORT) || 4e3
987
+ },
988
+ rpxy: {
989
+ state: rpxyState,
990
+ logs: rpxyLogs
991
+ },
992
+ ports
993
+ });
994
+ });
995
+ //#endregion
996
+ //#region src/routes/system.ts
997
+ const systemRoute = new Hono();
998
+ systemRoute.post("/rpxy/restart", requireScope("rw"), async (c) => {
999
+ await restart("rpxy");
1000
+ return c.json({
1001
+ success: true,
1002
+ message: "rpxy restarted"
1003
+ });
1004
+ });
1005
+ systemRoute.post("/daemon/restart", requireScope("rw"), (c) => {
1006
+ spawn("bash", ["-c", "sleep 1 && systemctl restart patiom-daemon"], {
1007
+ detached: true,
1008
+ stdio: "ignore"
1009
+ }).unref();
1010
+ return c.json({
1011
+ success: true,
1012
+ message: "Daemon restarting..."
1013
+ });
1014
+ });
1015
+ //#endregion
856
1016
  //#region src/server.ts
857
1017
  const app = new Hono();
858
1018
  app.onError((err, c) => {
@@ -871,6 +1031,8 @@ app.route("/db", dbRoute);
871
1031
  app.route("/apps", appsRoute);
872
1032
  app.route("/logs", logsRoute);
873
1033
  app.route("/tokens", tokensRoute);
1034
+ app.route("/status", statusRoute);
1035
+ app.route("/system", systemRoute);
874
1036
  const startServer = () => {
875
1037
  serve({
876
1038
  fetch: app.fetch,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@patiom/daemon",
3
- "version": "0.0.4",
3
+ "version": "0.0.6",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "patiom-server": "dist/index.js"