@patiom/daemon 0.0.4 → 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 +167 -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.5";
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,7 +404,7 @@ 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
@@ -461,9 +491,18 @@ const requireScope = (scope) => {
461
491
  //#region src/core/validation.ts
462
492
  const SAFE_NAME_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/u;
463
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
+ ]);
464
502
  const validateAppName = (name) => {
465
503
  if (!name || !SAFE_NAME_PATTERN.test(name)) throw new Error("Invalid app name. Use only letters, numbers, hyphens, underscores, and dots.");
466
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.`);
467
506
  };
468
507
  const validateReleaseId = (releaseId) => {
469
508
  if (!releaseId || !ULID_PATTERN.test(releaseId)) throw new Error("Invalid release ID. Must be a 26-character ULID.");
@@ -790,6 +829,38 @@ dbRoute.delete("/:name", requireScope("rw"), async (c) => {
790
829
  });
791
830
  });
792
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
793
864
  //#region src/routes/apps.ts
794
865
  const appsRoute = new Hono();
795
866
  appsRoute.get("/", async (c) => {
@@ -807,6 +878,52 @@ appsRoute.get("/", async (c) => {
807
878
  return c.json([]);
808
879
  }
809
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
+ });
810
927
  //#endregion
811
928
  //#region src/routes/logs.ts
812
929
  const logsRoute = new Hono();
@@ -853,6 +970,48 @@ tokensRoute.delete("/:id", async (c) => {
853
970
  return c.json({ success: true });
854
971
  });
855
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
856
1015
  //#region src/server.ts
857
1016
  const app = new Hono();
858
1017
  app.onError((err, c) => {
@@ -871,6 +1030,8 @@ app.route("/db", dbRoute);
871
1030
  app.route("/apps", appsRoute);
872
1031
  app.route("/logs", logsRoute);
873
1032
  app.route("/tokens", tokensRoute);
1033
+ app.route("/status", statusRoute);
1034
+ app.route("/system", systemRoute);
874
1035
  const startServer = () => {
875
1036
  serve({
876
1037
  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.5",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "patiom-server": "dist/index.js"