@fordi-org/sdn 0.0.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/README.md ADDED
@@ -0,0 +1,58 @@
1
+ # SDN
2
+
3
+ ## Systemd Demonizer for Node
4
+
5
+ This package will allow you to spin up a systemd service from an npm project with zero fuss.
6
+
7
+ # Usage
8
+
9
+ First, configure your `package.json` appropriately. The service name will be the `.name` of your project, with any `@org/` prefix stripped off. The entrypoint will be your `.main` entry. The service's description will be your project's description.
10
+
11
+ You can add any items to the service file with a `.config.systemd` section. The first level of depth is the `[Section]`, and the second is the `Property=`. Passing an array for a `Property=` will result in the property being repeated. For example:
12
+
13
+ ```json
14
+ {
15
+ "name": "my-service",
16
+ "main": "src/index.js",
17
+ "systemd": {
18
+ "Unit": {
19
+ "After": ["network.target", "mariadb.service"],
20
+ }
21
+ }
22
+ }
23
+ ```
24
+
25
+ You don't really need to get into the systemd weeds here. If you have a `.name` and `.main`, you should be good to go to the next steps:
26
+
27
+ ```bash
28
+ $ npx @fordi-org/sdn install
29
+ ```
30
+
31
+ This will create the service for you. While you're in your working directory, there are some convenience scripts:
32
+
33
+ ```bash
34
+ $ npx @fordi-org/sdn control start # This is an alias for `systemctl --user start your-service`
35
+ $ npx @fordi-org/sdn control stop # Any other service-related verb works
36
+ $ npx @fordi-org/sdn logs # Will run journalctl so you can see what your service is doing
37
+ $ npx @fordi-org/sdn uninstall # Remove your service from systemd
38
+ ```
39
+
40
+ You might want to consider adding `scripts` to your `package.json`:
41
+
42
+ ```json
43
+ {
44
+ ...
45
+ "scripts": {
46
+ "install-service": "npx @fordi-org/sdn install",
47
+ "uninstall-service": "npx @fordi-org/sdn uninstall",
48
+ "control": "npx @fordi-org/sdn control",
49
+ "start": "npx @fordi-org/sdn control start",
50
+ "status": "npx @fordi-org/sdn control status",
51
+ "stop": "npx @fordi-org/sdn control stop",
52
+ "logs": "npx @fordi-org/sdn logs",
53
+ "restart": "npx @fordi-org/sdn control restart",
54
+ "reload": "npx @fordi-org/sdn control '' daemon-reload"
55
+ },
56
+ ...
57
+ }
58
+ ```
package/index.js ADDED
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env node
2
+ import { fileURLToPath } from "node:url";
3
+
4
+ let cmd = process.argv[2];
5
+ if (!cmd) {
6
+ console.warn(`${process.argv[1]} {command} ...`);
7
+ process.exit(-1);
8
+ }
9
+ const script = fileURLToPath(new URL(`./service/${cmd}.js`, import.meta.url));
10
+ process.argv.splice(1, 2, script);
11
+ await import(script);
package/lib/config.js ADDED
@@ -0,0 +1,48 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { dirname, resolve } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ function getConfig(root) {
6
+ while (!existsSync(resolve(root, 'package.json')) && root !== '/') {
7
+ root = dirname(root);
8
+ }
9
+ if (root === '/') {
10
+ throw new Error("Not in an npm project");
11
+ }
12
+
13
+ const readJson = (rootRelPath) => JSON.parse(readFileSync(resolve(root, rootRelPath)), 'utf8')
14
+ const { config = {}, ...PACKAGE } = readJson("package.json");
15
+
16
+ if (config.from) {
17
+ try {
18
+ Object.assign(config, readJson(config.from));
19
+ } catch (e) {
20
+ console.info(`No ${config.from}, though one is configured from package.json`);
21
+ config.from = 'package.json';
22
+ }
23
+ } else {
24
+ config.from = "package.json";
25
+ }
26
+
27
+ [config.name, config.org = undefined] = PACKAGE.name.replace(/^@/, '').split('/').reverse();
28
+ config.description = PACKAGE.description;
29
+ config.root = root;
30
+ config.main = PACKAGE.main;
31
+ config.package = Object.freeze(PACKAGE);
32
+
33
+ Object.freeze(config);
34
+
35
+ class ConfigError extends Error {
36
+ constructor(message, needed) {
37
+ super(needed ? `${message}; Please add the following to ${config.from}:\n ${
38
+ Object.entries(needed).map(([name, desc]) => `${JSON.stringify(name)}: ${desc}`).join('\n ')
39
+ }\n` : `${message}; please check ${config.from}`);
40
+ }
41
+ }
42
+
43
+ return { config, PACKAGE, ConfigError };
44
+ }
45
+
46
+ const { config, PACKAGE, ConfigError } = getConfig(fileURLToPath(new URL("./..", import.meta.url)).replace(/\/$/, ''));
47
+
48
+ export { config, PACKAGE, ConfigError, getConfig };
package/lib/index.js ADDED
@@ -0,0 +1,3 @@
1
+ export { getConfig } from "./config.js";
2
+ export * from "./shellQuote.js";
3
+ export * from "./jsonCmd.js";
package/lib/jsonCmd.js ADDED
@@ -0,0 +1,58 @@
1
+ import child_process from "node:child_process";
2
+
3
+ export async function* jsonCmd(cmd, args, options) {
4
+ const h = child_process.spawn(cmd, args, { ...options });
5
+ const d = new TextDecoder();
6
+ let buf = new Uint8Array(0);
7
+ let currentPromise = Promise.withResolvers();
8
+ let queue = [];
9
+
10
+ const tryParse = (buffer) => {
11
+ const json = d.decode(buffer);
12
+ try {
13
+ return JSON.parse(json);
14
+ } catch (e) {
15
+ return { parseError: e, input: json };
16
+ }
17
+ };
18
+ const cutToFirstNewline = (...buffers) => {
19
+ const nextBuf = Buffer.concat(buffers);
20
+ const newline = nextBuf.indexOf(10);
21
+ if (newline === -1) {
22
+ return [undefined, nextBuf];
23
+ }
24
+ const nextStart = newline + 1;
25
+ const nextLen = nextBuf.byteLength - nextStart;
26
+ const line = new Uint8Array(nextStart);
27
+ nextBuf.copy(line, 0, 0, nextStart);
28
+ const buf = new Uint8Array(nextLen);
29
+ nextBuf.copy(buf, 0, nextStart, nextBuf.byteLength);
30
+ return [line, buf];
31
+ }
32
+ const queueChunk = (chunk) => {
33
+ let line;
34
+ [line, buf] = cutToFirstNewline(buf, chunk);
35
+ do {
36
+ if (line) {
37
+ queue.push(tryParse(line));
38
+ currentPromise.resolve();
39
+ currentPromise = Promise.withResolvers();
40
+ }
41
+ [line, buf] = cutToFirstNewline(buf);
42
+ } while (line !== undefined);
43
+ };
44
+ h.stdout.on('data', queueChunk);
45
+ h.stderr.on('data', queueChunk);
46
+ h.once('close', (status, signal) => {
47
+ currentPromise.resolve();
48
+ });
49
+ do {
50
+ await currentPromise.promise;
51
+ while (queue.length) {
52
+ yield queue.shift();
53
+ }
54
+ } while (h.exitCode === null)
55
+ if (buf.byteLength) {
56
+ yield tryParse(buf);
57
+ }
58
+ }
@@ -0,0 +1,11 @@
1
+ export function shellQuote(s, ...rest) {
2
+ if (Array.isArray(s)) {
3
+ return s.map((item) => shellQuote(item)).join(' ');
4
+ }
5
+ if (rest.length) {
6
+ return [s, ...rest].map((item) => shellQuote(item)).join(' ');
7
+ }
8
+ if (s === '') return `''`;
9
+ if (!/[^%+,-.\/:=@_0-9A-Za-z]/.test(s)) return s;
10
+ return `'` + s.replace(/'/g, `'\''`) + `'`;
11
+ }
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "@fordi-org/sdn",
3
+ "version": "0.0.1",
4
+ "description": "Make an npm project run as a systemd service",
5
+ "type": "module",
6
+ "main": "lib/index.js",
7
+ "dependencies": {
8
+ "nodemon": "^3.1.11"
9
+ },
10
+ "bin": "index.js",
11
+ "config": {
12
+ "systemd": {
13
+ "Unit": {
14
+ "After": [
15
+ "network.target"
16
+ ]
17
+ },
18
+ "Service": {
19
+ "Type": "simple"
20
+ },
21
+ "Install": {
22
+ "WantedBy": [
23
+ "multi-user.target"
24
+ ]
25
+ }
26
+ }
27
+ }
28
+ }
@@ -0,0 +1,19 @@
1
+ #!/usr/bin/env node
2
+ import { spawnSync } from "node:child_process";
3
+ import { resolve } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ import { getConfig } from "../lib/config.js";
7
+ import { shellQuote } from "../lib/shellQuote.js";
8
+
9
+ const { config: project } = getConfig(process.cwd());
10
+
11
+ export function systemctl([cmd, ...args]) {
12
+ const argv = ['systemctl', '--user', ...(cmd ? [cmd, project.name] : []), ...args];
13
+ console.info(`> ${shellQuote(argv)}`);
14
+ return spawnSync(argv[0], argv.slice(1), { stdio: "inherit" });
15
+ }
16
+
17
+ if (fileURLToPath(import.meta.url) === resolve(process.argv[1])) {
18
+ systemctl(process.argv.slice(2));
19
+ }
@@ -0,0 +1,28 @@
1
+ #!/usr/bin/env node
2
+ import { resolve } from "node:path";
3
+ import nodemon from "nodemon";
4
+ import { getConfig } from "../lib/config.js";
5
+
6
+ const { config: project } = getConfig(process.argv[2] ?? process.cwd());
7
+
8
+ process.title = project.name;
9
+
10
+ nodemon({
11
+ script: resolve(project.root, project.main),
12
+ args: process.argv.slice(3),
13
+ });
14
+
15
+ nodemon.on("start", () => {
16
+ console.log(`Service ${project.name} is running.`);
17
+ }).on("quit", () => {
18
+ console.log(`Service ${project.name} has quit.`);
19
+ process.exit();
20
+ }).on("restart", () => {
21
+ console.log(`Service ${project.name} will restart.`);
22
+ }).on("exit", () => {
23
+ console.log(`Service ${project.name} exited cleanly.`);
24
+ }).on("crash", () => {
25
+ console.log(`Service ${project.name} crashed.`);
26
+ }).on("config:update", () => {
27
+ console.log(`Service ${project.name} nodemon config has changed.`);
28
+ });
@@ -0,0 +1,59 @@
1
+ #!/usr/bin/env node
2
+ import { spawnSync } from "node:child_process";
3
+ import { existsSync, rmSync, writeFileSync } from "node:fs";
4
+ import { basename, resolve } from "node:path";
5
+
6
+ import { config, getConfig } from "../lib/config.js";
7
+ import { shellQuote } from "../lib/shellQuote.js";
8
+
9
+ const { config: project } = getConfig(process.cwd());
10
+ const ownName = basename(import.meta.url, '.js');
11
+ const systemd = {
12
+ ...project.systemd,
13
+ Unit: {
14
+ Description: project.description,
15
+ After: ["network.target"],
16
+ ...project.systemd?.Unit
17
+ },
18
+ Service: {
19
+ Type: "simple",
20
+ ...project.systemd?.Service,
21
+ WorkingDirectory: project.root,
22
+ ExecStart: shellQuote(
23
+ resolve(config.root, "service/node"),
24
+ resolve(config.root, "service/index.js"),
25
+ resolve(project.root)
26
+ ),
27
+ },
28
+ Install: {
29
+ WantedBy: ["multi-user.target"],
30
+ ...project.systemd?.Install,
31
+ },
32
+ };
33
+
34
+ const serviceContent = Object.entries(systemd).map(
35
+ ([heading, values]) => [
36
+ `[${heading}]`,
37
+ ...Object.entries(values).map(([name, value]) =>
38
+ Array.isArray(value)
39
+ ? value.map((v) => `${name}=${v}`).join('\n')
40
+ : `${name}=${value}`
41
+ )
42
+ ].join('\n')
43
+ ).join('\n\n');
44
+
45
+ const serviceFile = resolve(project.root, `${project.name}.service`);
46
+ const systemdFile = resolve(process.env.HOME, '.config', 'systemd', 'user', `${project.name}.service`);
47
+
48
+ writeFileSync(serviceFile, serviceContent, "utf8");
49
+
50
+ if (existsSync(systemdFile)) {
51
+ if (!process.argv.includes('--force')) {
52
+ console.warn(`${systemdFile} already exists; use "npx ${config.name} ${ownName} --force" to overwrite`);
53
+ process.exit();
54
+ } else {
55
+ rmSync(systemdFile);
56
+ spawnSync('systemctl', ['--user', 'daemon-reload'], { stdio: "inherit" });
57
+ }
58
+ }
59
+ spawnSync('systemctl', ['--user', 'link', serviceFile], { stdio: "inherit" });
@@ -0,0 +1,38 @@
1
+ #!/usr/bin/env node
2
+ import { resolve } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ import { config, getConfig } from "../lib/config.js";
6
+ import { shellQuote } from "../lib/shellQuote.js";
7
+ import { jsonCmd } from "../lib/jsonCmd.js";
8
+
9
+ const { config: project } = getConfig(process.cwd());
10
+
11
+ const pad = (n, l = 2) => String(n).padStart(l, '0');
12
+
13
+ const formatDateTime = (date) => {
14
+ const d = `${pad(date.getFullYear())}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
15
+ const g = date.getHours();
16
+ const h = g % 12 || 12;
17
+ const a = g < 12 ? 'a' : 'p';
18
+ const t = `${pad(h)}:${pad(date.getMinutes())}:${pad(date.getSeconds())}${a}`;
19
+ return `${d} ${t}`;
20
+ }
21
+
22
+ export const formatJournal = (line) => {
23
+ const time = parseInt(line.__REALTIME_TIMESTAMP.slice(0, -3));
24
+ const sym = line._TRANSPORT === 'stdout' ? ' > ' : '‼> ';
25
+ return `${formatDateTime(new Date(time))}${sym}${line.MESSAGE}`;
26
+ }
27
+
28
+ export function journalctl() {
29
+ const argv = ['journalctl', '--user-unit', project.name, '-o', 'json', '-e', '-f'];
30
+ console.info(`> ${shellQuote(argv)}`);
31
+ return jsonCmd(argv[0], argv.slice(1));
32
+ }
33
+ console.log(process.argv);
34
+ if (fileURLToPath(import.meta.url) === resolve(process.argv[1])) {
35
+ for await (const line of journalctl(process.argv.slice(2))) {
36
+ console.log(formatJournal(line));
37
+ }
38
+ }
package/service/node ADDED
@@ -0,0 +1,16 @@
1
+ #!/usr/bin/env bash
2
+ if [[ -d "${HOME}/.nvm" ]]; then
3
+ export NVM_DIR="${HOME}/.nvm"
4
+ source "${NVM_DIR}/nvm.sh"
5
+ if [[ -f .nvmrc ]]; then
6
+ nvm install
7
+ else
8
+ nvm use default
9
+ fi
10
+ fi
11
+ NODE="$(command -v node)"
12
+ if [[ -z "$NODE" ]]; then
13
+ echo "No node found. Please install one. User-level nvm is fine." >&2
14
+ exit -1
15
+ fi
16
+ node $*
@@ -0,0 +1,18 @@
1
+ #!/usr/bin/env node
2
+ import { spawnSync } from "node:child_process";
3
+ import { existsSync, rmSync } from "node:fs";
4
+ import { resolve } from "node:path";
5
+
6
+ import { getConfig } from "../lib/config.js";
7
+
8
+ const { config: project } = getConfig(process.cwd());
9
+
10
+ const systemdFile = resolve(process.env.HOME, '.config', 'systemd', 'user', `${project.name}.service`);
11
+
12
+ if (existsSync(systemdFile)) {
13
+ rmSync(systemdFile);
14
+ spawnSync('systemctl', ['--user', 'daemon-reload'], { stdio: "inherit" });
15
+ console.log(`Removed ${systemdFile}`);
16
+ } else {
17
+ console.warn(`No service file at ${systemdFile}`);
18
+ }