@phreshos/cli 0.1.5 → 0.1.6
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/LICENSE +21 -0
- package/README.md +44 -12
- package/dist/cli.js +3 -1
- package/dist/create.js +3 -19
- package/dist/project-dependency.js +3 -52
- package/dist/system/command.js +70 -0
- package/dist/system/installation.js +241 -0
- package/dist/system/lifecycle.js +174 -0
- package/dist/system/node.js +13 -0
- package/dist/system/paths.js +28 -0
- package/dist/system/process.js +23 -0
- package/dist/system/readiness.js +29 -0
- package/dist/system/release.js +86 -0
- package/dist/system/service/index.js +11 -0
- package/dist/system/service/linux.js +94 -0
- package/dist/system/service/macos.js +123 -0
- package/dist/system/types.js +0 -0
- package/dist/template/package.json +1 -1
- package/package.json +29 -1
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Zohayr SLILEH
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# @phreshos/cli
|
|
2
2
|
|
|
3
|
-
The `phresh` command for creating
|
|
4
|
-
|
|
3
|
+
The `phresh` command for creating and operating Programs, and for installing
|
|
4
|
+
and managing the PhreshOS System on the current machine.
|
|
5
5
|
|
|
6
6
|
## Package status
|
|
7
7
|
|
|
@@ -21,6 +21,7 @@ phresh install # lay this program out on this machine
|
|
|
21
21
|
phresh uninstall # remove its installed form
|
|
22
22
|
phresh start # run what your build left, and stay with it
|
|
23
23
|
phresh dev # run from source, and stay with it
|
|
24
|
+
phresh system status # inspect the local System and its background service
|
|
24
25
|
```
|
|
25
26
|
|
|
26
27
|
`phresh --help` lists them, `phresh <command> --help` explains one, and
|
|
@@ -28,11 +29,44 @@ phresh dev # run from source, and stay with it
|
|
|
28
29
|
unknown command, an unknown flag and a malformed option are each refused
|
|
29
30
|
and named.
|
|
30
31
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
32
|
+
Every top-level Program command acts on the current project, and that list ends
|
|
33
|
+
there. It accepts no arbitrary Program identity and has no word for a Process,
|
|
34
|
+
Window, store, or setting. Machine lifecycle is isolated under `phresh system`;
|
|
35
|
+
it manages the System installation and native service, not the state inside the
|
|
36
|
+
System.
|
|
37
|
+
|
|
38
|
+
## System lifecycle
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
phresh system install
|
|
42
|
+
phresh system uninstall
|
|
43
|
+
phresh system status
|
|
44
|
+
phresh system start
|
|
45
|
+
phresh system stop
|
|
46
|
+
phresh system enable
|
|
47
|
+
phresh system disable
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
`install` resolves the newest compatible stable release from the official
|
|
51
|
+
[`PhreshOS/system`](https://github.com/PhreshOS/system) GitHub Releases. It
|
|
52
|
+
downloads the production archive and adjacent checksum, verifies every byte,
|
|
53
|
+
installs production dependencies into a staged version directory, atomically
|
|
54
|
+
points the stable `current` path at it, then registers, enables, and starts the
|
|
55
|
+
native per-user service. The selected release and the service entry therefore
|
|
56
|
+
cannot become two competing sources of truth if installation is interrupted.
|
|
57
|
+
It never reads a source checkout and never requires Bun or TypeScript.
|
|
58
|
+
|
|
59
|
+
The System runs under `launchd` on macOS and `systemd --user` on Linux. The
|
|
60
|
+
native manager owns it after the CLI exits and restarts a failed active
|
|
61
|
+
service. `start` and `stop` change current execution only; `enable` and
|
|
62
|
+
`disable` change automatic startup only. `status` reads installation,
|
|
63
|
+
registration, startup, process, and local-intake readiness without changing
|
|
64
|
+
any of them.
|
|
65
|
+
|
|
66
|
+
Installation files and persistent System state have separate homes. Removing
|
|
67
|
+
the System unregisters its service and removes its release files while keeping
|
|
68
|
+
`~/.phreshos`, including Programs and owner data. Windows remains unsupported
|
|
69
|
+
until the System has an equally strong local-intake authorization model there.
|
|
36
70
|
|
|
37
71
|
## create
|
|
38
72
|
|
|
@@ -55,11 +89,9 @@ phresh create status-board \
|
|
|
55
89
|
```
|
|
56
90
|
|
|
57
91
|
Dependencies are installed by default. `--no-install` creates the same valid
|
|
58
|
-
project and leaves installation as the first reported next step.
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
distributed CLI, uses the published versions embedded in the template. Both
|
|
62
|
-
choices pass through the same dependency resolver used by `init`.
|
|
92
|
+
project and leaves installation as the first reported next step. Generated and
|
|
93
|
+
initialized Programs always use the published package ranges embedded in the
|
|
94
|
+
CLI; repository layout never changes dependency meaning.
|
|
63
95
|
|
|
64
96
|
## Saying something to a program you start
|
|
65
97
|
|
package/dist/cli.js
CHANGED
|
@@ -8,12 +8,13 @@ import launch from "./launch.js";
|
|
|
8
8
|
import init from "./init.js";
|
|
9
9
|
import pack from "./pack.js";
|
|
10
10
|
import uninstall from "./uninstall.js";
|
|
11
|
+
import systemCommands from "./system/command.js";
|
|
11
12
|
const { version } = metadata;
|
|
12
13
|
const coreRange = metadata.dependencies["@phreshos/core"];
|
|
13
14
|
const runOptionPrefix = "--run-option-";
|
|
14
15
|
const program = new Command()
|
|
15
16
|
.name("phresh")
|
|
16
|
-
.description("Create and manage
|
|
17
|
+
.description("Create Programs and manage PhreshOS")
|
|
17
18
|
.version(version, "-v, --version")
|
|
18
19
|
.showHelpAfterError()
|
|
19
20
|
.showSuggestionAfterError();
|
|
@@ -106,6 +107,7 @@ attached("dev", "run the development Program without installing", [
|
|
|
106
107
|
"Runs the same attached lifecycle as start using the development",
|
|
107
108
|
"declarations. A Client URL must respond within 15 seconds before launch."
|
|
108
109
|
], "development");
|
|
110
|
+
systemCommands(program);
|
|
109
111
|
// Every command begins with the same breathing room. Keep this at the entry
|
|
110
112
|
// point so individual commands never need to manufacture their own opening.
|
|
111
113
|
console.log("");
|
package/dist/create.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { installProjectDependencies, projectPackageManager, projectScript } from "./project-dependency.js";
|
|
2
2
|
import prompts from "./prompts.js";
|
|
3
3
|
import { accent, bold } from "./style.js";
|
|
4
4
|
import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
@@ -36,7 +36,7 @@ export default async function create(options = {}, directory = process.cwd()) {
|
|
|
36
36
|
try {
|
|
37
37
|
cpSync(bundled.directory, staging, { recursive: true });
|
|
38
38
|
renameSync(resolve(staging, "gitignore"), resolve(staging, ".gitignore"));
|
|
39
|
-
customize(staging,
|
|
39
|
+
customize(staging, identity, name, manager);
|
|
40
40
|
renameSync(staging, target);
|
|
41
41
|
placed = true;
|
|
42
42
|
// A project inside this repository becomes a real workspace member
|
|
@@ -79,7 +79,7 @@ function template() {
|
|
|
79
79
|
}
|
|
80
80
|
throw new Error("The CLI template has not been built — run its build command and try again");
|
|
81
81
|
}
|
|
82
|
-
function customize(directory,
|
|
82
|
+
function customize(directory, identity, name, manager) {
|
|
83
83
|
for (const path of textFiles(directory)) {
|
|
84
84
|
let content = readFileSync(path, "utf-8");
|
|
85
85
|
content = content.replaceAll("phresh-program", identity).replaceAll("Phresh Program", name);
|
|
@@ -93,15 +93,6 @@ function customize(directory, finalDirectory, identity, name, manager) {
|
|
|
93
93
|
const manifestPath = resolve(directory, "package.json");
|
|
94
94
|
const manifest = JSON.parse(readFileSync(manifestPath, "utf-8"));
|
|
95
95
|
manifest.name = identity;
|
|
96
|
-
for (const dependencies of [manifest.dependencies, manifest.devDependencies]) {
|
|
97
|
-
if (!dependencies)
|
|
98
|
-
continue;
|
|
99
|
-
for (const [dependency, range] of Object.entries(dependencies)) {
|
|
100
|
-
if (!isProjectPackage(dependency))
|
|
101
|
-
continue;
|
|
102
|
-
dependencies[dependency] = projectDependency(dependency, range, finalDirectory).manifestSpecifier;
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
96
|
writeFileSync(manifestPath, JSON.stringify(manifest, null, 4) + "\n");
|
|
106
97
|
}
|
|
107
98
|
function textFiles(directory) {
|
|
@@ -120,13 +111,6 @@ function packageManager(value) {
|
|
|
120
111
|
return value;
|
|
121
112
|
throw new Error(`The package manager must be bun, npm, pnpm, or yarn; received "${value}"`);
|
|
122
113
|
}
|
|
123
|
-
function isProjectPackage(value) {
|
|
124
|
-
return value === "@phreshos/core"
|
|
125
|
-
|| value === "@phreshos/client"
|
|
126
|
-
|| value === "@phreshos/server"
|
|
127
|
-
|| value === "@phreshos/react"
|
|
128
|
-
|| value === "@phreshos/cli";
|
|
129
|
-
}
|
|
130
114
|
function title(identity) {
|
|
131
115
|
return identity.split("-").map(word => word[0].toUpperCase() + word.slice(1)).join(" ");
|
|
132
116
|
}
|
|
@@ -1,37 +1,11 @@
|
|
|
1
1
|
import { line } from "./style.js";
|
|
2
2
|
import { spawn } from "node:child_process";
|
|
3
3
|
import { existsSync, readFileSync } from "node:fs";
|
|
4
|
-
import {
|
|
4
|
+
import { resolve } from "node:path";
|
|
5
5
|
/** Returns the package-manager command that runs one project script. */
|
|
6
6
|
export function projectScript(directory, declared, script) {
|
|
7
7
|
return `${projectPackageManager(directory, declared).name} run ${script}`;
|
|
8
8
|
}
|
|
9
|
-
/**
|
|
10
|
-
* Resolves an SDK through the one source policy shared by `init` and `create`.
|
|
11
|
-
*
|
|
12
|
-
* A Program inside this repository's workspace uses its sibling dev-kit. A
|
|
13
|
-
* standalone project or distributed CLI keeps the published range embedded
|
|
14
|
-
* in the generated template. Local packages therefore never leak their own
|
|
15
|
-
* workspace-only development graph into an unrelated project.
|
|
16
|
-
*/
|
|
17
|
-
export function projectDependency(name, range, directory) {
|
|
18
|
-
const workspaceDirectory = resolve(import.meta.dirname, "..", "..", "..");
|
|
19
|
-
const workspace = manifestAt(workspaceDirectory);
|
|
20
|
-
const sourceDirectory = resolve(import.meta.dirname, "..", "..", localDirectories[name]);
|
|
21
|
-
const manifest = manifestAt(sourceDirectory);
|
|
22
|
-
if (workspace?.name === "@phreshos/workspace" && workspaceIncludes(workspaceDirectory, directory, workspace.workspaces) && manifest?.name === name && manifest.version && accepts(range, manifest.version)) {
|
|
23
|
-
return {
|
|
24
|
-
installSpecifier: `${name}@workspace:*`,
|
|
25
|
-
manifestSpecifier: "workspace:*",
|
|
26
|
-
local: true
|
|
27
|
-
};
|
|
28
|
-
}
|
|
29
|
-
return {
|
|
30
|
-
installSpecifier: `${name}@${range}`,
|
|
31
|
-
manifestSpecifier: range,
|
|
32
|
-
local: false
|
|
33
|
-
};
|
|
34
|
-
}
|
|
35
9
|
/** Detects the package manager declared by, present in, or invoking a project. */
|
|
36
10
|
export function projectPackageManager(directory, declared, preferred) {
|
|
37
11
|
const named = preferred ?? packageManagerName(declared) ?? packageManagerFromLocks(directory) ?? packageManagerFromInvocation();
|
|
@@ -57,9 +31,8 @@ export default async function ensureProjectDependency(name, range, directory = p
|
|
|
57
31
|
if (manifest.dependencies?.[name] || manifest.devDependencies?.[name])
|
|
58
32
|
return;
|
|
59
33
|
const manager = projectPackageManager(directory, manifest.packageManager);
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
await run(manager.name, [...manager.addArgs(section), source.installSpecifier], directory);
|
|
34
|
+
line("dependency", name, `${manager.name}, ${range}`);
|
|
35
|
+
await run(manager.name, [...manager.addArgs(section), `${name}@${range}`], directory);
|
|
63
36
|
console.log("");
|
|
64
37
|
}
|
|
65
38
|
function run(command, args, directory, output = "inherit") {
|
|
@@ -85,21 +58,6 @@ function run(command, args, directory, output = "inherit") {
|
|
|
85
58
|
});
|
|
86
59
|
});
|
|
87
60
|
}
|
|
88
|
-
function accepts(range, version) {
|
|
89
|
-
return range === "workspace:*" || range === version || range === `^${version}` || range === `~${version}`;
|
|
90
|
-
}
|
|
91
|
-
function workspaceIncludes(root, directory, patterns) {
|
|
92
|
-
const local = relative(root, resolve(directory)).replaceAll("\\", "/");
|
|
93
|
-
if (!local || local === ".." || local.startsWith("../") || isAbsolute(local))
|
|
94
|
-
return false;
|
|
95
|
-
return patterns?.some(function (pattern) {
|
|
96
|
-
if (!pattern.endsWith("/*"))
|
|
97
|
-
return local === pattern;
|
|
98
|
-
const prefix = pattern.slice(0, -1);
|
|
99
|
-
const child = local.startsWith(prefix) ? local.slice(prefix.length) : "";
|
|
100
|
-
return Boolean(child) && !child.includes("/");
|
|
101
|
-
}) ?? false;
|
|
102
|
-
}
|
|
103
61
|
function manifestAt(directory) {
|
|
104
62
|
const path = resolve(directory, "package.json");
|
|
105
63
|
if (!existsSync(path))
|
|
@@ -132,13 +90,6 @@ function packageManagerFromInvocation() {
|
|
|
132
90
|
function isPackageManager(value) {
|
|
133
91
|
return value === "bun" || value === "npm" || value === "pnpm" || value === "yarn";
|
|
134
92
|
}
|
|
135
|
-
const localDirectories = {
|
|
136
|
-
"@phreshos/core": "core-sdk",
|
|
137
|
-
"@phreshos/client": "client-sdk",
|
|
138
|
-
"@phreshos/server": "server-sdk",
|
|
139
|
-
"@phreshos/react": "react-sdk",
|
|
140
|
-
"@phreshos/cli": "cli"
|
|
141
|
-
};
|
|
142
93
|
const managers = {
|
|
143
94
|
bun: {
|
|
144
95
|
name: "bun",
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import SystemLifecycle from "./lifecycle.js";
|
|
2
|
+
import prompts from "../prompts.js";
|
|
3
|
+
/** Attach the System lifecycle without mixing it with Program commands. */
|
|
4
|
+
export default function systemCommands(program, provided) {
|
|
5
|
+
let lifecycle = provided;
|
|
6
|
+
const current = () => lifecycle ?? (lifecycle = new SystemLifecycle());
|
|
7
|
+
const system = program.command("system").description("install and manage the PhreshOS System");
|
|
8
|
+
system.command("install")
|
|
9
|
+
.description("install or update the System and start its service")
|
|
10
|
+
.action(async function () {
|
|
11
|
+
const interaction = prompts();
|
|
12
|
+
interaction.begin("Install System", "official stable release");
|
|
13
|
+
const status = await interaction.progress("Installing PhreshOS", "PhreshOS installed", () => current().install());
|
|
14
|
+
report(interaction, status);
|
|
15
|
+
interaction.finish("System installed");
|
|
16
|
+
});
|
|
17
|
+
system.command("uninstall")
|
|
18
|
+
.description("remove the System installation and service")
|
|
19
|
+
.action(async function () {
|
|
20
|
+
const interaction = prompts();
|
|
21
|
+
interaction.begin("Uninstall System");
|
|
22
|
+
await interaction.progress("Removing PhreshOS", "PhreshOS removed", () => current().uninstall());
|
|
23
|
+
interaction.message("Persistent System data was kept.");
|
|
24
|
+
interaction.finish("System uninstalled");
|
|
25
|
+
});
|
|
26
|
+
system.command("status")
|
|
27
|
+
.description("show installation, service, and readiness state")
|
|
28
|
+
.action(async function () {
|
|
29
|
+
const interaction = prompts();
|
|
30
|
+
interaction.begin("System Status");
|
|
31
|
+
report(interaction, await current().status());
|
|
32
|
+
});
|
|
33
|
+
action(system, "start", "start the background service", () => current().start());
|
|
34
|
+
action(system, "stop", "stop the background service", () => current().stop());
|
|
35
|
+
action(system, "enable", "enable automatic startup", () => current().enable());
|
|
36
|
+
action(system, "disable", "disable automatic startup", () => current().disable());
|
|
37
|
+
return system;
|
|
38
|
+
}
|
|
39
|
+
function action(system, name, description, work) {
|
|
40
|
+
system.command(name)
|
|
41
|
+
.description(description)
|
|
42
|
+
.action(async function () {
|
|
43
|
+
const interaction = prompts();
|
|
44
|
+
interaction.begin(`System ${title(name)}`);
|
|
45
|
+
const status = await interaction.progress(`${title(name)}ing PhreshOS`, `PhreshOS ${past(name)}`, work);
|
|
46
|
+
report(interaction, status);
|
|
47
|
+
interaction.finish(`System ${past(name)}`);
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
function report(interaction, status) {
|
|
51
|
+
interaction.detail("installation", status.installed ? `installed · ${status.installed.version}` : "not installed");
|
|
52
|
+
interaction.detail("service", status.registered ? "registered" : "not registered");
|
|
53
|
+
interaction.detail("startup", status.enabled ? "enabled" : "disabled");
|
|
54
|
+
interaction.detail("process", status.running ? `running${status.pid ? ` · ${status.pid}` : ""}` : "stopped");
|
|
55
|
+
interaction.detail("intake", status.ready ? `ready · ${status.intake}` : "not ready");
|
|
56
|
+
interaction.detail("files", status.root);
|
|
57
|
+
interaction.detail("log", status.log);
|
|
58
|
+
}
|
|
59
|
+
function title(value) {
|
|
60
|
+
return `${value[0]?.toUpperCase()}${value.slice(1)}`;
|
|
61
|
+
}
|
|
62
|
+
function past(value) {
|
|
63
|
+
if (value === "stop")
|
|
64
|
+
return "stopped";
|
|
65
|
+
if (value === "enable")
|
|
66
|
+
return "enabled";
|
|
67
|
+
if (value === "disable")
|
|
68
|
+
return "disabled";
|
|
69
|
+
return "started";
|
|
70
|
+
}
|
|
@@ -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,174 @@
|
|
|
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 { join } from "node:path";
|
|
8
|
+
/** Coordinates acquisition, immutable files, and the native service as one transaction. */
|
|
9
|
+
export default class SystemLifecycle {
|
|
10
|
+
dependencies;
|
|
11
|
+
constructor(dependencies) {
|
|
12
|
+
const paths = systemPaths();
|
|
13
|
+
const service = dependencies?.service ?? systemService();
|
|
14
|
+
this.dependencies = {
|
|
15
|
+
installation: dependencies?.installation ?? new SystemInstallation(paths),
|
|
16
|
+
service,
|
|
17
|
+
resolveRelease: dependencies?.resolveRelease ?? resolveSystemRelease,
|
|
18
|
+
downloadRelease: dependencies?.downloadRelease ?? downloadSystemRelease,
|
|
19
|
+
ready: dependencies?.ready ?? intakeReady,
|
|
20
|
+
wait: dependencies?.wait ?? waitForIntake
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
async install() {
|
|
24
|
+
return await this.dependencies.installation.exclusive(() => this.installExclusive());
|
|
25
|
+
}
|
|
26
|
+
async installExclusive() {
|
|
27
|
+
const { installation, service } = this.dependencies;
|
|
28
|
+
const previous = await installation.current();
|
|
29
|
+
const previousService = await service.inspect();
|
|
30
|
+
const executable = await nodeExecutable();
|
|
31
|
+
const release = await this.dependencies.resolveRelease();
|
|
32
|
+
const downloaded = await this.dependencies.downloadRelease(release);
|
|
33
|
+
const prepared = await installation.prepare(downloaded);
|
|
34
|
+
const activation = await this.activate(prepared, previous, previousService);
|
|
35
|
+
try {
|
|
36
|
+
await service.register(definition(installation, executable));
|
|
37
|
+
await service.enable();
|
|
38
|
+
await service.start();
|
|
39
|
+
await this.waitUntilReady();
|
|
40
|
+
await activation.commit();
|
|
41
|
+
return await this.status();
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
await service.stop().catch(() => undefined);
|
|
45
|
+
await activation.rollback();
|
|
46
|
+
return await this.restoredFailure(error, previous, previousService);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
async uninstall() {
|
|
50
|
+
return await this.dependencies.installation.exclusive(() => this.uninstallExclusive());
|
|
51
|
+
}
|
|
52
|
+
async uninstallExclusive() {
|
|
53
|
+
const { installation, service } = this.dependencies;
|
|
54
|
+
const state = await service.inspect();
|
|
55
|
+
if (state.running)
|
|
56
|
+
await service.stop();
|
|
57
|
+
if (state.enabled)
|
|
58
|
+
await service.disable();
|
|
59
|
+
if (state.registered)
|
|
60
|
+
await service.unregister();
|
|
61
|
+
await installation.remove();
|
|
62
|
+
}
|
|
63
|
+
async start() {
|
|
64
|
+
return await this.dependencies.installation.exclusive(() => this.startExclusive());
|
|
65
|
+
}
|
|
66
|
+
async startExclusive() {
|
|
67
|
+
await this.requireInstalledService();
|
|
68
|
+
await this.dependencies.service.start();
|
|
69
|
+
await this.waitUntilReady();
|
|
70
|
+
return await this.status();
|
|
71
|
+
}
|
|
72
|
+
async stop() {
|
|
73
|
+
return await this.dependencies.installation.exclusive(() => this.stopExclusive());
|
|
74
|
+
}
|
|
75
|
+
async stopExclusive() {
|
|
76
|
+
await this.requireInstalledService();
|
|
77
|
+
await this.dependencies.service.stop();
|
|
78
|
+
return await this.status();
|
|
79
|
+
}
|
|
80
|
+
async enable() {
|
|
81
|
+
return await this.dependencies.installation.exclusive(() => this.enableExclusive());
|
|
82
|
+
}
|
|
83
|
+
async enableExclusive() {
|
|
84
|
+
await this.requireInstalledService();
|
|
85
|
+
await this.dependencies.service.enable();
|
|
86
|
+
return await this.status();
|
|
87
|
+
}
|
|
88
|
+
async disable() {
|
|
89
|
+
return await this.dependencies.installation.exclusive(() => this.disableExclusive());
|
|
90
|
+
}
|
|
91
|
+
async disableExclusive() {
|
|
92
|
+
await this.requireInstalledService();
|
|
93
|
+
await this.dependencies.service.disable();
|
|
94
|
+
return await this.status();
|
|
95
|
+
}
|
|
96
|
+
async status() {
|
|
97
|
+
const { installation, service } = this.dependencies;
|
|
98
|
+
const [installed, state] = await Promise.all([installation.current(), service.inspect()]);
|
|
99
|
+
const ready = state.running && await this.dependencies.ready(installation.paths.intake);
|
|
100
|
+
return {
|
|
101
|
+
...(installed ? { installed } : {}),
|
|
102
|
+
...state,
|
|
103
|
+
ready,
|
|
104
|
+
root: installation.paths.root,
|
|
105
|
+
intake: installation.paths.intake,
|
|
106
|
+
log: installation.paths.log
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
async requireInstalledService() {
|
|
110
|
+
const [installed, state] = await Promise.all([
|
|
111
|
+
this.dependencies.installation.current(),
|
|
112
|
+
this.dependencies.service.inspect()
|
|
113
|
+
]);
|
|
114
|
+
if (!installed)
|
|
115
|
+
throw new Error("PhreshOS System is not installed — run phresh system install");
|
|
116
|
+
if (!state.registered)
|
|
117
|
+
throw new Error("The PhreshOS System service is not registered — run phresh system install");
|
|
118
|
+
}
|
|
119
|
+
async waitUntilReady() {
|
|
120
|
+
const { installation, service } = this.dependencies;
|
|
121
|
+
try {
|
|
122
|
+
await this.dependencies.wait(installation.paths.intake, async () => (await service.inspect()).running);
|
|
123
|
+
}
|
|
124
|
+
catch (error) {
|
|
125
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
126
|
+
throw new Error(`${message}. Service log: ${installation.paths.log}`, { cause: error });
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
async activate(prepared, previous, state) {
|
|
130
|
+
const { installation, service } = this.dependencies;
|
|
131
|
+
try {
|
|
132
|
+
if (state.running)
|
|
133
|
+
await service.stop();
|
|
134
|
+
return await installation.activate(prepared, previous);
|
|
135
|
+
}
|
|
136
|
+
catch (error) {
|
|
137
|
+
await installation.abandon(prepared);
|
|
138
|
+
return await this.restoredFailure(error, previous, state);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
async restore(previous, state) {
|
|
142
|
+
const { installation, service } = this.dependencies;
|
|
143
|
+
if (!previous) {
|
|
144
|
+
await service.unregister().catch(() => undefined);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
await service.register(definition(installation, await nodeExecutable()));
|
|
148
|
+
if (state.enabled)
|
|
149
|
+
await service.enable();
|
|
150
|
+
else
|
|
151
|
+
await service.disable();
|
|
152
|
+
if (state.running) {
|
|
153
|
+
await service.start();
|
|
154
|
+
await this.waitUntilReady();
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
async restoredFailure(error, previous, state) {
|
|
158
|
+
try {
|
|
159
|
+
await this.restore(previous, state);
|
|
160
|
+
}
|
|
161
|
+
catch (restoration) {
|
|
162
|
+
throw new AggregateError([error, restoration], "The System update failed and its previous service could not be restored");
|
|
163
|
+
}
|
|
164
|
+
throw error;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
function definition(installation, executable) {
|
|
168
|
+
return {
|
|
169
|
+
executable,
|
|
170
|
+
entry: join(installation.paths.current, "server", "main.js"),
|
|
171
|
+
directory: installation.paths.current,
|
|
172
|
+
output: installation.paths.log
|
|
173
|
+
};
|
|
174
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
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
|
+
export default class LinuxSystemService {
|
|
9
|
+
run;
|
|
10
|
+
file;
|
|
11
|
+
constructor(userHome, run = execute) {
|
|
12
|
+
this.run = run;
|
|
13
|
+
this.file = join(userHome, ".config", "systemd", "user", unit);
|
|
14
|
+
}
|
|
15
|
+
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
|
+
};
|
|
27
|
+
}
|
|
28
|
+
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"]);
|
|
41
|
+
}
|
|
42
|
+
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]);
|
|
48
|
+
}
|
|
49
|
+
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]);
|
|
53
|
+
}
|
|
54
|
+
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]);
|
|
58
|
+
}
|
|
59
|
+
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]);
|
|
63
|
+
}
|
|
64
|
+
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]);
|
|
68
|
+
}
|
|
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
|
+
}
|
|
92
|
+
function quote(value) {
|
|
93
|
+
return JSON.stringify(value);
|
|
94
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
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 = "/bin/launchctl";
|
|
7
|
+
const defaultLabel = "com.phreshos.system";
|
|
8
|
+
export default class MacOSSystemService {
|
|
9
|
+
run;
|
|
10
|
+
label;
|
|
11
|
+
plist;
|
|
12
|
+
domain;
|
|
13
|
+
target;
|
|
14
|
+
constructor(userHome, run = execute, label = defaultLabel) {
|
|
15
|
+
this.run = run;
|
|
16
|
+
this.label = label;
|
|
17
|
+
const uid = process.getuid?.();
|
|
18
|
+
if (uid === undefined)
|
|
19
|
+
throw new Error("The current macOS user could not be identified");
|
|
20
|
+
this.plist = join(userHome, "Library", "LaunchAgents", `${this.label}.plist`);
|
|
21
|
+
this.domain = `gui/${uid}`;
|
|
22
|
+
this.target = `${this.domain}/${this.label}`;
|
|
23
|
+
}
|
|
24
|
+
async inspect() {
|
|
25
|
+
const registered = existsSync(this.plist);
|
|
26
|
+
const service = await this.run(command, ["print", this.target]);
|
|
27
|
+
const disabled = await this.run(command, ["print-disabled", this.domain]);
|
|
28
|
+
const explicitlyDisabled = new RegExp(`"${escapePattern(this.label)}"\\s*=>\\s*(?:true|disabled)`).test(disabled.stdout);
|
|
29
|
+
const pid = /\bpid\s*=\s*(\d+)/.exec(service.stdout)?.[1];
|
|
30
|
+
return {
|
|
31
|
+
registered,
|
|
32
|
+
enabled: registered && !explicitlyDisabled,
|
|
33
|
+
running: service.code === 0 && /\bstate\s*=\s*running\b/.test(service.stdout),
|
|
34
|
+
...(pid ? { pid: Number(pid) } : {})
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
async register(definition) {
|
|
38
|
+
await this.stop();
|
|
39
|
+
await mkdir(dirname(this.plist), { recursive: true });
|
|
40
|
+
await mkdir(dirname(definition.output), { recursive: true });
|
|
41
|
+
const temporary = `${this.plist}.${randomUUID()}.tmp`;
|
|
42
|
+
try {
|
|
43
|
+
await writeFile(temporary, plist(this.label, definition), { mode: 0o600 });
|
|
44
|
+
await rename(temporary, this.plist);
|
|
45
|
+
}
|
|
46
|
+
finally {
|
|
47
|
+
await rm(temporary, { force: true });
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
async unregister() {
|
|
51
|
+
await this.stop();
|
|
52
|
+
await rm(this.plist, { force: true });
|
|
53
|
+
}
|
|
54
|
+
async start() {
|
|
55
|
+
if (!existsSync(this.plist))
|
|
56
|
+
throw new Error("The PhreshOS System service is not registered");
|
|
57
|
+
const state = await this.inspect();
|
|
58
|
+
if (state.running)
|
|
59
|
+
return;
|
|
60
|
+
const loaded = await this.run(command, ["print", this.target]);
|
|
61
|
+
if (loaded.code === 0)
|
|
62
|
+
await this.require(["kickstart", "-k", this.target]);
|
|
63
|
+
else
|
|
64
|
+
await this.require(["bootstrap", this.domain, this.plist]);
|
|
65
|
+
}
|
|
66
|
+
async stop() {
|
|
67
|
+
const loaded = await this.run(command, ["print", this.target]);
|
|
68
|
+
if (loaded.code === 0)
|
|
69
|
+
await this.require(["bootout", this.target]);
|
|
70
|
+
}
|
|
71
|
+
async enable() {
|
|
72
|
+
if (!existsSync(this.plist))
|
|
73
|
+
throw new Error("The PhreshOS System service is not registered");
|
|
74
|
+
await this.require(["enable", this.target]);
|
|
75
|
+
}
|
|
76
|
+
async disable() {
|
|
77
|
+
if (!existsSync(this.plist))
|
|
78
|
+
throw new Error("The PhreshOS System service is not registered");
|
|
79
|
+
await this.require(["disable", this.target]);
|
|
80
|
+
}
|
|
81
|
+
async require(args) {
|
|
82
|
+
const result = await this.run(command, args);
|
|
83
|
+
if (result.code !== 0)
|
|
84
|
+
throw new Error(result.stderr.trim() || result.stdout.trim() || `${command} exited with code ${result.code}`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
function plist(label, definition) {
|
|
88
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
89
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
90
|
+
<plist version="1.0">
|
|
91
|
+
<dict>
|
|
92
|
+
<key>Label</key>
|
|
93
|
+
<string>${xml(label)}</string>
|
|
94
|
+
<key>ProgramArguments</key>
|
|
95
|
+
<array>
|
|
96
|
+
<string>${xml(definition.executable)}</string>
|
|
97
|
+
<string>${xml(definition.entry)}</string>
|
|
98
|
+
</array>
|
|
99
|
+
<key>WorkingDirectory</key>
|
|
100
|
+
<string>${xml(definition.directory)}</string>
|
|
101
|
+
<key>RunAtLoad</key>
|
|
102
|
+
<true/>
|
|
103
|
+
<key>KeepAlive</key>
|
|
104
|
+
<dict>
|
|
105
|
+
<key>SuccessfulExit</key>
|
|
106
|
+
<false/>
|
|
107
|
+
</dict>
|
|
108
|
+
<key>ThrottleInterval</key>
|
|
109
|
+
<integer>2</integer>
|
|
110
|
+
<key>StandardOutPath</key>
|
|
111
|
+
<string>${xml(definition.output)}</string>
|
|
112
|
+
<key>StandardErrorPath</key>
|
|
113
|
+
<string>${xml(definition.output)}</string>
|
|
114
|
+
</dict>
|
|
115
|
+
</plist>
|
|
116
|
+
`;
|
|
117
|
+
}
|
|
118
|
+
function xml(value) {
|
|
119
|
+
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """);
|
|
120
|
+
}
|
|
121
|
+
function escapePattern(value) {
|
|
122
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
123
|
+
}
|
|
File without changes
|
package/package.json
CHANGED
|
@@ -1,12 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@phreshos/cli",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.1.
|
|
4
|
+
"version": "0.1.6",
|
|
5
5
|
"description": "The Phresh command-line interface for Program projects and system management.",
|
|
6
|
+
"engines": {
|
|
7
|
+
"node": ">=20.10"
|
|
8
|
+
},
|
|
6
9
|
"scripts": {
|
|
7
10
|
"clean": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\"",
|
|
8
11
|
"compile": "tsc --noEmit false --outDir dist --rootDir source --rewriteRelativeImportExtensions true --allowImportingTsExtensions true",
|
|
9
12
|
"build": "node --run clean && node --run compile && node dist/build-template.js",
|
|
13
|
+
"test": "node --run compile && node --test tests/*.test.mjs",
|
|
14
|
+
"verify": "node --run build && node --run test && node scripts/verify-package.mjs",
|
|
15
|
+
"verify:system-release": "node --run compile && node scripts/verify-system-release.mjs",
|
|
16
|
+
"verify:macos-service": "node --run compile && node scripts/verify-macos-service.mjs",
|
|
10
17
|
"prepack": "node --run build"
|
|
11
18
|
},
|
|
12
19
|
"bin": {
|
|
@@ -14,8 +21,29 @@
|
|
|
14
21
|
},
|
|
15
22
|
"files": [
|
|
16
23
|
"dist",
|
|
24
|
+
"LICENSE",
|
|
17
25
|
"README.md"
|
|
18
26
|
],
|
|
27
|
+
"author": "Zohayr SLILEH",
|
|
28
|
+
"license": "MIT",
|
|
29
|
+
"repository": {
|
|
30
|
+
"type": "git",
|
|
31
|
+
"url": "git+https://github.com/PhreshOS/cli.git"
|
|
32
|
+
},
|
|
33
|
+
"bugs": {
|
|
34
|
+
"url": "https://github.com/PhreshOS/cli/issues"
|
|
35
|
+
},
|
|
36
|
+
"homepage": "https://github.com/PhreshOS/cli#readme",
|
|
37
|
+
"keywords": [
|
|
38
|
+
"phreshos",
|
|
39
|
+
"cli",
|
|
40
|
+
"program"
|
|
41
|
+
],
|
|
42
|
+
"publishConfig": {
|
|
43
|
+
"access": "public",
|
|
44
|
+
"provenance": true
|
|
45
|
+
},
|
|
46
|
+
"packageManager": "bun@1.3.14",
|
|
19
47
|
"dependencies": {
|
|
20
48
|
"@clack/prompts": "^1.7.0",
|
|
21
49
|
"@phreshos/core": "^0.1.1",
|