@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 +58 -0
- package/index.js +11 -0
- package/lib/config.js +48 -0
- package/lib/index.js +3 -0
- package/lib/jsonCmd.js +58 -0
- package/lib/shellQuote.js +11 -0
- package/package.json +28 -0
- package/service/control.js +19 -0
- package/service/index.js +28 -0
- package/service/install.js +59 -0
- package/service/logs.js +38 -0
- package/service/node +16 -0
- package/service/uninstall.js +18 -0
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
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
|
+
}
|
package/service/index.js
ADDED
|
@@ -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" });
|
package/service/logs.js
ADDED
|
@@ -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
|
+
}
|