@phreshos/cli 0.1.5 → 0.1.7

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.
@@ -0,0 +1,241 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
3
+ import { mkdir, mkdtemp, open, readFile, readdir, readlink, rename, rm, symlink, writeFile } from "node:fs/promises";
4
+ import { dirname, isAbsolute, join, relative, resolve } from "node:path";
5
+ import { requireSuccess } from "./process.js";
6
+ import AdmZip from "adm-zip";
7
+ /** Owns immutable release directories and the one atomic active record. */
8
+ export default class SystemInstallation {
9
+ paths;
10
+ dependencies;
11
+ constructor(paths, dependencies = installProductionDependencies) {
12
+ this.paths = paths;
13
+ this.dependencies = dependencies;
14
+ }
15
+ async current() {
16
+ let directory;
17
+ let value;
18
+ try {
19
+ directory = resolve(dirname(this.paths.current), await readlink(this.paths.current));
20
+ value = JSON.parse(await readFile(join(directory, ".release.json"), "utf8"));
21
+ }
22
+ catch (error) {
23
+ if (record(error) && error.code === "ENOENT")
24
+ return undefined;
25
+ throw new Error(`The System current release is invalid: ${this.paths.current}`);
26
+ }
27
+ const within = relative(this.paths.releases, directory);
28
+ if (!releaseRecord(value) || isAbsolute(within) || within.startsWith("..") || within === "") {
29
+ throw new Error(`The System current release is invalid: ${this.paths.current}`);
30
+ }
31
+ if (!existsSync(join(directory, "server", "main.js")))
32
+ throw new Error(`The installed System ${value.version} is incomplete`);
33
+ return { ...value, directory };
34
+ }
35
+ /** Serialize operations that can change files or native-service state. */
36
+ async exclusive(work) {
37
+ await mkdir(this.paths.root, { recursive: true });
38
+ const lock = join(this.paths.root, ".operation");
39
+ const owner = String(process.pid);
40
+ while (true) {
41
+ try {
42
+ const handle = await open(lock, "wx", 0o600);
43
+ try {
44
+ await handle.writeFile(`${owner}\n`);
45
+ return await work();
46
+ }
47
+ finally {
48
+ await handle.close();
49
+ await rm(lock, { force: true });
50
+ }
51
+ }
52
+ catch (error) {
53
+ if (!record(error) || error.code !== "EEXIST")
54
+ throw error;
55
+ const pid = await lockOwner(lock);
56
+ if (pid !== undefined && processAlive(pid))
57
+ throw new Error(`Another PhreshOS System operation is running (${pid})`);
58
+ const stale = `${lock}.stale-${randomUUID()}`;
59
+ try {
60
+ await rename(lock, stale);
61
+ await rm(stale, { force: true });
62
+ }
63
+ catch (replacement) {
64
+ if (!record(replacement) || replacement.code !== "ENOENT")
65
+ throw replacement;
66
+ }
67
+ }
68
+ }
69
+ }
70
+ async prepare(release) {
71
+ await mkdir(this.paths.releases, { recursive: true });
72
+ const directory = await mkdtemp(join(this.paths.releases, ".staging-"));
73
+ try {
74
+ extract(release.bytes, directory);
75
+ await validateDistribution(directory);
76
+ await this.dependencies(directory);
77
+ return { release, directory };
78
+ }
79
+ catch (error) {
80
+ await rm(directory, { recursive: true, force: true });
81
+ throw error;
82
+ }
83
+ }
84
+ async abandon(prepared) {
85
+ await rm(prepared.directory, { recursive: true, force: true });
86
+ }
87
+ async activate(prepared, previous) {
88
+ const directory = join(this.paths.releases, prepared.release.version);
89
+ const backup = `${directory}.previous-${randomUUID()}`;
90
+ const hadDirectory = existsSync(directory);
91
+ const installedAt = new Date().toISOString();
92
+ if (hadDirectory)
93
+ await rename(directory, backup);
94
+ try {
95
+ await writeFile(join(prepared.directory, ".release.json"), `${JSON.stringify({
96
+ version: prepared.release.version,
97
+ digest: prepared.release.digest,
98
+ installedAt
99
+ }, null, 2)}\n`, { mode: 0o600 });
100
+ await rename(prepared.directory, directory);
101
+ await this.pointTo(directory);
102
+ }
103
+ catch (error) {
104
+ await rm(directory, { recursive: true, force: true });
105
+ if (hadDirectory)
106
+ await rename(backup, directory);
107
+ if (previous)
108
+ await this.pointTo(previous.directory);
109
+ throw error;
110
+ }
111
+ const installed = {
112
+ version: prepared.release.version,
113
+ digest: prepared.release.digest,
114
+ directory,
115
+ installedAt
116
+ };
117
+ let settled = false;
118
+ return {
119
+ installed,
120
+ commit: async () => {
121
+ if (settled)
122
+ return;
123
+ settled = true;
124
+ if (hadDirectory)
125
+ await rm(backup, { recursive: true, force: true });
126
+ await this.removeOtherReleases(directory).catch(() => undefined);
127
+ },
128
+ rollback: async () => {
129
+ if (settled)
130
+ return;
131
+ settled = true;
132
+ if (previous)
133
+ await this.pointTo(previous.directory);
134
+ else
135
+ await rm(this.paths.current, { force: true });
136
+ await rm(directory, { recursive: true, force: true });
137
+ if (hadDirectory)
138
+ await rename(backup, directory);
139
+ }
140
+ };
141
+ }
142
+ async remove() {
143
+ await rm(this.paths.root, { recursive: true, force: true });
144
+ }
145
+ async removeOtherReleases(current) {
146
+ for (const entry of await readdir(this.paths.releases, { withFileTypes: true })) {
147
+ if (!entry.isDirectory())
148
+ continue;
149
+ const directory = join(this.paths.releases, entry.name);
150
+ if (directory !== current)
151
+ await rm(directory, { recursive: true, force: true });
152
+ }
153
+ }
154
+ async pointTo(directory) {
155
+ await mkdir(dirname(this.paths.current), { recursive: true });
156
+ const temporary = `${this.paths.current}.${randomUUID()}.tmp`;
157
+ try {
158
+ await symlink(relative(dirname(this.paths.current), directory), temporary, "dir");
159
+ await rename(temporary, this.paths.current);
160
+ }
161
+ finally {
162
+ await rm(temporary, { force: true });
163
+ }
164
+ }
165
+ }
166
+ async function validateDistribution(directory) {
167
+ let manifest;
168
+ try {
169
+ manifest = JSON.parse(await readFile(join(directory, "package.json"), "utf8"));
170
+ }
171
+ catch {
172
+ throw new Error("The System release has no valid production package manifest");
173
+ }
174
+ if (!record(manifest) || manifest.type !== "module" || !record(manifest.scripts) || manifest.scripts.start !== "node server/main.js" || !record(manifest.dependencies)) {
175
+ throw new Error("The System release package manifest is invalid");
176
+ }
177
+ for (const path of ["server/main.js", "client/index.html"]) {
178
+ if (!existsSync(join(directory, path)))
179
+ throw new Error(`The System release is missing ${path}`);
180
+ }
181
+ }
182
+ function extract(bytes, directory) {
183
+ const archive = new AdmZip(bytes);
184
+ for (const entry of archive.getEntries()) {
185
+ const name = entry.entryName.replaceAll("\\", "/");
186
+ const destination = resolve(directory, name);
187
+ const within = relative(directory, destination);
188
+ if (!name || name.startsWith("/") || name.split("/").includes("..") || isAbsolute(within) || within.startsWith("..")) {
189
+ throw new Error(`The System release contains an unsafe path: ${entry.entryName}`);
190
+ }
191
+ if (entry.isDirectory) {
192
+ requireDirectory(destination);
193
+ continue;
194
+ }
195
+ requireDirectory(dirname(destination));
196
+ writeFileSync(destination, entry.getData(), { mode: 0o600 });
197
+ }
198
+ }
199
+ function requireDirectory(path) {
200
+ mkdirSync(path, { recursive: true, mode: 0o700 });
201
+ }
202
+ async function installProductionDependencies(directory) {
203
+ const npm = process.platform === "win32" ? "npm.cmd" : "npm";
204
+ await requireSuccess(npm, ["install", "--omit=dev", "--no-audit", "--no-fund", "--package-lock=false"], { cwd: directory });
205
+ }
206
+ function releaseRecord(value) {
207
+ return record(value)
208
+ && typeof value.version === "string"
209
+ && /^[0-9]+\.[0-9]+\.[0-9]+$/.test(value.version)
210
+ && typeof value.digest === "string"
211
+ && /^[a-f0-9]{64}$/.test(value.digest)
212
+ && typeof value.installedAt === "string";
213
+ }
214
+ function record(value) {
215
+ return typeof value === "object" && value !== null && !Array.isArray(value);
216
+ }
217
+ async function lockOwner(path) {
218
+ for (let attempt = 0; attempt < 2; attempt += 1) {
219
+ try {
220
+ const value = (await readFile(path, "utf8")).trim();
221
+ if (/^[1-9][0-9]*$/.test(value))
222
+ return Number(value);
223
+ }
224
+ catch (error) {
225
+ if (!record(error) || error.code !== "ENOENT")
226
+ throw error;
227
+ return undefined;
228
+ }
229
+ await new Promise(settle => setTimeout(settle, 50));
230
+ }
231
+ return undefined;
232
+ }
233
+ function processAlive(pid) {
234
+ try {
235
+ process.kill(pid, 0);
236
+ return true;
237
+ }
238
+ catch (error) {
239
+ return record(error) && error.code === "EPERM";
240
+ }
241
+ }
@@ -0,0 +1,176 @@
1
+ import SystemInstallation from "./installation.js";
2
+ import { downloadSystemRelease, resolveSystemRelease } from "./release.js";
3
+ import { intakeReady, waitForIntake } from "./readiness.js";
4
+ import systemPaths from "./paths.js";
5
+ import systemService from "./service/index.js";
6
+ import nodeExecutable from "./node.js";
7
+ import { existsSync } from "node:fs";
8
+ import { join } from "node:path";
9
+ /** Coordinates acquisition, immutable files, and the native service as one transaction. */
10
+ export default class SystemLifecycle {
11
+ dependencies;
12
+ constructor(dependencies) {
13
+ const paths = systemPaths();
14
+ const service = dependencies?.service ?? systemService();
15
+ this.dependencies = {
16
+ installation: dependencies?.installation ?? new SystemInstallation(paths),
17
+ service,
18
+ resolveRelease: dependencies?.resolveRelease ?? resolveSystemRelease,
19
+ downloadRelease: dependencies?.downloadRelease ?? downloadSystemRelease,
20
+ ready: dependencies?.ready ?? intakeReady,
21
+ wait: dependencies?.wait ?? waitForIntake
22
+ };
23
+ }
24
+ async install() {
25
+ return await this.dependencies.installation.exclusive(() => this.installExclusive());
26
+ }
27
+ async installExclusive() {
28
+ const { installation, service } = this.dependencies;
29
+ const previous = await installation.current();
30
+ const previousService = await service.inspect();
31
+ const executable = await nodeExecutable();
32
+ const release = await this.dependencies.resolveRelease();
33
+ const downloaded = await this.dependencies.downloadRelease(release);
34
+ const prepared = await installation.prepare(downloaded);
35
+ const activation = await this.activate(prepared, previous, previousService);
36
+ try {
37
+ await service.register(definition(installation, executable));
38
+ await service.enable();
39
+ await service.start();
40
+ await this.waitUntilReady();
41
+ await activation.commit();
42
+ return await this.status();
43
+ }
44
+ catch (error) {
45
+ await service.stop().catch(() => undefined);
46
+ await activation.rollback();
47
+ return await this.restoredFailure(error, previous, previousService);
48
+ }
49
+ }
50
+ async uninstall() {
51
+ return await this.dependencies.installation.exclusive(() => this.uninstallExclusive());
52
+ }
53
+ async uninstallExclusive() {
54
+ const { installation, service } = this.dependencies;
55
+ const state = await service.inspect();
56
+ if (state.running)
57
+ await service.stop();
58
+ if (state.enabled)
59
+ await service.disable();
60
+ if (state.registered)
61
+ await service.unregister();
62
+ await installation.remove();
63
+ }
64
+ async start() {
65
+ return await this.dependencies.installation.exclusive(() => this.startExclusive());
66
+ }
67
+ async startExclusive() {
68
+ await this.requireInstalledService();
69
+ await this.dependencies.service.start();
70
+ await this.waitUntilReady();
71
+ return await this.status();
72
+ }
73
+ async stop() {
74
+ return await this.dependencies.installation.exclusive(() => this.stopExclusive());
75
+ }
76
+ async stopExclusive() {
77
+ await this.requireInstalledService();
78
+ await this.dependencies.service.stop();
79
+ return await this.status();
80
+ }
81
+ async enable() {
82
+ return await this.dependencies.installation.exclusive(() => this.enableExclusive());
83
+ }
84
+ async enableExclusive() {
85
+ await this.requireInstalledService();
86
+ await this.dependencies.service.enable();
87
+ return await this.status();
88
+ }
89
+ async disable() {
90
+ return await this.dependencies.installation.exclusive(() => this.disableExclusive());
91
+ }
92
+ async disableExclusive() {
93
+ await this.requireInstalledService();
94
+ await this.dependencies.service.disable();
95
+ return await this.status();
96
+ }
97
+ async status() {
98
+ const { installation, service } = this.dependencies;
99
+ const [installed, state] = await Promise.all([installation.current(), service.inspect()]);
100
+ const ready = state.running && await this.dependencies.ready(installation.paths.intake);
101
+ return {
102
+ ...(installed ? { installed } : {}),
103
+ ...state,
104
+ ready,
105
+ root: installation.paths.root,
106
+ intake: installation.paths.intake,
107
+ log: installation.paths.log
108
+ };
109
+ }
110
+ async requireInstalledService() {
111
+ const [installed, state] = await Promise.all([
112
+ this.dependencies.installation.current(),
113
+ this.dependencies.service.inspect()
114
+ ]);
115
+ if (!installed)
116
+ throw new Error("PhreshOS System is not installed — run phresh system install");
117
+ if (!state.registered)
118
+ throw new Error("The PhreshOS System service is not registered — run phresh system install");
119
+ }
120
+ async waitUntilReady() {
121
+ const { installation, service } = this.dependencies;
122
+ try {
123
+ await this.dependencies.wait(installation.paths.intake, async () => (await service.inspect()).running);
124
+ }
125
+ catch (error) {
126
+ const message = error instanceof Error ? error.message : String(error);
127
+ const log = existsSync(installation.paths.log) ? `. Service log: ${installation.paths.log}` : "";
128
+ throw new Error(`${message}${log}`, { cause: error });
129
+ }
130
+ }
131
+ async activate(prepared, previous, state) {
132
+ const { installation, service } = this.dependencies;
133
+ try {
134
+ if (state.running)
135
+ await service.stop();
136
+ return await installation.activate(prepared, previous);
137
+ }
138
+ catch (error) {
139
+ await installation.abandon(prepared);
140
+ return await this.restoredFailure(error, previous, state);
141
+ }
142
+ }
143
+ async restore(previous, state) {
144
+ const { installation, service } = this.dependencies;
145
+ if (!previous) {
146
+ await service.unregister().catch(() => undefined);
147
+ return;
148
+ }
149
+ await service.register(definition(installation, await nodeExecutable()));
150
+ if (state.enabled)
151
+ await service.enable();
152
+ else
153
+ await service.disable();
154
+ if (state.running) {
155
+ await service.start();
156
+ await this.waitUntilReady();
157
+ }
158
+ }
159
+ async restoredFailure(error, previous, state) {
160
+ try {
161
+ await this.restore(previous, state);
162
+ }
163
+ catch (restoration) {
164
+ throw new AggregateError([error, restoration], "The System update failed and its previous service could not be restored");
165
+ }
166
+ throw error;
167
+ }
168
+ }
169
+ function definition(installation, executable) {
170
+ return {
171
+ executable,
172
+ entry: join(installation.paths.current, "server", "main.js"),
173
+ directory: installation.paths.current,
174
+ output: installation.paths.log
175
+ };
176
+ }
@@ -0,0 +1,13 @@
1
+ import { isAbsolute } from "node:path";
2
+ import { requireSuccess } from "./process.js";
3
+ /** Resolve the real Node executable even when another runtime invoked the CLI. */
4
+ export default async function nodeExecutable() {
5
+ if (!process.versions.bun && process.release.name === "node" && isAbsolute(process.execPath))
6
+ return process.execPath;
7
+ const command = process.platform === "win32" ? "node.exe" : "node";
8
+ const result = await requireSuccess(command, ["-p", "process.execPath"]);
9
+ const executable = result.stdout.trim();
10
+ if (!isAbsolute(executable))
11
+ throw new Error("A real Node.js executable is required to run the PhreshOS System service");
12
+ return executable;
13
+ }
@@ -0,0 +1,28 @@
1
+ import { homedir } from "node:os";
2
+ import { isAbsolute, join } from "node:path";
3
+ /** The installation is separate from the persistent state it operates on. */
4
+ export default function systemPaths(platform = process.platform, userHome = homedir(), variables = process.env) {
5
+ const storage = join(userHome, ".phreshos");
6
+ const root = platform === "darwin"
7
+ ? join(userHome, "Library", "Application Support", "PhreshOS", "System")
8
+ : platform === "linux"
9
+ ? join(absoluteOr(variables.XDG_DATA_HOME, join(userHome, ".local", "share"), "XDG_DATA_HOME"), "phreshos", "system")
10
+ : platform === "win32"
11
+ ? join(absoluteOr(variables.LOCALAPPDATA, join(userHome, "AppData", "Local"), "LOCALAPPDATA"), "PhreshOS", "System")
12
+ : join(userHome, ".local", "share", "phreshos", "system");
13
+ return {
14
+ root,
15
+ releases: join(root, "releases"),
16
+ current: join(root, "current"),
17
+ storage,
18
+ intake: join(storage, "intake.sock"),
19
+ log: join(storage, "service.log")
20
+ };
21
+ }
22
+ function absoluteOr(value, fallback, name) {
23
+ if (value === undefined)
24
+ return fallback;
25
+ if (!isAbsolute(value))
26
+ throw new Error(`${name} must be an absolute filesystem path`);
27
+ return value;
28
+ }
@@ -0,0 +1,23 @@
1
+ import { spawn } from "node:child_process";
2
+ /** Execute one exact program without involving a command shell. */
3
+ export function execute(command, args, options = {}) {
4
+ return new Promise(function (settle, refuse) {
5
+ const child = spawn(command, args, {
6
+ cwd: options.cwd,
7
+ env: options.env,
8
+ stdio: ["ignore", "pipe", "pipe"]
9
+ });
10
+ let stdout = "";
11
+ let stderr = "";
12
+ child.stdout.setEncoding("utf8").on("data", chunk => stdout += chunk);
13
+ child.stderr.setEncoding("utf8").on("data", chunk => stderr += chunk);
14
+ child.once("error", refuse);
15
+ child.once("close", code => settle({ code: code ?? 1, stdout, stderr }));
16
+ });
17
+ }
18
+ export async function requireSuccess(command, args, options) {
19
+ const result = await execute(command, args, options);
20
+ if (result.code !== 0)
21
+ throw new Error(result.stderr.trim() || result.stdout.trim() || `${command} exited with code ${result.code}`);
22
+ return result;
23
+ }
@@ -0,0 +1,29 @@
1
+ import { connect } from "node:net";
2
+ export async function intakeReady(path) {
3
+ return await new Promise(function (settle) {
4
+ const socket = connect(path);
5
+ const timeout = setTimeout(() => finish(false), 500);
6
+ let finished = false;
7
+ socket.once("connect", () => finish(true));
8
+ socket.once("error", () => finish(false));
9
+ function finish(ready) {
10
+ if (finished)
11
+ return;
12
+ finished = true;
13
+ clearTimeout(timeout);
14
+ socket.destroy();
15
+ settle(ready);
16
+ }
17
+ });
18
+ }
19
+ export async function waitForIntake(path, running, timeout = 15_000) {
20
+ const until = Date.now() + timeout;
21
+ while (Date.now() < until) {
22
+ if (await intakeReady(path))
23
+ return;
24
+ if (!await running())
25
+ throw new Error("The PhreshOS System stopped before its intake became ready");
26
+ await new Promise(settle => setTimeout(settle, 100));
27
+ }
28
+ throw new Error(`The PhreshOS System did not become ready within ${Math.ceil(timeout / 1000)} seconds`);
29
+ }
@@ -0,0 +1,86 @@
1
+ import { createHash } from "node:crypto";
2
+ const releases = "https://api.github.com/repos/PhreshOS/system/releases?per_page=100";
3
+ const compatible = { major: 0, minor: 1 };
4
+ /** Resolve the newest stable release in the System line supported by this CLI. */
5
+ export async function resolveSystemRelease(fetcher = fetch) {
6
+ const response = await fetcher(releases, {
7
+ headers: {
8
+ Accept: "application/vnd.github+json",
9
+ "User-Agent": "@phreshos/cli"
10
+ },
11
+ signal: AbortSignal.timeout(30_000)
12
+ });
13
+ if (!response.ok)
14
+ throw new Error(`The System release list could not be read (${response.status} ${response.statusText})`);
15
+ return selectSystemRelease(await response.json());
16
+ }
17
+ export function selectSystemRelease(value) {
18
+ if (!Array.isArray(value))
19
+ throw new Error("The System release list is invalid");
20
+ const candidates = value.flatMap(function (item) {
21
+ if (!record(item) || item.draft === true || item.prerelease === true || typeof item.tag_name !== "string" || !Array.isArray(item.assets))
22
+ return [];
23
+ const version = parseVersion(item.tag_name);
24
+ if (!version || version.major !== compatible.major || version.minor !== compatible.minor)
25
+ return [];
26
+ const archiveName = `phreshos@${version.value}.zip`;
27
+ const checksumName = `${archiveName}.sha256`;
28
+ const archive = asset(item.assets, archiveName);
29
+ const checksum = asset(item.assets, checksumName);
30
+ return archive && checksum ? [{ version: version.value, archive, checksum }] : [];
31
+ });
32
+ candidates.sort((left, right) => compare(right.version, left.version));
33
+ const selected = candidates[0];
34
+ if (!selected)
35
+ throw new Error(`No compatible PhreshOS System ${compatible.major}.${compatible.minor}.x release is available`);
36
+ return selected;
37
+ }
38
+ /** Download both release assets and refuse any byte not named by the checksum. */
39
+ export async function downloadSystemRelease(release, fetcher = fetch) {
40
+ const [archive, checksum] = await Promise.all([
41
+ fetchAsset(release.archive, fetcher),
42
+ fetchAsset(release.checksum, fetcher)
43
+ ]);
44
+ const bytes = Buffer.from(await archive.arrayBuffer());
45
+ const said = (await checksum.text()).trim();
46
+ const name = `phreshos@${release.version}.zip`;
47
+ const match = /^([a-f0-9]{64})\s+(.+)$/i.exec(said);
48
+ if (!match || match[2] !== name)
49
+ throw new Error(`The checksum for ${name} is invalid`);
50
+ const digest = createHash("sha256").update(bytes).digest("hex");
51
+ if (digest !== match[1]?.toLowerCase())
52
+ throw new Error(`The downloaded ${name} does not match its SHA-256 checksum`);
53
+ return { ...release, bytes, digest };
54
+ }
55
+ async function fetchAsset(url, fetcher) {
56
+ const response = await fetcher(url, {
57
+ headers: { "User-Agent": "@phreshos/cli" },
58
+ signal: AbortSignal.timeout(120_000)
59
+ });
60
+ if (!response.ok)
61
+ throw new Error(`A System release asset could not be downloaded (${response.status} ${response.statusText})`);
62
+ return response;
63
+ }
64
+ function asset(assets, name) {
65
+ const found = assets.find(item => record(item) && item.name === name && typeof item.browser_download_url === "string");
66
+ return record(found) && typeof found.browser_download_url === "string" ? found.browser_download_url : undefined;
67
+ }
68
+ function parseVersion(tag) {
69
+ const match = /^v(\d+)\.(\d+)\.(\d+)$/.exec(tag);
70
+ if (!match)
71
+ return undefined;
72
+ return {
73
+ value: tag.slice(1),
74
+ major: Number(match[1]),
75
+ minor: Number(match[2]),
76
+ patch: Number(match[3])
77
+ };
78
+ }
79
+ function compare(left, right) {
80
+ const a = left.split(".").map(Number);
81
+ const b = right.split(".").map(Number);
82
+ return (a[0] ?? 0) - (b[0] ?? 0) || (a[1] ?? 0) - (b[1] ?? 0) || (a[2] ?? 0) - (b[2] ?? 0);
83
+ }
84
+ function record(value) {
85
+ return typeof value === "object" && value !== null;
86
+ }
@@ -0,0 +1,11 @@
1
+ import { homedir } from "node:os";
2
+ import LinuxSystemService from "./linux.js";
3
+ import MacOSSystemService from "./macos.js";
4
+ /** Select the native per-user service manager without changing its semantics. */
5
+ export default function systemService(platform = process.platform, userHome = homedir()) {
6
+ if (platform === "darwin")
7
+ return new MacOSSystemService(userHome);
8
+ if (platform === "linux")
9
+ return new LinuxSystemService(userHome);
10
+ throw new Error(`PhreshOS System services are not supported on ${platform}`);
11
+ }