@phreshos/cli 0.1.6 → 0.1.8
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 +9 -6
- package/dist/cli.js +13 -2
- package/dist/create.js +1 -0
- package/dist/prompts.js +18 -8
- package/dist/style.js +10 -0
- package/dist/system/command.js +45 -19
- package/dist/system/lifecycle.js +5 -2
- package/dist/system/service/background.js +139 -0
- package/dist/system/service/index.js +1 -1
- package/dist/system/service/linux.js +17 -73
- package/dist/system/service/macos.js +1 -0
- package/dist/system/service/systemd.js +101 -0
- package/dist/template/package.json +1 -1
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -41,6 +41,7 @@ System.
|
|
|
41
41
|
phresh system install
|
|
42
42
|
phresh system uninstall
|
|
43
43
|
phresh system status
|
|
44
|
+
phresh system version
|
|
44
45
|
phresh system start
|
|
45
46
|
phresh system stop
|
|
46
47
|
phresh system enable
|
|
@@ -56,12 +57,14 @@ native per-user service. The selected release and the service entry therefore
|
|
|
56
57
|
cannot become two competing sources of truth if installation is interrupted.
|
|
57
58
|
It never reads a source checkout and never requires Bun or TypeScript.
|
|
58
59
|
|
|
59
|
-
The System runs under `launchd` on macOS and `systemd --user` on
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
60
|
+
The System runs under `launchd` on macOS and a real `systemd --user` manager on
|
|
61
|
+
Linux. In Linux containers with no init manager, it runs as a detached
|
|
62
|
+
user-owned background process that survives the terminal but ends with the
|
|
63
|
+
container. Automatic startup is unavailable there rather than being reported
|
|
64
|
+
as enabled. `start` and `stop` change current execution only; where a native
|
|
65
|
+
manager exists, `enable` and `disable` change automatic startup only. `status`
|
|
66
|
+
reports the installed version, service readiness, and automatic startup without
|
|
67
|
+
changing them; `version` reports only the installed System release.
|
|
65
68
|
|
|
66
69
|
Installation files and persistent System state have separate homes. Removing
|
|
67
70
|
the System unregisters its service and removes its release files while keeping
|
package/dist/cli.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { Command, Option } from "commander";
|
|
3
3
|
import metadata from "../package.json" with { type: "json" };
|
|
4
|
-
import { PromptCancelled } from "./prompts.js";
|
|
4
|
+
import { PromptCancelled, ReportedFailure } from "./prompts.js";
|
|
5
5
|
import create from "./create.js";
|
|
6
6
|
import install from "./install.js";
|
|
7
7
|
import launch from "./launch.js";
|
|
@@ -17,7 +17,11 @@ const program = new Command()
|
|
|
17
17
|
.description("Create Programs and manage PhreshOS")
|
|
18
18
|
.version(version, "-v, --version")
|
|
19
19
|
.showHelpAfterError()
|
|
20
|
-
.showSuggestionAfterError()
|
|
20
|
+
.showSuggestionAfterError()
|
|
21
|
+
.configureOutput({
|
|
22
|
+
writeOut: value => process.stdout.write(spaced(value)),
|
|
23
|
+
writeErr: value => process.stderr.write(spaced(value))
|
|
24
|
+
});
|
|
21
25
|
program.addHelpText("after", "\nRun phresh <command> --help for detailed command guidance.\n");
|
|
22
26
|
describe(program.command("create")
|
|
23
27
|
.description("create a new Program project")
|
|
@@ -119,11 +123,18 @@ try {
|
|
|
119
123
|
catch (error) {
|
|
120
124
|
if (error instanceof PromptCancelled)
|
|
121
125
|
process.exitCode = 0;
|
|
126
|
+
else if (error instanceof ReportedFailure)
|
|
127
|
+
process.exitCode = 1;
|
|
122
128
|
else {
|
|
123
129
|
console.error(`\n phresh: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
124
130
|
process.exitCode = 1;
|
|
125
131
|
}
|
|
126
132
|
}
|
|
133
|
+
function spaced(value) {
|
|
134
|
+
if (value.endsWith("\n\n"))
|
|
135
|
+
return value;
|
|
136
|
+
return value.endsWith("\n") ? `${value}\n` : `${value}\n\n`;
|
|
137
|
+
}
|
|
127
138
|
function attached(name, summary, detail, mode) {
|
|
128
139
|
describe(program.command(name)
|
|
129
140
|
.description(summary)
|
package/dist/create.js
CHANGED
|
@@ -60,6 +60,7 @@ export default async function create(options = {}, directory = process.cwd()) {
|
|
|
60
60
|
console.log(bold(`\n${bundled.development ? "Run Development Program" : "Run Program"}`));
|
|
61
61
|
console.log(accent(script));
|
|
62
62
|
console.log(bold("\nYou can now open the project and start building your Program"));
|
|
63
|
+
console.log("");
|
|
63
64
|
}
|
|
64
65
|
function template() {
|
|
65
66
|
const candidates = [
|
package/dist/prompts.js
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import { cancel, confirm, intro, isCancel, log, outro, select, spinner, text } from "@clack/prompts";
|
|
2
2
|
import colors from "picocolors";
|
|
3
|
-
import { column,
|
|
3
|
+
import { caution, column, ending, line, section } from "./style.js";
|
|
4
4
|
/** Signals an ordinary interactive cancellation rather than an operation failure. */
|
|
5
5
|
export class PromptCancelled extends Error {
|
|
6
6
|
}
|
|
7
|
+
/** An unsuccessful command whose complete explanation has already been shown. */
|
|
8
|
+
export class ReportedFailure extends Error {
|
|
9
|
+
}
|
|
7
10
|
/**
|
|
8
11
|
* One interaction language shared by commands that can ask questions.
|
|
9
12
|
*
|
|
@@ -16,26 +19,32 @@ export default function prompts() {
|
|
|
16
19
|
if (interactive)
|
|
17
20
|
intro(`${colors.bold(title)}${context ? ` ${colors.dim(`· ${context}`)}` : ""}`);
|
|
18
21
|
else
|
|
19
|
-
|
|
22
|
+
section(title, context);
|
|
20
23
|
}
|
|
21
24
|
function finish(message) {
|
|
22
25
|
if (interactive)
|
|
23
26
|
outro(colors.bold(message));
|
|
24
27
|
else
|
|
25
|
-
|
|
28
|
+
ending(message);
|
|
26
29
|
}
|
|
27
30
|
function detail(label, value, source) {
|
|
28
31
|
if (interactive)
|
|
29
|
-
log.message(`${colors.dim(column(label))}${value}${source ? ` ${colors.dim(source)}` : ""}
|
|
32
|
+
log.message(`${colors.dim(column(label))}${value}${source ? ` ${colors.dim(source)}` : ""}`, { spacing: 0 });
|
|
30
33
|
else
|
|
31
34
|
line(label, value, source);
|
|
32
35
|
}
|
|
33
36
|
function message(value = "") {
|
|
34
37
|
if (interactive)
|
|
35
|
-
log.message(value);
|
|
38
|
+
log.message(value, { spacing: 0 });
|
|
36
39
|
else
|
|
37
40
|
console.log(value ? ` ${value}` : "");
|
|
38
41
|
}
|
|
42
|
+
function warning(value) {
|
|
43
|
+
if (interactive)
|
|
44
|
+
log.warning(value, { spacing: 0 });
|
|
45
|
+
else
|
|
46
|
+
line("warning", caution(value));
|
|
47
|
+
}
|
|
39
48
|
async function progress(message, completed, work) {
|
|
40
49
|
if (!interactive)
|
|
41
50
|
return await work();
|
|
@@ -55,7 +64,7 @@ export default function prompts() {
|
|
|
55
64
|
async function ask(explanation, question, fallback) {
|
|
56
65
|
if (!interactive)
|
|
57
66
|
throw new Error(`${question} Supply the corresponding option when no terminal is attached`);
|
|
58
|
-
log.message(colors.dim(explanation));
|
|
67
|
+
log.message(colors.dim(explanation), { spacing: 0 });
|
|
59
68
|
const value = await text({
|
|
60
69
|
message: question,
|
|
61
70
|
placeholder: fallback,
|
|
@@ -68,7 +77,7 @@ export default function prompts() {
|
|
|
68
77
|
async function yes(explanation, question, fallback) {
|
|
69
78
|
if (!interactive)
|
|
70
79
|
throw new Error(`${question} Supply the corresponding option when no terminal is attached`);
|
|
71
|
-
log.message(colors.dim(explanation));
|
|
80
|
+
log.message(colors.dim(explanation), { spacing: 0 });
|
|
72
81
|
const value = await confirm({ message: question, initialValue: fallback });
|
|
73
82
|
if (isCancel(value))
|
|
74
83
|
stop();
|
|
@@ -77,7 +86,7 @@ export default function prompts() {
|
|
|
77
86
|
async function choose(explanation, question, values, fallback) {
|
|
78
87
|
if (!interactive)
|
|
79
88
|
throw new Error(`${question} Supply the corresponding option when no terminal is attached`);
|
|
80
|
-
log.message(colors.dim(explanation));
|
|
89
|
+
log.message(colors.dim(explanation), { spacing: 0 });
|
|
81
90
|
const value = await select({
|
|
82
91
|
message: question,
|
|
83
92
|
options: values.map(value => ({ value, label: value })),
|
|
@@ -97,6 +106,7 @@ export default function prompts() {
|
|
|
97
106
|
finish,
|
|
98
107
|
detail,
|
|
99
108
|
message,
|
|
109
|
+
warning,
|
|
100
110
|
progress,
|
|
101
111
|
ask,
|
|
102
112
|
yes,
|
package/dist/style.js
CHANGED
|
@@ -3,6 +3,9 @@ import colors from "picocolors";
|
|
|
3
3
|
export const dim = colors.dim;
|
|
4
4
|
export const bold = colors.bold;
|
|
5
5
|
export const accent = colors.cyan;
|
|
6
|
+
export const positive = colors.green;
|
|
7
|
+
export const caution = colors.yellow;
|
|
8
|
+
export const negative = colors.red;
|
|
6
9
|
// A label, what it says, and where that came from. The label is quiet
|
|
7
10
|
// and the value is not, because the value is the thing being reported.
|
|
8
11
|
//
|
|
@@ -17,6 +20,13 @@ export function column(label) {
|
|
|
17
20
|
}
|
|
18
21
|
export function heading(title, note) {
|
|
19
22
|
console.log("");
|
|
23
|
+
section(title, note);
|
|
24
|
+
console.log("");
|
|
25
|
+
}
|
|
26
|
+
export function section(title, note) {
|
|
20
27
|
console.log(` ${bold(title)}${note ? ` ${dim("·")} ${dim(note)}` : ""}`);
|
|
28
|
+
}
|
|
29
|
+
export function ending(message) {
|
|
30
|
+
console.log(` ${bold(message)}`);
|
|
21
31
|
console.log("");
|
|
22
32
|
}
|
package/dist/system/command.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import SystemLifecycle from "./lifecycle.js";
|
|
2
|
-
import prompts from "../prompts.js";
|
|
2
|
+
import prompts, { ReportedFailure } from "../prompts.js";
|
|
3
|
+
import { accent, caution, dim, negative, positive } from "../style.js";
|
|
3
4
|
/** Attach the System lifecycle without mixing it with Program commands. */
|
|
4
5
|
export default function systemCommands(program, provided) {
|
|
5
6
|
let lifecycle = provided;
|
|
@@ -11,8 +12,7 @@ export default function systemCommands(program, provided) {
|
|
|
11
12
|
const interaction = prompts();
|
|
12
13
|
interaction.begin("Install System", "official stable release");
|
|
13
14
|
const status = await interaction.progress("Installing PhreshOS", "PhreshOS installed", () => current().install());
|
|
14
|
-
|
|
15
|
-
interaction.finish("System installed");
|
|
15
|
+
interaction.finish(`PhreshOS ${accent(status.installed?.version ?? "")} installed`);
|
|
16
16
|
});
|
|
17
17
|
system.command("uninstall")
|
|
18
18
|
.description("remove the System installation and service")
|
|
@@ -24,37 +24,63 @@ export default function systemCommands(program, provided) {
|
|
|
24
24
|
interaction.finish("System uninstalled");
|
|
25
25
|
});
|
|
26
26
|
system.command("status")
|
|
27
|
-
.description("show
|
|
27
|
+
.description("show the System version and operating state")
|
|
28
28
|
.action(async function () {
|
|
29
|
+
const status = await installed(current());
|
|
29
30
|
const interaction = prompts();
|
|
30
31
|
interaction.begin("System Status");
|
|
31
|
-
report(interaction,
|
|
32
|
+
report(interaction, status);
|
|
33
|
+
interaction.finish(status.ready ? positive("System ready") : caution("System not ready"));
|
|
34
|
+
});
|
|
35
|
+
system.command("version")
|
|
36
|
+
.description("show the installed System version")
|
|
37
|
+
.action(async function () {
|
|
38
|
+
const status = await installed(current());
|
|
39
|
+
const interaction = prompts();
|
|
40
|
+
interaction.begin("System Version");
|
|
41
|
+
interaction.finish(`PhreshOS ${accent(status.installed.version)}`);
|
|
32
42
|
});
|
|
33
|
-
action(system, "start", "start the background service",
|
|
34
|
-
action(system, "stop", "stop the background service",
|
|
35
|
-
action(system, "enable", "enable automatic startup",
|
|
36
|
-
action(system, "disable", "disable automatic startup",
|
|
43
|
+
action(system, "start", "start the background service", current, lifecycle => lifecycle.start());
|
|
44
|
+
action(system, "stop", "stop the background service", current, lifecycle => lifecycle.stop());
|
|
45
|
+
action(system, "enable", "enable automatic startup", current, lifecycle => lifecycle.enable());
|
|
46
|
+
action(system, "disable", "disable automatic startup", current, lifecycle => lifecycle.disable());
|
|
37
47
|
return system;
|
|
38
48
|
}
|
|
39
|
-
function action(system, name, description, work) {
|
|
49
|
+
function action(system, name, description, current, work) {
|
|
40
50
|
system.command(name)
|
|
41
51
|
.description(description)
|
|
42
52
|
.action(async function () {
|
|
53
|
+
const lifecycle = current();
|
|
54
|
+
await installed(lifecycle);
|
|
43
55
|
const interaction = prompts();
|
|
44
56
|
interaction.begin(`System ${title(name)}`);
|
|
45
|
-
|
|
46
|
-
report(interaction, status);
|
|
57
|
+
await interaction.progress(`${title(name)}ing PhreshOS`, `PhreshOS ${past(name)}`, () => work(lifecycle));
|
|
47
58
|
interaction.finish(`System ${past(name)}`);
|
|
48
59
|
});
|
|
49
60
|
}
|
|
50
61
|
function report(interaction, status) {
|
|
51
|
-
interaction.detail("
|
|
52
|
-
interaction.detail("service", status
|
|
53
|
-
interaction.detail("startup", status.enabled ? "enabled" : "disabled");
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
62
|
+
interaction.detail("version", accent(status.installed?.version ?? "unknown"));
|
|
63
|
+
interaction.detail("service", service(status));
|
|
64
|
+
interaction.detail("startup", status.automaticStartup ? status.enabled ? positive("enabled") : dim("disabled") : dim("unavailable"));
|
|
65
|
+
}
|
|
66
|
+
async function installed(lifecycle) {
|
|
67
|
+
const status = await lifecycle.status();
|
|
68
|
+
if (status.installed)
|
|
69
|
+
return { ...status, installed: status.installed };
|
|
70
|
+
const interaction = prompts();
|
|
71
|
+
interaction.begin("PhreshOS System");
|
|
72
|
+
interaction.warning("PhreshOS System is not installed");
|
|
73
|
+
interaction.finish(`${dim("Install it with")} ${accent("phresh system install")}`);
|
|
74
|
+
throw new ReportedFailure();
|
|
75
|
+
}
|
|
76
|
+
function service(status) {
|
|
77
|
+
if (status.ready)
|
|
78
|
+
return positive("ready");
|
|
79
|
+
if (status.running)
|
|
80
|
+
return caution("starting");
|
|
81
|
+
if (status.registered)
|
|
82
|
+
return caution("stopped");
|
|
83
|
+
return negative("not registered");
|
|
58
84
|
}
|
|
59
85
|
function title(value) {
|
|
60
86
|
return `${value[0]?.toUpperCase()}${value.slice(1)}`;
|
package/dist/system/lifecycle.js
CHANGED
|
@@ -4,6 +4,7 @@ import { intakeReady, waitForIntake } from "./readiness.js";
|
|
|
4
4
|
import systemPaths from "./paths.js";
|
|
5
5
|
import systemService from "./service/index.js";
|
|
6
6
|
import nodeExecutable from "./node.js";
|
|
7
|
+
import { existsSync } from "node:fs";
|
|
7
8
|
import { join } from "node:path";
|
|
8
9
|
/** Coordinates acquisition, immutable files, and the native service as one transaction. */
|
|
9
10
|
export default class SystemLifecycle {
|
|
@@ -34,7 +35,8 @@ export default class SystemLifecycle {
|
|
|
34
35
|
const activation = await this.activate(prepared, previous, previousService);
|
|
35
36
|
try {
|
|
36
37
|
await service.register(definition(installation, executable));
|
|
37
|
-
await service.
|
|
38
|
+
if ((await service.inspect()).automaticStartup)
|
|
39
|
+
await service.enable();
|
|
38
40
|
await service.start();
|
|
39
41
|
await this.waitUntilReady();
|
|
40
42
|
await activation.commit();
|
|
@@ -123,7 +125,8 @@ export default class SystemLifecycle {
|
|
|
123
125
|
}
|
|
124
126
|
catch (error) {
|
|
125
127
|
const message = error instanceof Error ? error.message : String(error);
|
|
126
|
-
|
|
128
|
+
const log = existsSync(installation.paths.log) ? `. Service log: ${installation.paths.log}` : "";
|
|
129
|
+
throw new Error(`${message}${log}`, { cause: error });
|
|
127
130
|
}
|
|
128
131
|
}
|
|
129
132
|
async activate(prepared, previous, state) {
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import { mkdir, open, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
/**
|
|
7
|
+
* Owns the System for the lifetime of a Linux container with no init manager.
|
|
8
|
+
* Registration and the pid are user files; execution is a detached Node child.
|
|
9
|
+
*/
|
|
10
|
+
export default class BackgroundSystemService {
|
|
11
|
+
definition;
|
|
12
|
+
pid;
|
|
13
|
+
constructor(userHome) {
|
|
14
|
+
this.definition = join(userHome, ".config", "phreshos", "system-service.json");
|
|
15
|
+
this.pid = join(userHome, ".local", "state", "phreshos", "system.pid");
|
|
16
|
+
}
|
|
17
|
+
async inspect() {
|
|
18
|
+
const registered = existsSync(this.definition);
|
|
19
|
+
const pid = await this.readPid();
|
|
20
|
+
const running = pid !== undefined && await this.owns(pid);
|
|
21
|
+
if (pid !== undefined && !running)
|
|
22
|
+
await rm(this.pid, { force: true });
|
|
23
|
+
return {
|
|
24
|
+
registered,
|
|
25
|
+
automaticStartup: false,
|
|
26
|
+
enabled: false,
|
|
27
|
+
running,
|
|
28
|
+
...(running ? { pid } : {})
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
async register(definition) {
|
|
32
|
+
await this.stop();
|
|
33
|
+
await atomic(this.definition, JSON.stringify(definition), 0o600);
|
|
34
|
+
}
|
|
35
|
+
async unregister() {
|
|
36
|
+
await this.stop();
|
|
37
|
+
await rm(this.definition, { force: true });
|
|
38
|
+
}
|
|
39
|
+
async start() {
|
|
40
|
+
const definition = await this.readDefinition();
|
|
41
|
+
if ((await this.inspect()).running)
|
|
42
|
+
return;
|
|
43
|
+
await mkdir(dirname(definition.output), { recursive: true });
|
|
44
|
+
const output = await open(definition.output, "a", 0o600);
|
|
45
|
+
try {
|
|
46
|
+
const child = spawn(definition.executable, [definition.entry], {
|
|
47
|
+
cwd: definition.directory,
|
|
48
|
+
detached: true,
|
|
49
|
+
stdio: ["ignore", output.fd, output.fd]
|
|
50
|
+
});
|
|
51
|
+
await new Promise(function (settle, refuse) {
|
|
52
|
+
child.once("spawn", settle);
|
|
53
|
+
child.once("error", refuse);
|
|
54
|
+
});
|
|
55
|
+
if (child.pid === undefined)
|
|
56
|
+
throw new Error("The PhreshOS System background process has no pid");
|
|
57
|
+
await atomic(this.pid, String(child.pid), 0o600);
|
|
58
|
+
child.unref();
|
|
59
|
+
}
|
|
60
|
+
finally {
|
|
61
|
+
await output.close();
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
async stop() {
|
|
65
|
+
const pid = await this.readPid();
|
|
66
|
+
if (pid === undefined)
|
|
67
|
+
return;
|
|
68
|
+
if (!await this.owns(pid)) {
|
|
69
|
+
await rm(this.pid, { force: true });
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
process.kill(pid, "SIGTERM");
|
|
73
|
+
const until = Date.now() + 5_000;
|
|
74
|
+
while (Date.now() < until && await this.owns(pid))
|
|
75
|
+
await new Promise(settle => setTimeout(settle, 50));
|
|
76
|
+
if (await this.owns(pid))
|
|
77
|
+
process.kill(pid, "SIGKILL");
|
|
78
|
+
await rm(this.pid, { force: true });
|
|
79
|
+
}
|
|
80
|
+
async enable() {
|
|
81
|
+
throw new Error("Automatic System startup is unavailable because this Linux environment has no service manager");
|
|
82
|
+
}
|
|
83
|
+
async disable() {
|
|
84
|
+
throw new Error("Automatic System startup is unavailable because this Linux environment has no service manager");
|
|
85
|
+
}
|
|
86
|
+
async readDefinition() {
|
|
87
|
+
let value;
|
|
88
|
+
try {
|
|
89
|
+
value = JSON.parse(await readFile(this.definition, "utf8"));
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
throw new Error("The PhreshOS System service is not registered");
|
|
93
|
+
}
|
|
94
|
+
if (!definition(value))
|
|
95
|
+
throw new Error("The PhreshOS System service definition is invalid");
|
|
96
|
+
return value;
|
|
97
|
+
}
|
|
98
|
+
async readPid() {
|
|
99
|
+
try {
|
|
100
|
+
const value = (await readFile(this.pid, "utf8")).trim();
|
|
101
|
+
return /^[1-9][0-9]*$/.test(value) ? Number(value) : undefined;
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
return undefined;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
async owns(pid) {
|
|
108
|
+
try {
|
|
109
|
+
process.kill(pid, 0);
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
if (process.platform !== "linux")
|
|
115
|
+
return true;
|
|
116
|
+
const definition = await this.readDefinition().catch(() => undefined);
|
|
117
|
+
if (!definition)
|
|
118
|
+
return false;
|
|
119
|
+
const command = await readFile(`/proc/${pid}/cmdline`, "utf8").catch(() => "");
|
|
120
|
+
return command.split("\0").includes(definition.entry);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
async function atomic(path, content, mode) {
|
|
124
|
+
await mkdir(dirname(path), { recursive: true });
|
|
125
|
+
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
126
|
+
try {
|
|
127
|
+
await writeFile(temporary, content, { mode });
|
|
128
|
+
await rename(temporary, path);
|
|
129
|
+
}
|
|
130
|
+
finally {
|
|
131
|
+
await rm(temporary, { force: true });
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
function definition(value) {
|
|
135
|
+
if (!value || typeof value !== "object")
|
|
136
|
+
return false;
|
|
137
|
+
const candidate = value;
|
|
138
|
+
return ["executable", "entry", "directory", "output"].every(name => typeof candidate[name] === "string" && candidate[name] !== "");
|
|
139
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { homedir } from "node:os";
|
|
2
2
|
import LinuxSystemService from "./linux.js";
|
|
3
3
|
import MacOSSystemService from "./macos.js";
|
|
4
|
-
/** Select the
|
|
4
|
+
/** Select the per-user service implementation available in this environment. */
|
|
5
5
|
export default function systemService(platform = process.platform, userHome = homedir()) {
|
|
6
6
|
if (platform === "darwin")
|
|
7
7
|
return new MacOSSystemService(userHome);
|
|
@@ -1,94 +1,38 @@
|
|
|
1
1
|
import { execute } from "../process.js";
|
|
2
|
-
import
|
|
3
|
-
import
|
|
4
|
-
import { dirname, join } from "node:path";
|
|
5
|
-
import { randomUUID } from "node:crypto";
|
|
2
|
+
import BackgroundSystemService from "./background.js";
|
|
3
|
+
import SystemdSystemService from "./systemd.js";
|
|
6
4
|
const command = "systemctl";
|
|
7
|
-
|
|
5
|
+
/** Selects systemd only after proving that its user manager is real. */
|
|
8
6
|
export default class LinuxSystemService {
|
|
9
|
-
|
|
10
|
-
file;
|
|
7
|
+
selected;
|
|
11
8
|
constructor(userHome, run = execute) {
|
|
12
|
-
this.
|
|
13
|
-
this.file = join(userHome, ".config", "systemd", "user", unit);
|
|
9
|
+
this.selected = select(userHome, run);
|
|
14
10
|
}
|
|
15
11
|
async inspect() {
|
|
16
|
-
|
|
17
|
-
const active = await this.run(command, ["--user", "is-active", "--quiet", unit]);
|
|
18
|
-
const enabled = await this.run(command, ["--user", "is-enabled", "--quiet", unit]);
|
|
19
|
-
const pid = active.code === 0 ? await this.run(command, ["--user", "show", unit, "--property", "MainPID", "--value"]) : undefined;
|
|
20
|
-
const value = pid && /^[0-9]+$/.test(pid.stdout.trim()) ? Number(pid.stdout.trim()) : undefined;
|
|
21
|
-
return {
|
|
22
|
-
registered,
|
|
23
|
-
enabled: registered && enabled.code === 0,
|
|
24
|
-
running: registered && active.code === 0,
|
|
25
|
-
...(value ? { pid: value } : {})
|
|
26
|
-
};
|
|
12
|
+
return await (await this.selected).inspect();
|
|
27
13
|
}
|
|
28
14
|
async register(definition) {
|
|
29
|
-
await this.
|
|
30
|
-
await mkdir(dirname(this.file), { recursive: true });
|
|
31
|
-
await mkdir(dirname(definition.output), { recursive: true });
|
|
32
|
-
const temporary = `${this.file}.${randomUUID()}.tmp`;
|
|
33
|
-
try {
|
|
34
|
-
await writeFile(temporary, service(definition), { mode: 0o600 });
|
|
35
|
-
await rename(temporary, this.file);
|
|
36
|
-
}
|
|
37
|
-
finally {
|
|
38
|
-
await rm(temporary, { force: true });
|
|
39
|
-
}
|
|
40
|
-
await this.require(["--user", "daemon-reload"]);
|
|
15
|
+
await (await this.selected).register(definition);
|
|
41
16
|
}
|
|
42
17
|
async unregister() {
|
|
43
|
-
await this.
|
|
44
|
-
await this.run(command, ["--user", "disable", unit]);
|
|
45
|
-
await rm(this.file, { force: true });
|
|
46
|
-
await this.require(["--user", "daemon-reload"]);
|
|
47
|
-
await this.run(command, ["--user", "reset-failed", unit]);
|
|
18
|
+
await (await this.selected).unregister();
|
|
48
19
|
}
|
|
49
20
|
async start() {
|
|
50
|
-
|
|
51
|
-
throw new Error("The PhreshOS System service is not registered");
|
|
52
|
-
await this.require(["--user", "start", unit]);
|
|
21
|
+
await (await this.selected).start();
|
|
53
22
|
}
|
|
54
23
|
async stop() {
|
|
55
|
-
|
|
56
|
-
if (state.code === 0)
|
|
57
|
-
await this.require(["--user", "stop", unit]);
|
|
24
|
+
await (await this.selected).stop();
|
|
58
25
|
}
|
|
59
26
|
async enable() {
|
|
60
|
-
|
|
61
|
-
throw new Error("The PhreshOS System service is not registered");
|
|
62
|
-
await this.require(["--user", "enable", unit]);
|
|
27
|
+
await (await this.selected).enable();
|
|
63
28
|
}
|
|
64
29
|
async disable() {
|
|
65
|
-
|
|
66
|
-
throw new Error("The PhreshOS System service is not registered");
|
|
67
|
-
await this.require(["--user", "disable", unit]);
|
|
30
|
+
await (await this.selected).disable();
|
|
68
31
|
}
|
|
69
|
-
async require(args) {
|
|
70
|
-
const result = await this.run(command, args);
|
|
71
|
-
if (result.code !== 0)
|
|
72
|
-
throw new Error(result.stderr.trim() || result.stdout.trim() || `${command} exited with code ${result.code}`);
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
function service(definition) {
|
|
76
|
-
return `[Unit]
|
|
77
|
-
Description=PhreshOS System
|
|
78
|
-
|
|
79
|
-
[Service]
|
|
80
|
-
Type=simple
|
|
81
|
-
ExecStart=${quote(definition.executable)} ${quote(definition.entry)}
|
|
82
|
-
WorkingDirectory=${quote(definition.directory)}
|
|
83
|
-
Restart=on-failure
|
|
84
|
-
RestartSec=2
|
|
85
|
-
StandardOutput=append:${quote(definition.output)}
|
|
86
|
-
StandardError=append:${quote(definition.output)}
|
|
87
|
-
|
|
88
|
-
[Install]
|
|
89
|
-
WantedBy=default.target
|
|
90
|
-
`;
|
|
91
32
|
}
|
|
92
|
-
function
|
|
93
|
-
|
|
33
|
+
async function select(userHome, run) {
|
|
34
|
+
const result = await run(command, ["--user", "show-environment"]).catch(() => undefined);
|
|
35
|
+
const lines = result?.stdout.trim().split("\n").filter(Boolean) ?? [];
|
|
36
|
+
const systemd = result?.code === 0 && lines.length > 0 && lines.every(line => /^[a-zA-Z_][a-zA-Z0-9_]*=/.test(line));
|
|
37
|
+
return systemd ? new SystemdSystemService(userHome, run) : new BackgroundSystemService(userHome);
|
|
94
38
|
}
|
|
@@ -29,6 +29,7 @@ export default class MacOSSystemService {
|
|
|
29
29
|
const pid = /\bpid\s*=\s*(\d+)/.exec(service.stdout)?.[1];
|
|
30
30
|
return {
|
|
31
31
|
registered,
|
|
32
|
+
automaticStartup: true,
|
|
32
33
|
enabled: registered && !explicitlyDisabled,
|
|
33
34
|
running: service.code === 0 && /\bstate\s*=\s*running\b/.test(service.stdout),
|
|
34
35
|
...(pid ? { pid: Number(pid) } : {})
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { execute } from "../process.js";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { mkdir, rename, rm, writeFile } from "node:fs/promises";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { randomUUID } from "node:crypto";
|
|
6
|
+
const command = "systemctl";
|
|
7
|
+
const unit = "phreshos.service";
|
|
8
|
+
/** A real systemd user manager, after Linux selection has proved it exists. */
|
|
9
|
+
export default class SystemdSystemService {
|
|
10
|
+
run;
|
|
11
|
+
file;
|
|
12
|
+
constructor(userHome, run = execute) {
|
|
13
|
+
this.run = run;
|
|
14
|
+
this.file = join(userHome, ".config", "systemd", "user", unit);
|
|
15
|
+
}
|
|
16
|
+
async inspect() {
|
|
17
|
+
const registered = existsSync(this.file);
|
|
18
|
+
const active = await this.run(command, ["--user", "is-active", "--quiet", unit]);
|
|
19
|
+
const enabled = await this.run(command, ["--user", "is-enabled", "--quiet", unit]);
|
|
20
|
+
const pid = active.code === 0 ? await this.run(command, ["--user", "show", unit, "--property", "MainPID", "--value"]) : undefined;
|
|
21
|
+
const value = pid && /^[0-9]+$/.test(pid.stdout.trim()) ? Number(pid.stdout.trim()) : undefined;
|
|
22
|
+
return {
|
|
23
|
+
registered,
|
|
24
|
+
automaticStartup: true,
|
|
25
|
+
enabled: registered && enabled.code === 0,
|
|
26
|
+
running: registered && active.code === 0,
|
|
27
|
+
...(value ? { pid: value } : {})
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
async register(definition) {
|
|
31
|
+
await this.stop();
|
|
32
|
+
await mkdir(dirname(this.file), { recursive: true });
|
|
33
|
+
await mkdir(dirname(definition.output), { recursive: true });
|
|
34
|
+
const temporary = `${this.file}.${randomUUID()}.tmp`;
|
|
35
|
+
try {
|
|
36
|
+
await writeFile(temporary, service(definition), { mode: 0o600 });
|
|
37
|
+
await rename(temporary, this.file);
|
|
38
|
+
}
|
|
39
|
+
finally {
|
|
40
|
+
await rm(temporary, { force: true });
|
|
41
|
+
}
|
|
42
|
+
await this.require(["--user", "daemon-reload"]);
|
|
43
|
+
}
|
|
44
|
+
async unregister() {
|
|
45
|
+
await this.stop();
|
|
46
|
+
await this.run(command, ["--user", "disable", unit]);
|
|
47
|
+
await rm(this.file, { force: true });
|
|
48
|
+
await this.require(["--user", "daemon-reload"]);
|
|
49
|
+
await this.run(command, ["--user", "reset-failed", unit]);
|
|
50
|
+
}
|
|
51
|
+
async start() {
|
|
52
|
+
if (!existsSync(this.file))
|
|
53
|
+
throw new Error("The PhreshOS System service is not registered");
|
|
54
|
+
await this.require(["--user", "start", unit]);
|
|
55
|
+
}
|
|
56
|
+
async stop() {
|
|
57
|
+
const state = await this.run(command, ["--user", "is-active", "--quiet", unit]);
|
|
58
|
+
if (state.code === 0)
|
|
59
|
+
await this.require(["--user", "stop", unit]);
|
|
60
|
+
}
|
|
61
|
+
async enable() {
|
|
62
|
+
if (!existsSync(this.file))
|
|
63
|
+
throw new Error("The PhreshOS System service is not registered");
|
|
64
|
+
await this.require(["--user", "enable", unit]);
|
|
65
|
+
}
|
|
66
|
+
async disable() {
|
|
67
|
+
if (!existsSync(this.file))
|
|
68
|
+
throw new Error("The PhreshOS System service is not registered");
|
|
69
|
+
await this.require(["--user", "disable", unit]);
|
|
70
|
+
}
|
|
71
|
+
async require(args) {
|
|
72
|
+
const result = await this.run(command, args);
|
|
73
|
+
if (result.code !== 0)
|
|
74
|
+
throw new Error(result.stderr.trim() || result.stdout.trim() || `${command} exited with code ${result.code}`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
function service(definition) {
|
|
78
|
+
return `[Unit]
|
|
79
|
+
Description=PhreshOS System
|
|
80
|
+
|
|
81
|
+
[Service]
|
|
82
|
+
Type=simple
|
|
83
|
+
ExecStart=${quote(definition.executable)} ${quote(definition.entry)}
|
|
84
|
+
WorkingDirectory=${setting(definition.directory)}
|
|
85
|
+
Restart=on-failure
|
|
86
|
+
RestartSec=2
|
|
87
|
+
StandardOutput=append:${setting(definition.output)}
|
|
88
|
+
StandardError=append:${setting(definition.output)}
|
|
89
|
+
|
|
90
|
+
[Install]
|
|
91
|
+
WantedBy=default.target
|
|
92
|
+
`;
|
|
93
|
+
}
|
|
94
|
+
function quote(value) {
|
|
95
|
+
return JSON.stringify(value);
|
|
96
|
+
}
|
|
97
|
+
function setting(value) {
|
|
98
|
+
if (value.includes("\n") || value.includes("\r"))
|
|
99
|
+
throw new Error("A systemd service path cannot contain a line break");
|
|
100
|
+
return value.replaceAll("%", "%%");
|
|
101
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@phreshos/cli",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.1.
|
|
4
|
+
"version": "0.1.8",
|
|
5
5
|
"description": "The Phresh command-line interface for Program projects and system management.",
|
|
6
6
|
"engines": {
|
|
7
7
|
"node": ">=20.10"
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
"test": "node --run compile && node --test tests/*.test.mjs",
|
|
14
14
|
"verify": "node --run build && node --run test && node scripts/verify-package.mjs",
|
|
15
15
|
"verify:system-release": "node --run compile && node scripts/verify-system-release.mjs",
|
|
16
|
+
"verify:linux-service": "node --run compile && node scripts/verify-linux-service.mjs",
|
|
16
17
|
"verify:macos-service": "node --run compile && node scripts/verify-macos-service.mjs",
|
|
17
18
|
"prepack": "node --run build"
|
|
18
19
|
},
|