@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.
- package/dist/index.js +178 -90
- 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 {
|
|
17
|
+
import { spawn } from "node:child_process";
|
|
18
18
|
//#region package.json
|
|
19
|
-
var version = "0.0.
|
|
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
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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
|
|
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("
|
|
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
|
-
|
|
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
|
+
"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",
|