@phreshos/cli 0.1.7 → 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 CHANGED
@@ -57,12 +57,14 @@ native per-user service. The selected release and the service entry therefore
57
57
  cannot become two competing sources of truth if installation is interrupted.
58
58
  It never reads a source checkout and never requires Bun or TypeScript.
59
59
 
60
- The System runs under `launchd` on macOS and `systemd --user` on Linux. The
61
- native manager owns it after the CLI exits and restarts a failed active
62
- service. `start` and `stop` change current execution only; `enable` and
63
- `disable` change automatic startup only. `status` reports the installed version,
64
- service readiness, and automatic startup without changing them; `version`
65
- reports only the installed System release.
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.
66
68
 
67
69
  Installation files and persistent System state have separate homes. Removing
68
70
  the System unregisters its service and removes its release files while keeping
@@ -61,7 +61,7 @@ function action(system, name, description, current, work) {
61
61
  function report(interaction, status) {
62
62
  interaction.detail("version", accent(status.installed?.version ?? "unknown"));
63
63
  interaction.detail("service", service(status));
64
- interaction.detail("startup", status.enabled ? positive("enabled") : dim("disabled"));
64
+ interaction.detail("startup", status.automaticStartup ? status.enabled ? positive("enabled") : dim("disabled") : dim("unavailable"));
65
65
  }
66
66
  async function installed(lifecycle) {
67
67
  const status = await lifecycle.status();
@@ -35,7 +35,8 @@ export default class SystemLifecycle {
35
35
  const activation = await this.activate(prepared, previous, previousService);
36
36
  try {
37
37
  await service.register(definition(installation, executable));
38
- await service.enable();
38
+ if ((await service.inspect()).automaticStartup)
39
+ await service.enable();
39
40
  await service.start();
40
41
  await this.waitUntilReady();
41
42
  await activation.commit();
@@ -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 native per-user service manager without changing its semantics. */
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,99 +1,38 @@
1
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";
2
+ import BackgroundSystemService from "./background.js";
3
+ import SystemdSystemService from "./systemd.js";
6
4
  const command = "systemctl";
7
- const unit = "phreshos.service";
5
+ /** Selects systemd only after proving that its user manager is real. */
8
6
  export default class LinuxSystemService {
9
- run;
10
- file;
7
+ selected;
11
8
  constructor(userHome, run = execute) {
12
- this.run = run;
13
- this.file = join(userHome, ".config", "systemd", "user", unit);
9
+ this.selected = select(userHome, run);
14
10
  }
15
11
  async inspect() {
16
- const registered = existsSync(this.file);
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.stop();
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.stop();
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
- if (!existsSync(this.file))
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
- const state = await this.run(command, ["--user", "is-active", "--quiet", unit]);
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
- if (!existsSync(this.file))
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
- if (!existsSync(this.file))
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=${setting(definition.directory)}
83
- Restart=on-failure
84
- RestartSec=2
85
- StandardOutput=append:${setting(definition.output)}
86
- StandardError=append:${setting(definition.output)}
87
-
88
- [Install]
89
- WantedBy=default.target
90
- `;
91
- }
92
- function quote(value) {
93
- return JSON.stringify(value);
94
32
  }
95
- function setting(value) {
96
- if (value.includes("\n") || value.includes("\r"))
97
- throw new Error("A systemd service path cannot contain a line break");
98
- return value.replaceAll("%", "%%");
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);
99
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
+ }
@@ -16,7 +16,7 @@
16
16
  "react-dom": "^19.2.8"
17
17
  },
18
18
  "devDependencies": {
19
- "@phreshos/cli": "^0.1.7",
19
+ "@phreshos/cli": "^0.1.8",
20
20
  "@types/node": "^26.2.0",
21
21
  "@types/react": "^19.2.18",
22
22
  "@types/react-dom": "^19.2.4",
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@phreshos/cli",
3
3
  "type": "module",
4
- "version": "0.1.7",
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"