@phreshos/cli 0.1.34 → 0.1.36
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/dist/development-client.js +197 -0
- package/dist/install.js +1 -1
- package/dist/launch.js +93 -12
- package/dist/program-installation.js +7 -6
- package/dist/system/service/macos.js +56 -22
- package/dist/template/package.json +1 -1
- package/package.json +3 -3
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { connect } from "node:net";
|
|
3
|
+
import { delimiter, join } from "node:path";
|
|
4
|
+
const readinessTimeout = 15_000;
|
|
5
|
+
const pollingInterval = 200;
|
|
6
|
+
const reportingInterval = 2_000;
|
|
7
|
+
const sandboxedClientOrigin = "null";
|
|
8
|
+
/** One Client development server presented and supervised by the CLI. */
|
|
9
|
+
export class DevelopmentClient {
|
|
10
|
+
child;
|
|
11
|
+
output = [];
|
|
12
|
+
stopped = false;
|
|
13
|
+
result = null;
|
|
14
|
+
outputWaiter = null;
|
|
15
|
+
completion;
|
|
16
|
+
constructor(command, directory) {
|
|
17
|
+
this.child = spawn(command, {
|
|
18
|
+
cwd: directory,
|
|
19
|
+
env: commandEnvironment(directory),
|
|
20
|
+
shell: true,
|
|
21
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
22
|
+
detached: true
|
|
23
|
+
});
|
|
24
|
+
this.child.stdout?.on("data", chunk => this.push(outputEvent("out", chunk)));
|
|
25
|
+
this.child.stderr?.on("data", chunk => this.push(outputEvent("err", chunk)));
|
|
26
|
+
this.completion = new Promise(resolve => {
|
|
27
|
+
const finish = (exit) => {
|
|
28
|
+
if (this.result)
|
|
29
|
+
return;
|
|
30
|
+
this.result = exit;
|
|
31
|
+
this.outputWaiter?.();
|
|
32
|
+
this.outputWaiter = null;
|
|
33
|
+
resolve(exit);
|
|
34
|
+
};
|
|
35
|
+
this.child.once("error", error => finish({ code: null, signal: null, error }));
|
|
36
|
+
this.child.once("exit", (code, signal) => finish({ code, signal, error: null }));
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
drain() { return this.output.splice(0); }
|
|
40
|
+
exited() { return this.completion; }
|
|
41
|
+
exitResult() { return this.result; }
|
|
42
|
+
outputAvailable() {
|
|
43
|
+
if (this.output.length || this.result)
|
|
44
|
+
return Promise.resolve();
|
|
45
|
+
return new Promise(resolve => { this.outputWaiter = resolve; });
|
|
46
|
+
}
|
|
47
|
+
async stop() {
|
|
48
|
+
if (this.stopped)
|
|
49
|
+
return;
|
|
50
|
+
this.stopped = true;
|
|
51
|
+
if (!running(this.child))
|
|
52
|
+
return;
|
|
53
|
+
terminate(this.child, "SIGTERM");
|
|
54
|
+
await waitUntilStopped(this.child, 1_000);
|
|
55
|
+
if (running(this.child))
|
|
56
|
+
terminate(this.child, "SIGKILL");
|
|
57
|
+
await waitUntilStopped(this.child, 1_000);
|
|
58
|
+
}
|
|
59
|
+
endingWasRequested() { return this.stopped; }
|
|
60
|
+
push(event) {
|
|
61
|
+
this.output.push(event);
|
|
62
|
+
this.outputWaiter?.();
|
|
63
|
+
this.outputWaiter = null;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/** Refuse to claim a URL already served by an unrelated process. */
|
|
67
|
+
export async function assertAvailable(url) {
|
|
68
|
+
if (!await occupied(url))
|
|
69
|
+
return;
|
|
70
|
+
throw new Error(`Client development URL is already in use: ${url}`);
|
|
71
|
+
}
|
|
72
|
+
/** Wait until a sandboxed Program iframe can load the development Client. */
|
|
73
|
+
export async function* waitForDevelopmentClient(config, client, signal) {
|
|
74
|
+
const began = Date.now();
|
|
75
|
+
let nextReport = began + reportingInterval;
|
|
76
|
+
while (Date.now() - began < readinessTimeout) {
|
|
77
|
+
throwIfAborted(signal);
|
|
78
|
+
for (const event of client?.drain() ?? [])
|
|
79
|
+
yield event;
|
|
80
|
+
const exit = client?.exitResult();
|
|
81
|
+
if (exit && !client?.endingWasRequested())
|
|
82
|
+
throw commandFailure(exit);
|
|
83
|
+
const availability = await inspect(config.url, readinessTimeout - (Date.now() - began));
|
|
84
|
+
if (availability === "ready")
|
|
85
|
+
return;
|
|
86
|
+
if (availability === "cors-blocked") {
|
|
87
|
+
throw new Error([
|
|
88
|
+
`Client development URL responded, but does not allow the sandboxed Client origin: ${config.url}`,
|
|
89
|
+
"Enable CORS so the response includes Access-Control-Allow-Origin: *."
|
|
90
|
+
].join("\n"));
|
|
91
|
+
}
|
|
92
|
+
const now = Date.now();
|
|
93
|
+
if (now >= nextReport) {
|
|
94
|
+
yield { event: "waiting", subject: "client", url: config.url };
|
|
95
|
+
while (nextReport <= now)
|
|
96
|
+
nextReport += reportingInterval;
|
|
97
|
+
}
|
|
98
|
+
await pause(Math.min(pollingInterval, readinessTimeout - (now - began)), signal);
|
|
99
|
+
}
|
|
100
|
+
throw new Error(`Client development URL did not respond within 15 seconds: ${config.url}`);
|
|
101
|
+
}
|
|
102
|
+
export function commandFailure(exit) {
|
|
103
|
+
if (exit.error)
|
|
104
|
+
return new Error(`Client development command failed: ${exit.error.message}`);
|
|
105
|
+
if (exit.signal)
|
|
106
|
+
return new Error(`Client development command ended on ${exit.signal}`);
|
|
107
|
+
return new Error(`Client development command exited with ${exit.code ?? 0}`);
|
|
108
|
+
}
|
|
109
|
+
function outputEvent(stream, chunk) {
|
|
110
|
+
return { event: "output", stream, text: String(chunk) };
|
|
111
|
+
}
|
|
112
|
+
function commandEnvironment(directory) {
|
|
113
|
+
const key = Object.keys(process.env).find(name => name.toLowerCase() === "path") ?? "PATH";
|
|
114
|
+
const inherited = process.env[key];
|
|
115
|
+
return { ...process.env, [key]: [join(directory, "node_modules", ".bin"), inherited].filter(Boolean).join(delimiter) };
|
|
116
|
+
}
|
|
117
|
+
async function occupied(url) {
|
|
118
|
+
const location = new URL(url);
|
|
119
|
+
const port = Number(location.port || (location.protocol === "https:" ? 443 : 80));
|
|
120
|
+
return await new Promise(resolve => {
|
|
121
|
+
const socket = connect({ host: location.hostname, port });
|
|
122
|
+
let done = false;
|
|
123
|
+
const finish = (value) => {
|
|
124
|
+
if (done)
|
|
125
|
+
return;
|
|
126
|
+
done = true;
|
|
127
|
+
socket.destroy();
|
|
128
|
+
resolve(value);
|
|
129
|
+
};
|
|
130
|
+
socket.setTimeout(500);
|
|
131
|
+
socket.once("connect", () => finish(true));
|
|
132
|
+
socket.once("error", () => finish(false));
|
|
133
|
+
socket.once("timeout", () => finish(false));
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
async function inspect(url, remaining) {
|
|
137
|
+
try {
|
|
138
|
+
const response = await fetch(url, {
|
|
139
|
+
headers: { origin: sandboxedClientOrigin },
|
|
140
|
+
signal: AbortSignal.timeout(Math.max(1, Math.min(500, remaining)))
|
|
141
|
+
});
|
|
142
|
+
const allowedOrigin = response.headers.get("access-control-allow-origin")?.trim();
|
|
143
|
+
await response.body?.cancel();
|
|
144
|
+
return allowedOrigin === "*" || allowedOrigin === sandboxedClientOrigin ? "ready" : "cors-blocked";
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
return "unavailable";
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
function terminate(child, signal) {
|
|
151
|
+
if (!child.pid)
|
|
152
|
+
return;
|
|
153
|
+
try {
|
|
154
|
+
process.kill(-child.pid, signal);
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
child.kill(signal);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
function running(child) {
|
|
161
|
+
if (!child.pid)
|
|
162
|
+
return false;
|
|
163
|
+
try {
|
|
164
|
+
process.kill(-child.pid, 0);
|
|
165
|
+
return true;
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
return child.exitCode === null && child.signalCode === null;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
async function waitUntilStopped(child, milliseconds) {
|
|
172
|
+
const deadline = Date.now() + milliseconds;
|
|
173
|
+
while (running(child) && Date.now() < deadline)
|
|
174
|
+
await new Promise(resolve => setTimeout(resolve, 20));
|
|
175
|
+
}
|
|
176
|
+
function pause(milliseconds, signal) {
|
|
177
|
+
return new Promise((resolve, reject) => {
|
|
178
|
+
const timer = setTimeout(finish, Math.max(0, milliseconds));
|
|
179
|
+
const cancel = () => {
|
|
180
|
+
cleanup();
|
|
181
|
+
reject(signal?.reason instanceof Error ? signal.reason : new Error("The operation was cancelled"));
|
|
182
|
+
};
|
|
183
|
+
const cleanup = () => {
|
|
184
|
+
clearTimeout(timer);
|
|
185
|
+
signal?.removeEventListener("abort", cancel);
|
|
186
|
+
};
|
|
187
|
+
function finish() { cleanup(); resolve(); }
|
|
188
|
+
if (signal?.aborted)
|
|
189
|
+
cancel();
|
|
190
|
+
else
|
|
191
|
+
signal?.addEventListener("abort", cancel, { once: true });
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
function throwIfAborted(signal) {
|
|
195
|
+
if (signal?.aborted)
|
|
196
|
+
throw signal.reason instanceof Error ? signal.reason : new Error("The operation was cancelled");
|
|
197
|
+
}
|
package/dist/install.js
CHANGED
|
@@ -7,7 +7,7 @@ import { prepareOfficialProgram } from "./program-release.js";
|
|
|
7
7
|
*
|
|
8
8
|
* A local project is built and derived from its authoring declaration. An
|
|
9
9
|
* official name resolves a verified production package and turns its
|
|
10
|
-
* canonical paths into the same concrete
|
|
10
|
+
* canonical paths into the same concrete definition. From that point on,
|
|
11
11
|
* both sources cross the exact same gateway and the System performs the exact
|
|
12
12
|
* same authoritative installation.
|
|
13
13
|
*
|
package/dist/launch.js
CHANGED
|
@@ -1,25 +1,32 @@
|
|
|
1
1
|
import { Project } from "@phreshos/node";
|
|
2
2
|
import { relative } from "node:path";
|
|
3
|
+
import { assertAvailable, commandFailure, DevelopmentClient, waitForDevelopmentClient } from "./development-client.js";
|
|
3
4
|
import { dim, heading, line } from "./style.js";
|
|
4
5
|
/** Run the current project through one connected System. */
|
|
5
6
|
export default async function launch(mode, directory = process.cwd(), options = {}) {
|
|
6
7
|
const project = await Project.open(directory);
|
|
7
|
-
const
|
|
8
|
-
heading(`${
|
|
8
|
+
const definition = mode === "development" ? project.developmentDefinition() : project.productionDefinition();
|
|
9
|
+
heading(`${definition.name ?? definition.identity}${definition.version ? ` ${definition.version}` : ""}`, mode);
|
|
9
10
|
if (mode === "production" && project.config.buildCommand)
|
|
10
11
|
line("build", project.config.buildCommand);
|
|
11
|
-
if (
|
|
12
|
-
line(
|
|
13
|
-
if (
|
|
14
|
-
line("client", project.config.client?.development?.startCommand ?? place(project.directory,
|
|
15
|
-
line("storage", place(project.directory, String(
|
|
12
|
+
if (definition.server)
|
|
13
|
+
line(definition.server.startCommand ? "server" : "server worker", String(definition.server.startCommand ?? definition.server.entryFile), place(project.directory, definition.server.location));
|
|
14
|
+
if (definition.client)
|
|
15
|
+
line("client", project.config.client?.development?.startCommand ?? place(project.directory, definition.client.location));
|
|
16
|
+
line("storage", place(project.directory, String(definition.storage)));
|
|
16
17
|
if (Object.keys(options).length)
|
|
17
18
|
line("options", Object.entries(options).map(([name, value]) => `${name}=${value}`).join(" "));
|
|
18
19
|
console.log("");
|
|
19
20
|
const system = await (await import("@phreshos/node")).System.connect();
|
|
20
21
|
const controller = new AbortController();
|
|
22
|
+
const development = mode === "development" && definition.client && (definition.client.start ?? true)
|
|
23
|
+
? project.config.client?.development
|
|
24
|
+
: undefined;
|
|
25
|
+
const command = development?.startCommand;
|
|
26
|
+
let client;
|
|
21
27
|
let interrupted = false;
|
|
22
28
|
let ended = null;
|
|
29
|
+
let program = null;
|
|
23
30
|
const stop = () => {
|
|
24
31
|
if (interrupted)
|
|
25
32
|
return;
|
|
@@ -29,20 +36,35 @@ export default async function launch(mode, directory = process.cwd(), options =
|
|
|
29
36
|
for (const signal of ["SIGINT", "SIGTERM"])
|
|
30
37
|
process.on(signal, stop);
|
|
31
38
|
try {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
39
|
+
if (command && development) {
|
|
40
|
+
await assertAvailable(development.url);
|
|
41
|
+
client = new DevelopmentClient(command, project.directory);
|
|
42
|
+
}
|
|
43
|
+
if (development) {
|
|
44
|
+
for await (const event of waitForDevelopmentClient(development, client, controller.signal))
|
|
45
|
+
presentDevelopment(event);
|
|
46
|
+
}
|
|
47
|
+
if (mode === "production")
|
|
48
|
+
await project.build();
|
|
49
|
+
program = await system.forceCreateProgram(definition);
|
|
50
|
+
ended = await consume(program.process.run({ options }, { signal: controller.signal }), client);
|
|
37
51
|
}
|
|
38
52
|
catch (error) {
|
|
39
53
|
if (!interrupted)
|
|
40
54
|
throw error;
|
|
41
55
|
}
|
|
42
56
|
finally {
|
|
57
|
+
controller.abort(new Error("The local Program run ended"));
|
|
58
|
+
const cleanup = await Promise.allSettled([
|
|
59
|
+
client?.stop(),
|
|
60
|
+
program ? forget(program) : undefined
|
|
61
|
+
]);
|
|
43
62
|
for (const signal of ["SIGINT", "SIGTERM"])
|
|
44
63
|
process.off(signal, stop);
|
|
45
64
|
await system.disconnect();
|
|
65
|
+
const failure = cleanup.find(result => result.status === "rejected");
|
|
66
|
+
if (failure?.status === "rejected")
|
|
67
|
+
throw failure.reason;
|
|
46
68
|
}
|
|
47
69
|
if (interrupted)
|
|
48
70
|
process.exit(130);
|
|
@@ -51,6 +73,65 @@ export default async function launch(mode, directory = process.cwd(), options =
|
|
|
51
73
|
console.log(`\n ${dim(ended.signal ? `ended on ${ended.signal}` : `ended with ${ended.code ?? 0}`)}\n`);
|
|
52
74
|
process.exit(ended.signal ? 128 : ended.code ?? 0);
|
|
53
75
|
}
|
|
76
|
+
async function consume(lifecycle, client) {
|
|
77
|
+
const iterator = lifecycle[Symbol.asyncIterator]();
|
|
78
|
+
let next = iterator.next();
|
|
79
|
+
let developmentExit = client?.exited();
|
|
80
|
+
let developmentOutput = client?.outputAvailable();
|
|
81
|
+
let process = null;
|
|
82
|
+
let ending = null;
|
|
83
|
+
while (true) {
|
|
84
|
+
for (const event of client?.drain() ?? [])
|
|
85
|
+
presentDevelopment(event);
|
|
86
|
+
const outcome = await Promise.race([
|
|
87
|
+
next.then(result => ({ source: "system", result })),
|
|
88
|
+
...(developmentExit ? [developmentExit.then(result => ({ source: "client", result }))] : []),
|
|
89
|
+
...(developmentOutput ? [developmentOutput.then(() => ({ source: "output" }))] : [])
|
|
90
|
+
]);
|
|
91
|
+
if (outcome.source === "output") {
|
|
92
|
+
developmentOutput = client?.outputAvailable();
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if (outcome.source === "client") {
|
|
96
|
+
developmentExit = undefined;
|
|
97
|
+
if (!client?.endingWasRequested())
|
|
98
|
+
throw commandFailure(outcome.result);
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (outcome.result.done)
|
|
102
|
+
break;
|
|
103
|
+
const event = outcome.result.value;
|
|
104
|
+
if (event.event === "started") {
|
|
105
|
+
process = event.process.identity;
|
|
106
|
+
line("process", process);
|
|
107
|
+
}
|
|
108
|
+
else if (event.event === "output")
|
|
109
|
+
write(event.stream, event.text);
|
|
110
|
+
else
|
|
111
|
+
ending = { code: event.exit.code, signal: event.exit.signal };
|
|
112
|
+
next = iterator.next();
|
|
113
|
+
}
|
|
114
|
+
if (!process || !ending)
|
|
115
|
+
throw new Error("The System ended the Program run without a complete Process lifecycle");
|
|
116
|
+
return { process, ...ending };
|
|
117
|
+
}
|
|
118
|
+
function presentDevelopment(event) {
|
|
119
|
+
if (event.event === "output")
|
|
120
|
+
write(event.stream === "err" ? "stderr" : "stdout", event.text);
|
|
121
|
+
}
|
|
122
|
+
function write(stream, text) {
|
|
123
|
+
(stream === "stderr" ? process.stderr : process.stdout).write(text);
|
|
124
|
+
}
|
|
125
|
+
async function forget(program) {
|
|
126
|
+
try {
|
|
127
|
+
await program.forget();
|
|
128
|
+
}
|
|
129
|
+
catch (error) {
|
|
130
|
+
if (error instanceof Error && error.message === "The Program represented by this handle does not exist")
|
|
131
|
+
return;
|
|
132
|
+
throw error;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
54
135
|
function place(directory, location) {
|
|
55
136
|
return /^https?:\/\//i.test(location) ? location : `./${relative(directory, location)}`;
|
|
56
137
|
}
|
|
@@ -11,13 +11,14 @@ export default async function installProgram(program, options = {}) {
|
|
|
11
11
|
let process = null;
|
|
12
12
|
let installationFinished = false;
|
|
13
13
|
try {
|
|
14
|
-
if (program instanceof Project)
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
installed = await system.forceCreateProgram(program);
|
|
18
|
-
for await (const chunk of installed.install())
|
|
19
|
-
writeProgramCommandOutput(chunk);
|
|
14
|
+
if (program instanceof Project) {
|
|
15
|
+
await program.build();
|
|
16
|
+
installed = await system.forceCreateProgram(program.productionDefinition());
|
|
20
17
|
}
|
|
18
|
+
else
|
|
19
|
+
installed = await system.forceCreateProgram(program);
|
|
20
|
+
for await (const chunk of installed.install())
|
|
21
|
+
writeProgramCommandOutput(chunk);
|
|
21
22
|
installationFinished = true;
|
|
22
23
|
if (options.startup) {
|
|
23
24
|
await installed.startup.enable();
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { execute } from "../process.js";
|
|
2
2
|
import { existsSync } from "node:fs";
|
|
3
|
-
import { mkdir, rename, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
4
4
|
import { dirname, join } from "node:path";
|
|
5
5
|
import { randomUUID } from "node:crypto";
|
|
6
6
|
const command = "/bin/launchctl";
|
|
@@ -9,7 +9,8 @@ export default class MacOSSystemService {
|
|
|
9
9
|
run;
|
|
10
10
|
label;
|
|
11
11
|
environment;
|
|
12
|
-
|
|
12
|
+
definition;
|
|
13
|
+
startup;
|
|
13
14
|
domain;
|
|
14
15
|
target;
|
|
15
16
|
constructor(userHome, run = execute, label = defaultLabel, uid = process.getuid?.(), environment = process.env) {
|
|
@@ -18,52 +19,58 @@ export default class MacOSSystemService {
|
|
|
18
19
|
this.environment = environment;
|
|
19
20
|
if (uid === undefined)
|
|
20
21
|
throw new Error("The current macOS user could not be identified");
|
|
21
|
-
this.
|
|
22
|
+
this.definition = join(userHome, "Library", "Application Support", "PhreshOS", "System", `${this.label}.plist`);
|
|
23
|
+
this.startup = join(userHome, "Library", "LaunchAgents", `${this.label}.plist`);
|
|
22
24
|
this.domain = `gui/${uid}`;
|
|
23
25
|
this.target = `${this.domain}/${this.label}`;
|
|
24
26
|
}
|
|
25
27
|
async inspect() {
|
|
26
|
-
|
|
28
|
+
await this.migrate();
|
|
29
|
+
const registered = existsSync(this.definition);
|
|
27
30
|
const service = await this.run(command, ["print", this.target]);
|
|
28
|
-
const disabled = await this.run(command, ["print-disabled", this.domain]);
|
|
29
|
-
const explicitlyDisabled = new RegExp(`"${escapePattern(this.label)}"\\s*=>\\s*(?:true|disabled)`).test(disabled.stdout);
|
|
30
31
|
const pid = /\bpid\s*=\s*(\d+)/.exec(service.stdout)?.[1];
|
|
31
32
|
return {
|
|
32
33
|
registered,
|
|
33
34
|
automaticStartup: true,
|
|
34
|
-
enabled: registered &&
|
|
35
|
+
enabled: registered && existsSync(this.startup),
|
|
35
36
|
running: service.code === 0 && /\bstate\s*=\s*running\b/.test(service.stdout),
|
|
36
37
|
...(pid ? { pid: Number(pid) } : {})
|
|
37
38
|
};
|
|
38
39
|
}
|
|
39
40
|
async register(definition) {
|
|
41
|
+
await this.migrate();
|
|
40
42
|
await this.stop();
|
|
41
|
-
await mkdir(dirname(this.plist), { recursive: true });
|
|
42
43
|
await mkdir(dirname(definition.output), { recursive: true });
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
await
|
|
47
|
-
}
|
|
48
|
-
finally {
|
|
49
|
-
await rm(temporary, { force: true });
|
|
50
|
-
}
|
|
44
|
+
const content = plist(this.label, definition, this.environment);
|
|
45
|
+
await atomic(this.definition, content);
|
|
46
|
+
if (existsSync(this.startup))
|
|
47
|
+
await atomic(this.startup, content);
|
|
51
48
|
}
|
|
52
49
|
async unregister() {
|
|
50
|
+
await this.migrate();
|
|
53
51
|
await this.stop();
|
|
54
|
-
await
|
|
52
|
+
await Promise.all([
|
|
53
|
+
rm(this.definition, { force: true }),
|
|
54
|
+
rm(this.startup, { force: true })
|
|
55
|
+
]);
|
|
56
|
+
await this.run(command, ["enable", this.target]);
|
|
55
57
|
}
|
|
56
58
|
async start() {
|
|
57
|
-
|
|
59
|
+
await this.migrate();
|
|
60
|
+
if (!existsSync(this.definition))
|
|
58
61
|
throw new Error("The PhreshOS System service is not registered");
|
|
59
62
|
const state = await this.inspect();
|
|
60
63
|
if (state.running)
|
|
61
64
|
return;
|
|
65
|
+
// launchctl disable prevents manual execution as well as login startup.
|
|
66
|
+
// Automatic startup is represented by the LaunchAgents copy instead,
|
|
67
|
+
// leaving the canonical definition eligible for an explicit start.
|
|
68
|
+
await this.require(["enable", this.target]);
|
|
62
69
|
const loaded = await this.run(command, ["print", this.target]);
|
|
63
70
|
if (loaded.code === 0)
|
|
64
71
|
await this.require(["kickstart", "-k", this.target]);
|
|
65
72
|
else
|
|
66
|
-
await this.require(["bootstrap", this.domain, this.
|
|
73
|
+
await this.require(["bootstrap", this.domain, this.definition]);
|
|
67
74
|
}
|
|
68
75
|
async stop() {
|
|
69
76
|
const loaded = await this.run(command, ["print", this.target]);
|
|
@@ -71,14 +78,30 @@ export default class MacOSSystemService {
|
|
|
71
78
|
await this.require(["bootout", this.target]);
|
|
72
79
|
}
|
|
73
80
|
async enable() {
|
|
74
|
-
|
|
81
|
+
await this.migrate();
|
|
82
|
+
if (!existsSync(this.definition))
|
|
75
83
|
throw new Error("The PhreshOS System service is not registered");
|
|
84
|
+
await atomic(this.startup, await readFile(this.definition, "utf8"));
|
|
76
85
|
await this.require(["enable", this.target]);
|
|
77
86
|
}
|
|
78
87
|
async disable() {
|
|
79
|
-
|
|
88
|
+
await this.migrate();
|
|
89
|
+
if (!existsSync(this.definition))
|
|
80
90
|
throw new Error("The PhreshOS System service is not registered");
|
|
81
|
-
await this.
|
|
91
|
+
await rm(this.startup, { force: true });
|
|
92
|
+
await this.require(["enable", this.target]);
|
|
93
|
+
}
|
|
94
|
+
/** Convert the former launchctl-disabled representation without changing its setting. */
|
|
95
|
+
async migrate() {
|
|
96
|
+
if (existsSync(this.definition) || !existsSync(this.startup))
|
|
97
|
+
return;
|
|
98
|
+
const disabled = await this.run(command, ["print-disabled", this.domain]);
|
|
99
|
+
const automatic = !new RegExp(`"${escapePattern(this.label)}"\\s*=>\\s*(?:true|disabled)`).test(disabled.stdout);
|
|
100
|
+
await atomic(this.definition, await readFile(this.startup, "utf8"));
|
|
101
|
+
if (!automatic) {
|
|
102
|
+
await rm(this.startup, { force: true });
|
|
103
|
+
await this.require(["enable", this.target]);
|
|
104
|
+
}
|
|
82
105
|
}
|
|
83
106
|
async require(args) {
|
|
84
107
|
const result = await this.run(command, args);
|
|
@@ -129,6 +152,17 @@ function environmentPath(environment) {
|
|
|
129
152
|
const key = Object.keys(environment).find(name => name.toLowerCase() === "path");
|
|
130
153
|
return key === undefined ? undefined : environment[key];
|
|
131
154
|
}
|
|
155
|
+
async function atomic(path, content) {
|
|
156
|
+
await mkdir(dirname(path), { recursive: true });
|
|
157
|
+
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
158
|
+
try {
|
|
159
|
+
await writeFile(temporary, content, { mode: 0o600 });
|
|
160
|
+
await rename(temporary, path);
|
|
161
|
+
}
|
|
162
|
+
finally {
|
|
163
|
+
await rm(temporary, { force: true });
|
|
164
|
+
}
|
|
165
|
+
}
|
|
132
166
|
function xml(value) {
|
|
133
167
|
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """);
|
|
134
168
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@phreshos/cli",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.1.
|
|
4
|
+
"version": "0.1.36",
|
|
5
5
|
"description": "The Phresh command-line interface for Program projects and system management.",
|
|
6
6
|
"engines": {
|
|
7
7
|
"node": ">=20.10"
|
|
@@ -49,8 +49,8 @@
|
|
|
49
49
|
"packageManager": "bun@1.3.14",
|
|
50
50
|
"dependencies": {
|
|
51
51
|
"@clack/prompts": "^1.7.0",
|
|
52
|
-
"@phreshos/core": "^0.1.
|
|
53
|
-
"@phreshos/node": "^0.1.
|
|
52
|
+
"@phreshos/core": "^0.1.23",
|
|
53
|
+
"@phreshos/node": "^0.1.2",
|
|
54
54
|
"adm-zip": "^0.6.0",
|
|
55
55
|
"commander": "^15.0.0",
|
|
56
56
|
"jiti": "^2.7.0",
|