@phreshos/cli 0.1.11 → 0.1.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -26
- package/dist/cli.js +0 -2
- package/dist/derive.js +0 -5
- package/dist/init.js +0 -11
- package/dist/intake-address.js +14 -0
- package/dist/pack.js +0 -6
- package/dist/program-intake.js +8 -7
- package/dist/program-release.js +0 -1
- package/dist/project.js +0 -2
- package/dist/system/installation.js +3 -2
- package/dist/system/npm.js +8 -0
- package/dist/system/paths.js +2 -1
- package/dist/system/service/index.js +3 -0
- package/dist/system/service/macos.js +1 -2
- package/dist/system/service/windows-runner.js +45 -0
- package/dist/system/service/windows.js +238 -0
- package/dist/template/package.json +1 -1
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -57,20 +57,22 @@ native per-user service. The selected release and the service entry therefore
|
|
|
57
57
|
cannot become two competing sources of truth if installation is interrupted.
|
|
58
58
|
It never reads a source checkout and never requires Bun or TypeScript.
|
|
59
59
|
|
|
60
|
-
The System runs under `launchd` on macOS
|
|
61
|
-
Linux
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
60
|
+
The System runs under `launchd` on macOS, a real `systemd --user` manager on
|
|
61
|
+
Linux, and a least-privilege per-user scheduled task on Windows. In Linux
|
|
62
|
+
containers with no init manager, it runs as a detached user-owned background
|
|
63
|
+
process that survives the terminal but ends with the container. Automatic
|
|
64
|
+
startup is unavailable there rather than being reported as enabled. `start`
|
|
65
|
+
and `stop` change current execution only; where a native manager exists,
|
|
66
|
+
`enable` and `disable` change automatic startup only. Successful installation
|
|
67
|
+
and startup show the desktop address. `status` reports that same address with
|
|
68
|
+
the installed version, service readiness, and automatic startup without
|
|
69
|
+
changing them; `version` reports only the installed System release.
|
|
69
70
|
|
|
70
71
|
Installation files and persistent System state have separate homes. Removing
|
|
71
72
|
the System unregisters its service and removes its release files while keeping
|
|
72
|
-
`~/.phreshos`, including Programs and owner data.
|
|
73
|
-
|
|
73
|
+
`~/.phreshos`, including Programs and owner data. Local Program intake uses an
|
|
74
|
+
owner-only socket file on POSIX and an owner-created duplex named pipe on
|
|
75
|
+
Windows; neither becomes a network endpoint or introduces a bearer secret.
|
|
74
76
|
|
|
75
77
|
## create
|
|
76
78
|
|
|
@@ -151,8 +153,6 @@ export default defineConfig({
|
|
|
151
153
|
|
|
152
154
|
description: "A file manager",
|
|
153
155
|
|
|
154
|
-
apiDocs: "api.md",
|
|
155
|
-
|
|
156
156
|
icon: "icon.png",
|
|
157
157
|
|
|
158
158
|
buildCommand: "bun run build",
|
|
@@ -397,20 +397,11 @@ are the canonical locations the package just created. An explicit
|
|
|
397
397
|
`start: false` crosses with its half; an omitted value remains omitted
|
|
398
398
|
and means `true`. At least one declared half must resolve to true.
|
|
399
399
|
|
|
400
|
-
When `apiDocs` is declared, its Markdown file is copied to `api-docs.md` and
|
|
401
|
-
the packaged description names that canonical entry point. A missing declared
|
|
402
|
-
file is an error, not an undocumented Program.
|
|
403
|
-
|
|
404
|
-
The document covers only what the Program owns: its capabilities, operation
|
|
405
|
-
and event names, payloads, behavior, state, and Program-defined failures. It
|
|
406
|
-
does not explain how to find a Program, obtain an Endpoint, publish or ask, or
|
|
407
|
-
register a subscription. Those are system contracts documented once by the
|
|
408
|
-
SDKs; `api-docs.md` supplies only the Program-specific meaning carried through
|
|
409
|
-
them.
|
|
410
|
-
|
|
411
400
|
There is **no wrapping directory**: `program.json`, `server/`, `client/`,
|
|
412
|
-
optional `icon.png
|
|
413
|
-
directory it installs into from your program's `identity`.
|
|
401
|
+
and optional `icon.png` sit at the package's root, and the system names the
|
|
402
|
+
directory it installs into from your program's `identity`. Service API
|
|
403
|
+
documentation belongs to the live service that exposes it and is therefore
|
|
404
|
+
not part of the Program package.
|
|
414
405
|
|
|
415
406
|
Nothing about the system moves because this exists. `program.json` is
|
|
416
407
|
still the only thing the system reads, and a package assembled by hand
|
package/dist/cli.js
CHANGED
|
@@ -46,7 +46,6 @@ describe(program.command("create")
|
|
|
46
46
|
describe(program.command("init")
|
|
47
47
|
.description("initialize an existing Program project")
|
|
48
48
|
.option("--name <name>", "human-readable Program name")
|
|
49
|
-
.option("--api-docs <path>", "official Program API documentation")
|
|
50
49
|
.option("--build-command <command>", "prepare production files before use")
|
|
51
50
|
.option("--server", "include a Server endpoint")
|
|
52
51
|
.option("--server-location <path>", "production Server directory")
|
|
@@ -60,7 +59,6 @@ describe(program.command("init")
|
|
|
60
59
|
.action(async function (options) {
|
|
61
60
|
await init({
|
|
62
61
|
name: options.name,
|
|
63
|
-
apiDocs: options.apiDocs,
|
|
64
62
|
buildCommand: options.buildCommand,
|
|
65
63
|
server: options.server === true || options.serverLocation !== undefined || options.serverStartCommand !== undefined,
|
|
66
64
|
serverLocation: options.serverLocation,
|
package/dist/derive.js
CHANGED
|
@@ -27,11 +27,6 @@ export default function derive(config, directory, which) {
|
|
|
27
27
|
name: config.name,
|
|
28
28
|
version: config.version,
|
|
29
29
|
description: config.description,
|
|
30
|
-
// Like the icon, the document stays where the author put it for an
|
|
31
|
-
// attached run. Installation and packaging give it its canonical
|
|
32
|
-
// name; the runtime receives an absolute source path here because a
|
|
33
|
-
// derived description has no file beside which to resolve it.
|
|
34
|
-
apiDocs: config.apiDocs && resolve(directory, config.apiDocs),
|
|
35
30
|
// Where it already is. Unlike pack, which gives it its canonical
|
|
36
31
|
// name, this points into the authoring tree and leaves it alone.
|
|
37
32
|
icon: config.icon && resolve(directory, config.icon),
|
package/dist/init.js
CHANGED
|
@@ -47,15 +47,6 @@ export default async function init(options = {}, directory = process.cwd(), core
|
|
|
47
47
|
const name = options.name ?? (interactive ? await ask("This name is shown to people; the package name remains the Program identity.", "What name should people see?", manifest.name) : undefined);
|
|
48
48
|
if (name !== undefined && name.trim().length === 0)
|
|
49
49
|
throw new Error("--name must not be empty");
|
|
50
|
-
let apiDocs = options.apiDocs;
|
|
51
|
-
if (interactive && apiDocs === undefined) {
|
|
52
|
-
const suggested = ["api-docs.md", "README.md"].find(file => existsSync(resolve(directory, file)));
|
|
53
|
-
if (await yes("API documentation explains only the services this Program itself provides.", "Does this Program provide API documentation?", false)) {
|
|
54
|
-
apiDocs = await ask("The path is resolved from the project root and becomes the official API entry point.", "Where is the API documentation file?", suggested);
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
if (apiDocs !== undefined && apiDocs.trim().length === 0)
|
|
58
|
-
throw new Error("An API documentation path must not be empty");
|
|
59
50
|
let buildCommand = options.buildCommand;
|
|
60
51
|
if (interactive && buildCommand === undefined) {
|
|
61
52
|
const suggested = manifest.scripts?.build && projectScript(directory, manifest.packageManager, "build");
|
|
@@ -118,7 +109,6 @@ export default async function init(options = {}, directory = process.cwd(), core
|
|
|
118
109
|
name,
|
|
119
110
|
version: manifest.version,
|
|
120
111
|
description: manifest.description,
|
|
121
|
-
apiDocs,
|
|
122
112
|
buildCommand
|
|
123
113
|
};
|
|
124
114
|
const config = server
|
|
@@ -147,7 +137,6 @@ function compose(config) {
|
|
|
147
137
|
field("name", config.name),
|
|
148
138
|
field("version", config.version),
|
|
149
139
|
field("description", config.description),
|
|
150
|
-
field("apiDocs", config.apiDocs),
|
|
151
140
|
field("buildCommand", config.buildCommand),
|
|
152
141
|
half("server", config.server && {
|
|
153
142
|
location: config.server.location,
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
/** Address the same owner-local intake without turning it into a network port. */
|
|
4
|
+
export default function intakeAddress(storage, platform = process.platform) {
|
|
5
|
+
if (platform !== "win32")
|
|
6
|
+
return join(storage, "intake.sock");
|
|
7
|
+
// Windows pipe names share one machine-wide namespace. The storage root
|
|
8
|
+
// separates users and isolated instances while case folding follows the
|
|
9
|
+
// filesystem they came from. Access remains the pipe creator's default
|
|
10
|
+
// duplex ACL; the name is identity, not a secret or an authorization.
|
|
11
|
+
const owner = storage.replaceAll("\\", "/").replace(/\/+$/, "").toLowerCase();
|
|
12
|
+
const identity = createHash("sha256").update(owner).digest("hex").slice(0, 32);
|
|
13
|
+
return `\\\\.\\pipe\\phreshos-${identity}-intake`;
|
|
14
|
+
}
|
package/dist/pack.js
CHANGED
|
@@ -44,8 +44,6 @@ export default async function pack(directory = process.cwd()) {
|
|
|
44
44
|
}
|
|
45
45
|
if (config.icon)
|
|
46
46
|
file(zip, directory, config.icon, "icon.png", "Program icon");
|
|
47
|
-
if (config.apiDocs)
|
|
48
|
-
document(zip, directory, config.apiDocs);
|
|
49
47
|
zip.addFile("program.json", Buffer.from(JSON.stringify(program(config, version), null, 4) + "\n"));
|
|
50
48
|
const archive = `${config.identity}@${version ?? "0.0.0"}.zip`;
|
|
51
49
|
const bytes = zip.toBuffer();
|
|
@@ -64,7 +62,6 @@ function program(config, version) {
|
|
|
64
62
|
name: config.name,
|
|
65
63
|
version,
|
|
66
64
|
description: config.description,
|
|
67
|
-
apiDocs: config.apiDocs ? "api-docs.md" : undefined,
|
|
68
65
|
icon: config.icon ? "icon.png" : undefined,
|
|
69
66
|
...config.server && { server: { location: "server", start: config.server.start, installCommand: config.server.installCommand, startCommand: config.server.startCommand } },
|
|
70
67
|
...config.client && { client: { location: "client", start: config.client.start, title: config.client.title, size: config.client.size, position: config.client.position, layer: config.client.layer, minimize: config.client.minimize } }
|
|
@@ -78,9 +75,6 @@ function place(zip, directory, location, half) {
|
|
|
78
75
|
throw new Error(`The ${half} files are not at ${location} — nothing was built there`);
|
|
79
76
|
zip.addLocalFolder(from, half);
|
|
80
77
|
}
|
|
81
|
-
function document(zip, directory, location) {
|
|
82
|
-
file(zip, directory, location, "api-docs.md", "API documentation file");
|
|
83
|
-
}
|
|
84
78
|
function file(zip, directory, location, target, label) {
|
|
85
79
|
const from = resolve(directory, location);
|
|
86
80
|
if (!existsSync(from) || !statSync(from).isFile())
|
package/dist/program-intake.js
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
import { connect } from "node:net";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { isAbsolute, join } from "node:path";
|
|
4
|
+
import intakeAddress from "./intake-address.js";
|
|
4
5
|
/**
|
|
5
6
|
* The local Program intake, from the CLI's side.
|
|
6
7
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* is the proof.
|
|
8
|
+
* An owner-local IPC address rather than a port: a mode-0600 socket file
|
|
9
|
+
* on POSIX and an owner-created duplex named pipe on Windows. Only the
|
|
10
|
+
* account that owns this machine can complete the channel, and that account
|
|
11
|
+
* is exactly who may run and install programs on it. Nothing is sent to
|
|
12
|
+
* prove anything, because being able to connect is the proof.
|
|
12
13
|
*
|
|
13
14
|
* A message is a line. Closing our own side to mark the end of a
|
|
14
15
|
* question would make lifetime ambiguous. A line delimiter keeps the
|
|
@@ -23,10 +24,10 @@ import { isAbsolute, join } from "node:path";
|
|
|
23
24
|
export function programIntakePath(environment = process.env, userHome = homedir()) {
|
|
24
25
|
const instanceHome = environment.PHRESHOS_HOME;
|
|
25
26
|
if (instanceHome === undefined)
|
|
26
|
-
return join(userHome, ".phreshos"
|
|
27
|
+
return intakeAddress(join(userHome, ".phreshos"));
|
|
27
28
|
if (!isAbsolute(instanceHome))
|
|
28
29
|
throw new Error("PHRESHOS_HOME must be an absolute filesystem path");
|
|
29
|
-
return
|
|
30
|
+
return intakeAddress(instanceHome);
|
|
30
31
|
}
|
|
31
32
|
export const socketPath = programIntakePath();
|
|
32
33
|
export default function speak(question, heard, path = socketPath, signal) {
|
package/dist/program-release.js
CHANGED
|
@@ -99,7 +99,6 @@ async function readProgram(directory, release) {
|
|
|
99
99
|
throw new Error("A published Program package cannot choose its installed storage");
|
|
100
100
|
return {
|
|
101
101
|
...value,
|
|
102
|
-
...pathField(value, directory, "apiDocs", "api-docs.md"),
|
|
103
102
|
...pathField(value, directory, "icon", "icon.png"),
|
|
104
103
|
...half(value, directory, "server"),
|
|
105
104
|
...half(value, directory, "client")
|
package/dist/project.js
CHANGED
|
@@ -43,8 +43,6 @@ function coherent(config) {
|
|
|
43
43
|
if (config[field] !== undefined && typeof config[field] !== "string")
|
|
44
44
|
throw new Error(`A program's ${field} must be text`);
|
|
45
45
|
}
|
|
46
|
-
if (config.apiDocs !== undefined && (typeof config.apiDocs !== "string" || config.apiDocs.trim().length === 0))
|
|
47
|
-
throw new Error("A program's apiDocs must be a non-empty path");
|
|
48
46
|
if (config.buildCommand !== undefined && (typeof config.buildCommand !== "string" || config.buildCommand.trim().length === 0))
|
|
49
47
|
throw new Error("A program's buildCommand must be non-empty text");
|
|
50
48
|
for (const half of ["server", "client"]) {
|
|
@@ -3,6 +3,7 @@ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
|
3
3
|
import { mkdir, mkdtemp, open, readFile, readdir, readlink, rename, rm, symlink, writeFile } from "node:fs/promises";
|
|
4
4
|
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
5
5
|
import { requireSuccess } from "./process.js";
|
|
6
|
+
import npmInvocation from "./npm.js";
|
|
6
7
|
import { minimumSystemNodeVersion } from "./node.js";
|
|
7
8
|
import AdmZip from "adm-zip";
|
|
8
9
|
/** Owns immutable release directories and the one atomic active record. */
|
|
@@ -207,8 +208,8 @@ function requireDirectory(path) {
|
|
|
207
208
|
mkdirSync(path, { recursive: true, mode: 0o700 });
|
|
208
209
|
}
|
|
209
210
|
async function installProductionDependencies(directory) {
|
|
210
|
-
const npm =
|
|
211
|
-
await requireSuccess(npm,
|
|
211
|
+
const npm = npmInvocation(["install", "--omit=dev", "--no-audit", "--no-fund", "--package-lock=false"]);
|
|
212
|
+
await requireSuccess(npm.command, npm.args, { cwd: directory });
|
|
212
213
|
}
|
|
213
214
|
function releaseRecord(value) {
|
|
214
215
|
return record(value)
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { win32 } from "node:path";
|
|
2
|
+
/** Invoke npm without asking a platform command shell to interpret its shim. */
|
|
3
|
+
export default function npmInvocation(args, platform = process.platform, executable = process.execPath) {
|
|
4
|
+
if (platform !== "win32")
|
|
5
|
+
return { command: "npm", args };
|
|
6
|
+
const npm = win32.join(win32.dirname(executable), "node_modules", "npm", "bin", "npm-cli.js");
|
|
7
|
+
return { command: executable, args: [npm, ...args] };
|
|
8
|
+
}
|
package/dist/system/paths.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import intakeAddress from "../intake-address.js";
|
|
1
2
|
import { homedir } from "node:os";
|
|
2
3
|
import { isAbsolute, join } from "node:path";
|
|
3
4
|
/** The installation is separate from the persistent state it operates on. */
|
|
@@ -15,7 +16,7 @@ export default function systemPaths(platform = process.platform, userHome = home
|
|
|
15
16
|
releases: join(root, "releases"),
|
|
16
17
|
current: join(root, "current"),
|
|
17
18
|
storage,
|
|
18
|
-
intake:
|
|
19
|
+
intake: intakeAddress(storage, platform),
|
|
19
20
|
log: join(storage, "service.log")
|
|
20
21
|
};
|
|
21
22
|
}
|
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import { homedir } from "node:os";
|
|
2
2
|
import LinuxSystemService from "./linux.js";
|
|
3
3
|
import MacOSSystemService from "./macos.js";
|
|
4
|
+
import WindowsSystemService from "./windows.js";
|
|
4
5
|
/** Select the per-user service implementation available in this environment. */
|
|
5
6
|
export default function systemService(platform = process.platform, userHome = homedir()) {
|
|
6
7
|
if (platform === "darwin")
|
|
7
8
|
return new MacOSSystemService(userHome);
|
|
8
9
|
if (platform === "linux")
|
|
9
10
|
return new LinuxSystemService(userHome);
|
|
11
|
+
if (platform === "win32")
|
|
12
|
+
return new WindowsSystemService(userHome);
|
|
10
13
|
throw new Error(`PhreshOS System services are not supported on ${platform}`);
|
|
11
14
|
}
|
|
@@ -11,10 +11,9 @@ export default class MacOSSystemService {
|
|
|
11
11
|
plist;
|
|
12
12
|
domain;
|
|
13
13
|
target;
|
|
14
|
-
constructor(userHome, run = execute, label = defaultLabel) {
|
|
14
|
+
constructor(userHome, run = execute, label = defaultLabel, uid = process.getuid?.()) {
|
|
15
15
|
this.run = run;
|
|
16
16
|
this.label = label;
|
|
17
|
-
const uid = process.getuid?.();
|
|
18
17
|
if (uid === undefined)
|
|
19
18
|
throw new Error("The current macOS user could not be identified");
|
|
20
19
|
this.plist = join(userHome, "Library", "LaunchAgents", `${this.label}.plist`);
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { open, writeFile } from "node:fs/promises";
|
|
3
|
+
const payload = decode(process.argv[2]);
|
|
4
|
+
const output = await open(payload.definition.output, "a", 0o600);
|
|
5
|
+
try {
|
|
6
|
+
const child = spawn(payload.definition.executable, [payload.definition.entry], {
|
|
7
|
+
cwd: payload.definition.directory,
|
|
8
|
+
stdio: ["ignore", output.fd, output.fd]
|
|
9
|
+
});
|
|
10
|
+
await new Promise(function (settle, refuse) {
|
|
11
|
+
child.once("spawn", settle);
|
|
12
|
+
child.once("error", refuse);
|
|
13
|
+
});
|
|
14
|
+
if (child.pid === undefined)
|
|
15
|
+
throw new Error("The PhreshOS System process has no pid");
|
|
16
|
+
await writeFile(payload.state, JSON.stringify({ runner: process.pid, child: child.pid }), { mode: 0o600 });
|
|
17
|
+
const result = await new Promise(settle => {
|
|
18
|
+
child.once("close", (code, signal) => settle({ code, signal }));
|
|
19
|
+
});
|
|
20
|
+
process.exitCode = result.signal ? 1 : result.code ?? 1;
|
|
21
|
+
}
|
|
22
|
+
finally {
|
|
23
|
+
await output.close();
|
|
24
|
+
}
|
|
25
|
+
function decode(encoded) {
|
|
26
|
+
let value;
|
|
27
|
+
try {
|
|
28
|
+
value = JSON.parse(Buffer.from(encoded ?? "", "base64url").toString("utf8"));
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
throw new Error("The PhreshOS System task payload is invalid");
|
|
32
|
+
}
|
|
33
|
+
if (!value || typeof value !== "object")
|
|
34
|
+
throw new Error("The PhreshOS System task payload is invalid");
|
|
35
|
+
const candidate = value;
|
|
36
|
+
if (typeof candidate.state !== "string" || !definition(candidate.definition))
|
|
37
|
+
throw new Error("The PhreshOS System task payload is invalid");
|
|
38
|
+
return { state: candidate.state, definition: candidate.definition };
|
|
39
|
+
}
|
|
40
|
+
function definition(value) {
|
|
41
|
+
if (!value || typeof value !== "object")
|
|
42
|
+
return false;
|
|
43
|
+
const candidate = value;
|
|
44
|
+
return ["executable", "entry", "directory", "output"].every(name => typeof candidate[name] === "string" && candidate[name] !== "");
|
|
45
|
+
}
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import { execute } from "../process.js";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
const scheduler = "schtasks.exe";
|
|
7
|
+
const powershell = "powershell.exe";
|
|
8
|
+
const defaultTask = "PhreshOS System";
|
|
9
|
+
const runner = fileURLToPath(new URL("./windows-runner.js", import.meta.url));
|
|
10
|
+
/** A per-user Windows Task Scheduler service requiring no administrator rights. */
|
|
11
|
+
export default class WindowsSystemService {
|
|
12
|
+
userHome;
|
|
13
|
+
run;
|
|
14
|
+
task;
|
|
15
|
+
constructor(userHome, run = execute, task = defaultTask) {
|
|
16
|
+
this.userHome = userHome;
|
|
17
|
+
this.run = run;
|
|
18
|
+
this.task = task;
|
|
19
|
+
this.state = join(userHome, ".phreshos", "system-service.json");
|
|
20
|
+
}
|
|
21
|
+
state;
|
|
22
|
+
async inspect() {
|
|
23
|
+
const result = await this.run(powershell, powershellArguments(inspectScript(this.task)));
|
|
24
|
+
if (result.code === 3)
|
|
25
|
+
return absent();
|
|
26
|
+
if (result.code !== 0)
|
|
27
|
+
throw failure(powershell, result);
|
|
28
|
+
const match = /^([0-4]),([01])$/.exec(result.stdout.trim());
|
|
29
|
+
if (!match)
|
|
30
|
+
throw new Error("The PhreshOS System scheduled task returned an invalid state");
|
|
31
|
+
const pid = match[1] === "4" ? (await this.readState())?.child : undefined;
|
|
32
|
+
return {
|
|
33
|
+
registered: true,
|
|
34
|
+
automaticStartup: true,
|
|
35
|
+
enabled: match[2] === "1",
|
|
36
|
+
running: match[1] === "4",
|
|
37
|
+
...(pid && alive(pid) ? { pid } : {})
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
async register(definition) {
|
|
41
|
+
await this.stop();
|
|
42
|
+
await mkdir(dirname(definition.output), { recursive: true });
|
|
43
|
+
await mkdir(dirname(this.state), { recursive: true });
|
|
44
|
+
await rm(this.state, { force: true });
|
|
45
|
+
const sid = await this.userSid();
|
|
46
|
+
const temporary = join(this.userHome, `.phreshos-system-${randomUUID()}.xml`);
|
|
47
|
+
try {
|
|
48
|
+
await writeFile(temporary, utf16(task(this.task, sid, definition, this.state)));
|
|
49
|
+
await this.require(scheduler, ["/Create", "/TN", this.task, "/XML", temporary, "/F"]);
|
|
50
|
+
}
|
|
51
|
+
finally {
|
|
52
|
+
await rm(temporary, { force: true });
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
async unregister() {
|
|
56
|
+
const state = await this.inspect();
|
|
57
|
+
if (!state.registered) {
|
|
58
|
+
await rm(this.state, { force: true });
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
if (state.running)
|
|
62
|
+
await this.stop();
|
|
63
|
+
await this.require(scheduler, ["/Delete", "/TN", this.task, "/F"]);
|
|
64
|
+
await rm(this.state, { force: true });
|
|
65
|
+
}
|
|
66
|
+
async start() {
|
|
67
|
+
const state = await this.inspect();
|
|
68
|
+
if (!state.registered)
|
|
69
|
+
throw new Error("The PhreshOS System service is not registered");
|
|
70
|
+
if (state.running)
|
|
71
|
+
return;
|
|
72
|
+
// Task Scheduler refuses an explicit run while a task is disabled.
|
|
73
|
+
// Enablement names future logons, not current execution, so borrow it
|
|
74
|
+
// only for the launch and restore the persisted choice immediately.
|
|
75
|
+
if (!state.enabled)
|
|
76
|
+
await this.change("/Enable");
|
|
77
|
+
try {
|
|
78
|
+
await this.require(scheduler, ["/Run", "/TN", this.task]);
|
|
79
|
+
}
|
|
80
|
+
finally {
|
|
81
|
+
if (!state.enabled)
|
|
82
|
+
await this.change("/Disable");
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
async stop() {
|
|
86
|
+
const state = await this.inspect();
|
|
87
|
+
if (!state.registered || !state.running)
|
|
88
|
+
return;
|
|
89
|
+
await this.require(scheduler, ["/End", "/TN", this.task]);
|
|
90
|
+
const until = Date.now() + 5_000;
|
|
91
|
+
while (Date.now() < until) {
|
|
92
|
+
const processes = await this.readState();
|
|
93
|
+
if (!(await this.inspect()).running && !running(processes))
|
|
94
|
+
break;
|
|
95
|
+
await new Promise(settle => setTimeout(settle, 50));
|
|
96
|
+
}
|
|
97
|
+
const processes = await this.readState();
|
|
98
|
+
if (running(processes)) {
|
|
99
|
+
if (processes?.child && alive(processes.child))
|
|
100
|
+
process.kill(processes.child);
|
|
101
|
+
await settle(processes?.child);
|
|
102
|
+
if (processes?.runner && alive(processes.runner))
|
|
103
|
+
process.kill(processes.runner);
|
|
104
|
+
await settle(processes?.runner);
|
|
105
|
+
}
|
|
106
|
+
if ((await this.inspect()).running || running(await this.readState()))
|
|
107
|
+
throw new Error("The PhreshOS System scheduled task did not stop");
|
|
108
|
+
await rm(this.state, { force: true });
|
|
109
|
+
}
|
|
110
|
+
async enable() {
|
|
111
|
+
await this.requireRegistered();
|
|
112
|
+
await this.change("/Enable");
|
|
113
|
+
}
|
|
114
|
+
async disable() {
|
|
115
|
+
await this.requireRegistered();
|
|
116
|
+
await this.change("/Disable");
|
|
117
|
+
}
|
|
118
|
+
async userSid() {
|
|
119
|
+
const result = await this.run(powershell, powershellArguments("[Console]::Out.Write([Security.Principal.WindowsIdentity]::GetCurrent().User.Value)"));
|
|
120
|
+
if (result.code !== 0)
|
|
121
|
+
throw failure(powershell, result);
|
|
122
|
+
const sid = result.stdout.trim();
|
|
123
|
+
if (!/^S-[0-9]+(?:-[0-9]+)+$/.test(sid))
|
|
124
|
+
throw new Error("The current Windows user could not be identified");
|
|
125
|
+
return sid;
|
|
126
|
+
}
|
|
127
|
+
async readState() {
|
|
128
|
+
try {
|
|
129
|
+
const value = JSON.parse(await readFile(this.state, "utf8"));
|
|
130
|
+
if (integer(value.runner) && integer(value.child))
|
|
131
|
+
return { runner: value.runner, child: value.child };
|
|
132
|
+
}
|
|
133
|
+
catch { }
|
|
134
|
+
return undefined;
|
|
135
|
+
}
|
|
136
|
+
async requireRegistered() {
|
|
137
|
+
if (!(await this.inspect()).registered)
|
|
138
|
+
throw new Error("The PhreshOS System service is not registered");
|
|
139
|
+
}
|
|
140
|
+
async change(action) {
|
|
141
|
+
await this.require(scheduler, ["/Change", "/TN", this.task, action]);
|
|
142
|
+
}
|
|
143
|
+
async require(command, args) {
|
|
144
|
+
const result = await this.run(command, args);
|
|
145
|
+
if (result.code !== 0)
|
|
146
|
+
throw failure(command, result);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
function absent() {
|
|
150
|
+
return { registered: false, automaticStartup: true, enabled: false, running: false };
|
|
151
|
+
}
|
|
152
|
+
function inspectScript(name) {
|
|
153
|
+
const selected = quotePowerShell(name);
|
|
154
|
+
return `$task = Get-ScheduledTask -TaskName ${selected} -TaskPath '\\' -ErrorAction SilentlyContinue; if ($null -eq $task) { exit 3 }; [Console]::Out.Write(("{0},{1}" -f [int]$task.State, [int]$task.Settings.Enabled))`;
|
|
155
|
+
}
|
|
156
|
+
function task(name, sid, definition, state) {
|
|
157
|
+
const payload = Buffer.from(JSON.stringify({ definition, state })).toString("base64url");
|
|
158
|
+
const argumentsValue = `"${runner}" ${payload}`;
|
|
159
|
+
return `<?xml version="1.0" encoding="UTF-16"?>
|
|
160
|
+
<Task version="1.4" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
|
|
161
|
+
<RegistrationInfo>
|
|
162
|
+
<Description>PhreshOS System</Description>
|
|
163
|
+
<URI>\\${xml(name)}</URI>
|
|
164
|
+
</RegistrationInfo>
|
|
165
|
+
<Triggers>
|
|
166
|
+
<LogonTrigger>
|
|
167
|
+
<Enabled>true</Enabled>
|
|
168
|
+
<UserId>${xml(sid)}</UserId>
|
|
169
|
+
</LogonTrigger>
|
|
170
|
+
</Triggers>
|
|
171
|
+
<Principals>
|
|
172
|
+
<Principal id="User">
|
|
173
|
+
<UserId>${xml(sid)}</UserId>
|
|
174
|
+
<LogonType>InteractiveToken</LogonType>
|
|
175
|
+
<RunLevel>LeastPrivilege</RunLevel>
|
|
176
|
+
</Principal>
|
|
177
|
+
</Principals>
|
|
178
|
+
<Settings>
|
|
179
|
+
<MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
|
|
180
|
+
<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
|
|
181
|
+
<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
|
|
182
|
+
<AllowHardTerminate>true</AllowHardTerminate>
|
|
183
|
+
<StartWhenAvailable>true</StartWhenAvailable>
|
|
184
|
+
<AllowStartOnDemand>true</AllowStartOnDemand>
|
|
185
|
+
<Enabled>true</Enabled>
|
|
186
|
+
<ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
|
|
187
|
+
<RestartOnFailure>
|
|
188
|
+
<Interval>PT1M</Interval>
|
|
189
|
+
<Count>999</Count>
|
|
190
|
+
</RestartOnFailure>
|
|
191
|
+
</Settings>
|
|
192
|
+
<Actions Context="User">
|
|
193
|
+
<Exec>
|
|
194
|
+
<Command>${xml(definition.executable)}</Command>
|
|
195
|
+
<Arguments>${xml(argumentsValue)}</Arguments>
|
|
196
|
+
<WorkingDirectory>${xml(definition.directory)}</WorkingDirectory>
|
|
197
|
+
</Exec>
|
|
198
|
+
</Actions>
|
|
199
|
+
</Task>
|
|
200
|
+
`;
|
|
201
|
+
}
|
|
202
|
+
function powershellArguments(script) {
|
|
203
|
+
return ["-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", script];
|
|
204
|
+
}
|
|
205
|
+
function quotePowerShell(value) {
|
|
206
|
+
return `'${value.replaceAll("'", "''")}'`;
|
|
207
|
+
}
|
|
208
|
+
function xml(value) {
|
|
209
|
+
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
|
|
210
|
+
}
|
|
211
|
+
function utf16(value) {
|
|
212
|
+
return Buffer.concat([Buffer.from([0xff, 0xfe]), Buffer.from(value, "utf16le")]);
|
|
213
|
+
}
|
|
214
|
+
function failure(command, result) {
|
|
215
|
+
return new Error(result.stderr.trim() || result.stdout.trim() || `${command} exited with code ${result.code}`);
|
|
216
|
+
}
|
|
217
|
+
function integer(value) {
|
|
218
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value > 0;
|
|
219
|
+
}
|
|
220
|
+
function alive(pid) {
|
|
221
|
+
try {
|
|
222
|
+
process.kill(pid, 0);
|
|
223
|
+
return true;
|
|
224
|
+
}
|
|
225
|
+
catch {
|
|
226
|
+
return false;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
function running(value) {
|
|
230
|
+
return Boolean(value && (alive(value.runner) || alive(value.child)));
|
|
231
|
+
}
|
|
232
|
+
async function settle(pid) {
|
|
233
|
+
if (!pid)
|
|
234
|
+
return;
|
|
235
|
+
const until = Date.now() + 1_000;
|
|
236
|
+
while (Date.now() < until && alive(pid))
|
|
237
|
+
await new Promise(resolve => setTimeout(resolve, 25));
|
|
238
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@phreshos/cli",
|
|
3
3
|
"type": "module",
|
|
4
|
-
|
|
4
|
+
"version": "0.1.13",
|
|
5
5
|
"description": "The Phresh command-line interface for Program projects and system management.",
|
|
6
6
|
"engines": {
|
|
7
7
|
"node": ">=20.10"
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
"verify:system-release": "node --run compile && node scripts/verify-system-release.mjs",
|
|
16
16
|
"verify:linux-service": "node --run compile && node scripts/verify-linux-service.mjs",
|
|
17
17
|
"verify:macos-service": "node --run compile && node scripts/verify-macos-service.mjs",
|
|
18
|
+
"verify:windows-service": "node --run compile && node scripts/verify-windows-service.mjs",
|
|
18
19
|
"prepack": "node --run build"
|
|
19
20
|
},
|
|
20
21
|
"bin": {
|
|
@@ -47,7 +48,7 @@
|
|
|
47
48
|
"packageManager": "bun@1.3.14",
|
|
48
49
|
"dependencies": {
|
|
49
50
|
"@clack/prompts": "^1.7.0",
|
|
50
|
-
|
|
51
|
+
"@phreshos/core": "^0.1.4",
|
|
51
52
|
"adm-zip": "^0.6.0",
|
|
52
53
|
"commander": "^15.0.0",
|
|
53
54
|
"picocolors": "^1.1.1"
|