@phreshos/cli 0.1.31 → 0.1.33

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -10,8 +10,9 @@ active testing. The architecture's components will be released in stages as
10
10
  their contracts and integrations are verified.
11
11
 
12
12
  `@phreshos/cli` is not intended to be used independently of that architecture.
13
- Its Program commands depend on `@phreshos/core`, and its runtime operations
14
- require a compatible system installation.
13
+ Its Program commands use `@phreshos/node`, whose `Project` model owns local
14
+ project behavior and whose `System.connect()` provides the same Core contract
15
+ used inside Server Programs. Runtime operations require a compatible System.
15
16
 
16
17
  ```bash
17
18
  phresh create app # create a complete new Program
@@ -1,7 +1,44 @@
1
- import { requestSystem } from "./gateway.js";
1
+ import { gatewayAddress, resolveHome } from "@phreshos/node";
2
+ import { connect } from "node:net";
2
3
  /** Owner-local client for the authoritative running-System contract. */
3
4
  export default class LocalSystemControl {
4
- execute(request, signal) {
5
- return requestSystem(request, undefined, signal);
5
+ async execute(request, signal) {
6
+ const address = gatewayAddress(resolveHome());
7
+ return await new Promise((resolve, reject) => {
8
+ const socket = connect(address);
9
+ let buffer = "";
10
+ let settled = false;
11
+ const finish = (work) => {
12
+ if (settled)
13
+ return;
14
+ settled = true;
15
+ signal?.removeEventListener("abort", cancel);
16
+ socket.destroy();
17
+ work();
18
+ };
19
+ const cancel = () => finish(() => reject(signal?.reason instanceof Error ? signal.reason : new Error("The request was cancelled")));
20
+ signal?.addEventListener("abort", cancel, { once: true });
21
+ socket.on("connect", () => socket.write(`${JSON.stringify({ target: "system", request })}\n`));
22
+ socket.on("data", chunk => {
23
+ buffer += String(chunk);
24
+ const boundary = buffer.indexOf("\n");
25
+ if (boundary < 0)
26
+ return;
27
+ try {
28
+ const outcome = JSON.parse(buffer.slice(0, boundary));
29
+ if (outcome.success)
30
+ finish(() => resolve(outcome.result));
31
+ else
32
+ finish(() => reject(new Error(outcome.error)));
33
+ }
34
+ catch {
35
+ finish(() => reject(new Error("The System returned an invalid response")));
36
+ }
37
+ });
38
+ socket.on("error", () => finish(() => reject(new Error(`No System is listening at ${address} — start PhreshOS first`))));
39
+ socket.on("close", () => finish(() => reject(new Error("The System closed the request without an answer"))));
40
+ if (signal?.aborted)
41
+ cancel();
42
+ });
6
43
  }
7
44
  }
package/dist/gateway.js CHANGED
@@ -1,96 +1,5 @@
1
- import { createHash } from "node:crypto";
2
- import { connect } from "node:net";
3
- import { join } from "node:path";
4
- import phreshosHome from "./home.js";
5
- /** Resolve the one owner-local gateway address for the selected System home. */
6
- export function gatewayPath(home = phreshosHome(), platform = process.platform) {
7
- if (platform !== "win32")
8
- return join(home, "gateway.sock");
9
- const owner = home.replaceAll("\\", "/").replace(/\/+$/, "").toLowerCase();
10
- const identity = createHash("sha256").update(owner).digest("hex").slice(0, 32);
11
- return `\\\\.\\pipe\\phreshos-${identity}-gateway`;
12
- }
13
- /** Stream one Program lifecycle request over the shared gateway. */
14
- export function streamProgram(request, heard, path = gatewayPath(), signal) {
15
- return new Promise(function (resolve, reject) {
16
- const socket = connect(path);
17
- let buffer = "";
18
- let failure = null;
19
- let cancelled = false;
20
- const cancel = () => {
21
- cancelled = true;
22
- socket.destroy();
23
- };
24
- if (signal?.aborted)
25
- cancel();
26
- else
27
- signal?.addEventListener("abort", cancel, { once: true });
28
- socket.on("connect", () => socket.write(`${JSON.stringify({ target: "program", request })}\n`));
29
- socket.on("data", function (chunk) {
30
- buffer += String(chunk);
31
- const lines = buffer.split("\n");
32
- buffer = lines.pop() ?? "";
33
- for (const line of lines)
34
- if (line.trim()) {
35
- const event = JSON.parse(line);
36
- if (event.event === "error")
37
- failure = new Error(String(event.message));
38
- else
39
- heard(event);
40
- }
41
- });
42
- socket.on("error", () => {
43
- if (!cancelled)
44
- reject(new Error(`No System gateway is listening at ${path} — start PhreshOS first`));
45
- });
46
- socket.on("close", () => {
47
- signal?.removeEventListener("abort", cancel);
48
- if (cancelled)
49
- reject(new Error("The attached launch was stopped"));
50
- else if (failure)
51
- reject(failure);
52
- else
53
- resolve();
54
- });
55
- });
56
- }
57
- /** Execute one short request over the shared gateway. */
58
- export function requestSystem(request, path = gatewayPath(), signal) {
59
- return new Promise((resolve, reject) => {
60
- const socket = connect(path);
61
- let buffer = "";
62
- let settled = false;
63
- const finish = (work) => {
64
- if (settled)
65
- return;
66
- settled = true;
67
- signal?.removeEventListener("abort", cancel);
68
- socket.destroy();
69
- work();
70
- };
71
- const cancel = () => finish(() => reject(signal?.reason instanceof Error ? signal.reason : new Error("The request was cancelled")));
72
- signal?.addEventListener("abort", cancel, { once: true });
73
- socket.on("connect", () => socket.write(`${JSON.stringify({ target: "system", request })}\n`));
74
- socket.on("data", function (chunk) {
75
- buffer += String(chunk);
76
- const boundary = buffer.indexOf("\n");
77
- if (boundary < 0)
78
- return;
79
- let outcome;
80
- try {
81
- outcome = JSON.parse(buffer.slice(0, boundary));
82
- }
83
- catch {
84
- return finish(() => reject(new Error("The System returned an invalid gateway response")));
85
- }
86
- if (outcome.success)
87
- finish(() => resolve(outcome.result));
88
- else
89
- finish(() => reject(new Error(outcome.error)));
90
- });
91
- socket.on("error", () => finish(() => reject(new Error(`No System gateway is listening at ${path} — start PhreshOS first`))));
92
- socket.on("close", () => finish(() => reject(new Error("The System closed the gateway request without an answer"))));
93
- if (signal?.aborted)
94
- cancel();
95
- });
1
+ import { gatewayAddress, resolveHome } from "@phreshos/node";
2
+ /** Resolve the Gateway address while preserving the CLI's public helper. */
3
+ export function gatewayPath(home = resolveHome(), platform = process.platform) {
4
+ return gatewayAddress(home, platform);
96
5
  }
package/dist/install.js CHANGED
@@ -1,9 +1,7 @@
1
- import derive from "./derive.js";
2
- import { readConfig } from "./project.js";
1
+ import { Project } from "@phreshos/node";
3
2
  import { dim, heading, line } from "./style.js";
4
3
  import installProgram, {} from "./program-installation.js";
5
4
  import { prepareOfficialProgram } from "./program-release.js";
6
- import build from "./build-command.js";
7
5
  /**
8
6
  * Lay this program out on this machine's system.
9
7
  *
@@ -26,7 +24,9 @@ export default async function install(options = {}) {
26
24
  const directory = options.directory ?? process.cwd();
27
25
  const prepared = options.name ? await prepareOfficialProgram(options.name) : null;
28
26
  try {
29
- const program = prepared?.program ?? await localProgram(directory);
27
+ const program = prepared ? prepared.program : await Project.open(directory);
28
+ if (program instanceof Project && program.config.buildCommand)
29
+ line("build", program.config.buildCommand);
30
30
  const result = await installProgram(program, options);
31
31
  if (options.announce !== false) {
32
32
  const { name, identity, version } = result.program;
@@ -44,8 +44,3 @@ export default async function install(options = {}) {
44
44
  await prepared?.dispose();
45
45
  }
46
46
  }
47
- async function localProgram(directory) {
48
- const config = await readConfig(directory);
49
- await build(config, directory);
50
- return derive(config, directory, "production");
51
- }
package/dist/launch.js CHANGED
@@ -1,111 +1,56 @@
1
- import derive, {} from "./derive.js";
2
- import { readConfig } from "./project.js";
3
- import { dim, heading, line } from "./style.js";
1
+ import { Project } from "@phreshos/node";
4
2
  import { relative } from "node:path";
5
- import attach from "./attach.js";
6
- import { assertClientDevelopmentUrlFree, commandFailure, startClientDevelopment, waitForClientDevelopment } from "./client-development.js";
7
- import build from "./build-command.js";
8
- /**
9
- * Run this program, without installing it, and stay with it.
10
- *
11
- * `phresh start` and `phresh dev` are the same act over the same
12
- * derivation, differing only in where each half is said to be — what the
13
- * build left, or where the source is.
14
- *
15
- * What the derivation produced is shown before it is sent, because it is
16
- * worth seeing rather than inferring from what happens next. Shown, not
17
- * dumped: the whole program.json is a wall of braces an author already
18
- * knows, and what they cannot know at a glance is which of their two
19
- * answers each half took. So each half says where it came from, and a
20
- * path is shortened back to the form they typed — the absolute one is
21
- * the machine's business, and they are standing in the directory it is
22
- * relative to.
23
- *
24
- * Nothing is installed. This local project is authoritative for attached
25
- * use of its declared identity: the system ends and forgets any runtime
26
- * Program already there, then registers this run as the sole uninstalled
27
- * occupant. Installed files and storage are not removed. Its root process
28
- * tethers the whole replacement to this command: when the process ends, the
29
- * registry record and any remaining processes go too.
30
- * **Attached means not installed; installed means persistent** — a
31
- * program meant to outlive a terminal is installed rather than run.
32
- *
33
- * A client development command belongs to this local authoring session,
34
- * never to the Program sent to the system. The declared client location
35
- * has to become reachable before that Program is launched, so the first
36
- * window does not open onto a destination already known to be absent.
37
- *
38
- * Its output arrives here because it has somewhere to arrive: the system
39
- * pipes a launched program only when someone is listening, and this is
40
- * the someone.
41
- */
42
- export default async function launch(which, directory = process.cwd(), options = {}) {
43
- const config = await readConfig(directory);
44
- if (which === "production")
45
- await build(config, directory);
46
- const program = derive(config, directory, which);
47
- const clientDevelopmentConfig = which === "development" && program.client && (program.client.start ?? true)
48
- ? config.client?.development
49
- : undefined;
50
- const clientStartCommand = clientDevelopmentConfig?.startCommand;
51
- heading(`${program.name ?? program.identity}${program.version ? ` ${program.version}` : ""}`, which);
3
+ import { dim, heading, line } from "./style.js";
4
+ /** Run the current project through one connected System. */
5
+ export default async function launch(mode, directory = process.cwd(), options = {}) {
6
+ const project = await Project.open(directory);
7
+ const program = project.description(mode);
8
+ heading(`${program.name ?? program.identity}${program.version ? ` ${program.version}` : ""}`, mode);
9
+ if (mode === "production" && project.config.buildCommand)
10
+ line("build", project.config.buildCommand);
52
11
  if (program.server)
53
- line(program.server.startCommand ? "server" : "server worker", String(program.server.startCommand ?? program.server.entryFile), place(directory, program.server.location));
12
+ line(program.server.startCommand ? "server" : "server worker", String(program.server.startCommand ?? program.server.entryFile), place(project.directory, program.server.location));
54
13
  if (program.client)
55
- line("client", clientStartCommand ?? place(directory, program.client.location), clientStartCommand ? place(directory, program.client.location) : undefined);
56
- line("storage", place(directory, String(program.storage)));
14
+ line("client", project.config.client?.development?.startCommand ?? place(project.directory, program.client.location));
15
+ line("storage", place(project.directory, String(program.storage)));
16
+ if (Object.keys(options).length)
17
+ line("options", Object.entries(options).map(([name, value]) => `${name}=${value}`).join(" "));
57
18
  console.log("");
58
- if (clientStartCommand && program.client)
59
- await assertClientDevelopmentUrlFree(program.client.location);
60
- const clientDevelopment = clientDevelopmentConfig
61
- ? startClientDevelopment(clientStartCommand, directory)
62
- : null;
19
+ const system = await (await import("@phreshos/node")).System.connect();
63
20
  const controller = new AbortController();
64
- let signalled = false;
65
- const stopOnSignal = () => {
66
- if (signalled)
21
+ let interrupted = false;
22
+ let ended = null;
23
+ const stop = () => {
24
+ if (interrupted)
67
25
  return;
68
- signalled = true;
69
- controller.abort();
70
- void (clientDevelopment?.stop() ?? Promise.resolve()).finally(() => process.exit(130));
26
+ interrupted = true;
27
+ controller.abort(new Error("The local Program run was interrupted"));
71
28
  };
72
- // A signal ends this command, and ending this command closes the
73
- // socket, and closing the socket stops the program. A client development
74
- // command is another child of the same session, so it is ended first.
75
29
  for (const signal of ["SIGINT", "SIGTERM"])
76
- process.on(signal, stopOnSignal);
77
- let status = 0;
30
+ process.on(signal, stop);
78
31
  try {
79
- if (clientDevelopmentConfig && program.client)
80
- await waitForClientDevelopment(program.client.location, clientDevelopment);
81
- if (Object.keys(options).length)
82
- line("options", Object.entries(options).map(([name, value]) => `${name}=${value}`).join(" "));
83
- const attachment = attach(program, options, {
84
- started: identity => console.log(`${clientDevelopment ? "\n" : ""} ${dim("running as")} ${identity}\n`),
85
- output: (stream, text) => (stream === "err" ? process.stderr : process.stdout).write(text)
86
- }, undefined, controller.signal);
87
- const ended = clientDevelopment ? await Promise.race([
88
- attachment,
89
- clientDevelopment.exited.then(exit => {
90
- if (clientDevelopment.stopping)
91
- return new Promise(() => undefined);
92
- controller.abort();
93
- throw commandFailure(exit);
94
- })
95
- ]) : await attachment;
96
- console.log(`\n ${dim(ended.signal ? `ended on ${ended.signal}` : `ended with ${ended.code ?? 0}`)}\n`);
97
- status = ended.signal ? 128 : ended.code ?? 0;
32
+ const result = mode === "development"
33
+ ? await project.dev(system, { options, signal: controller.signal })
34
+ : await project.start(system, { options, signal: controller.signal });
35
+ console.log(`\n ${dim("ran as")} ${result.process.identity}`);
36
+ ended = { code: result.exit.code, signal: result.exit.signal };
37
+ }
38
+ catch (error) {
39
+ if (!interrupted)
40
+ throw error;
98
41
  }
99
42
  finally {
100
43
  for (const signal of ["SIGINT", "SIGTERM"])
101
- process.off(signal, stopOnSignal);
102
- await clientDevelopment?.stop();
44
+ process.off(signal, stop);
45
+ await system.disconnect();
103
46
  }
104
- // Its status is this command's status: whoever ran the program is
105
- // owed what the program said on the way out.
106
- process.exit(status);
47
+ if (interrupted)
48
+ process.exit(130);
49
+ if (!ended)
50
+ throw new Error("The System closed before the Program ended");
51
+ console.log(`\n ${dim(ended.signal ? `ended on ${ended.signal}` : `ended with ${ended.code ?? 0}`)}\n`);
52
+ process.exit(ended.signal ? 128 : ended.code ?? 0);
107
53
  }
108
- // A URL as written; a path as the author typed it.
109
- function place(directory, where) {
110
- return /^https?:\/\//i.test(where) ? where : `./${relative(directory, where)}`;
54
+ function place(directory, location) {
55
+ return /^https?:\/\//i.test(location) ? location : `./${relative(directory, location)}`;
111
56
  }
package/dist/pack.js CHANGED
@@ -1,107 +1,11 @@
1
- import { readConfig, readManifest } from "./project.js";
2
- import { createHash } from "node:crypto";
3
- import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs";
4
- import { resolve } from "node:path";
5
- import AdmZip from "adm-zip";
6
- import build from "./build-command.js";
7
- /**
8
- * Package what is at each half's location.
9
- *
10
- * When the author config declares `buildCommand`, it completes before
11
- * any files are gathered. Pack remains an assembler rather than a
12
- * build tool: it invokes the author's command, then packages its result.
13
- *
14
- * The authoring tree and the installable form differ. A program may
15
- * leave its halves anywhere; the package always has them in the same
16
- * places, so the artifact's shape is the contract's rather than the
17
- * author's. The program.json names those canonical places explicitly:
18
- * a declared half always has a location, even when the system chose it.
19
- *
20
- * There is **no wrapping directory**. `program.json`, `server/`,
21
- * `client/`, optional `icon.png`, and optional `agent.md` sit at the package's
22
- * root, and the directory
23
- * the system installs into is named from the program's own `identity`.
24
- * The archive itself therefore needs no second naming layer.
25
- *
26
- * The files go into the archive directly rather than through a staging
27
- * directory: a copy of a build is a thing that can be stale. The generated
28
- * standalone program.json is also placed beside the archive for release
29
- * discovery; both declarations are made from the same bytes.
30
- */
1
+ import { Project } from "@phreshos/node";
2
+ import { line } from "./style.js";
3
+ /** Package the current Project and present only its command-facing progress. */
31
4
  export default async function pack(directory = process.cwd()) {
32
- const config = await readConfig(directory);
33
- await build(config, directory);
34
- const manifest = await readManifest(directory);
35
- const version = config.version ?? manifest.version;
36
- if (config.version && manifest.version && config.version !== manifest.version) {
37
- console.log(`phresh: packing ${config.version}, though package.json says ${manifest.version}\n`);
38
- }
39
- const zip = new AdmZip();
40
- if (config.server)
41
- place(zip, directory, config.server.location, "server");
42
- if (config.client) {
43
- place(zip, directory, config.client.location, "client");
44
- if (!zip.getEntry("client/index.html"))
45
- throw new Error(`The client files have no index.html — ${config.client.location} is not where a client half is`);
46
- }
47
- if (config.icon)
48
- file(zip, directory, config.icon, "icon.png", "Program icon");
49
- if (config.agent)
50
- file(zip, directory, config.agent, "agent.md", "Program agent documentation");
51
- const declaration = Buffer.from(JSON.stringify(program(config, version), null, 4) + "\n");
52
- zip.addFile("program.json", declaration);
53
- // The standalone release declaration and the declaration inside the
54
- // archive are the same bytes. There is no separately authored catalog
55
- // description that can drift from the installable Program.
56
- writeFileSync(resolve(directory, "program.json"), declaration);
57
- const archive = `${config.identity}@${version ?? "0.0.0"}.zip`;
58
- const bytes = zip.toBuffer();
59
- writeFileSync(resolve(directory, archive), bytes);
60
- const digest = createHash("sha256").update(bytes).digest("hex");
61
- writeFileSync(resolve(directory, `${archive}.sha256`), `${digest} ${archive}\n`);
62
- console.log(`\nPacked ${archive}`);
63
- return archive;
64
- }
65
- // The derivation, and the whole of it. What the config says about the
66
- // program passes through; what it says about the build does not. Half
67
- // locations are rewritten to the canonical places inside the package.
68
- function program(config, version) {
69
- return {
70
- identity: config.identity,
71
- name: config.name,
72
- version,
73
- description: config.description,
74
- icon: config.icon ? "icon.png" : undefined,
75
- agent: config.agent ? "agent.md" : undefined,
76
- categories: config.categories,
77
- keywords: config.keywords,
78
- website: config.website,
79
- ...config.server && { server: {
80
- location: "server",
81
- start: config.server.start,
82
- installCommand: config.server.installCommand,
83
- uninstallCommand: config.server.uninstallCommand,
84
- ...serverExecution(config.server)
85
- } },
86
- ...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 } }
87
- };
88
- }
89
- function serverExecution(server) {
90
- return server.startCommand !== undefined
91
- ? { startCommand: server.startCommand }
92
- : { entryFile: server.entryFile };
93
- }
94
- // What is at a half's location, where the package keeps it. A half that
95
- // was declared and is not there is the answer to whether it was built.
96
- function place(zip, directory, location, half) {
97
- const from = resolve(directory, location);
98
- if (!existsSync(from))
99
- throw new Error(`The ${half} files are not at ${location} — nothing was built there`);
100
- zip.addLocalFolder(from, half);
101
- }
102
- function file(zip, directory, location, target, label) {
103
- const from = resolve(directory, location);
104
- if (!existsSync(from) || !statSync(from).isFile())
105
- throw new Error(`The ${label} is not at ${location}`);
106
- zip.addFile(target, readFileSync(from));
5
+ const project = await Project.open(directory);
6
+ if (project.config.buildCommand)
7
+ line("build", project.config.buildCommand);
8
+ const packed = await project.pack();
9
+ console.log(`\nPacked ${packed.archive}`);
10
+ return packed.archive;
107
11
  }
@@ -1,57 +1,53 @@
1
- import { streamProgram } from "./gateway.js";
1
+ import { Project } from "@phreshos/node";
2
2
  import writeProgramCommandOutput from "./program-command-output.js";
3
3
  /** Install one prepared Program and await every explicitly requested outcome. */
4
4
  export default async function installProgram(program, options = {}) {
5
- let installedValue;
6
- let replaced = false;
5
+ const system = await (await import("@phreshos/node")).System.connect();
6
+ const identity = program instanceof Project ? program.config.identity : program.identity;
7
+ const current = await system.program.find(identity);
8
+ const replaced = await current?.installed() ?? false;
9
+ let installed = null;
7
10
  let startupEnabled = false;
8
- let processValue;
9
- await streamProgram({
10
- word: "install",
11
- program,
12
- run: options.run === true,
13
- startup: options.startup === true
14
- }, function (event) {
15
- if (event.event === "output")
16
- writeProgramCommandOutput(event);
17
- else if (event.event === "installed") {
18
- installedValue = event.program;
19
- replaced = event.replaced === true;
11
+ let process = null;
12
+ let installationFinished = false;
13
+ try {
14
+ if (program instanceof Project)
15
+ installed = await program.install(system);
16
+ else {
17
+ installed = await system.forceCreateProgram(program);
18
+ for await (const chunk of installed.install())
19
+ writeProgramCommandOutput(chunk);
20
20
  }
21
- else if (event.event === "startupEnabled")
21
+ installationFinished = true;
22
+ if (options.startup) {
23
+ await installed.startup.enable();
22
24
  startupEnabled = true;
23
- else if (event.event === "running")
24
- processValue = event.process;
25
- });
26
- if (installedValue === undefined)
25
+ }
26
+ if (options.run)
27
+ process = (await installed.process.create()).identity;
28
+ }
29
+ catch (error) {
30
+ if (installed && !installationFinished) {
31
+ try {
32
+ await installed.forget();
33
+ }
34
+ catch { /* Preserve the installation failure. */ }
35
+ }
36
+ throw error;
37
+ }
38
+ finally {
39
+ await system.disconnect();
40
+ }
41
+ if (!installed)
27
42
  throw new Error("The System ended Program installation without confirming it");
28
- const installed = installedProgram(installedValue);
29
43
  if (options.startup && !startupEnabled)
30
44
  throw new Error("The System installed the Program without confirming startup");
31
- const process = processValue === undefined ? null : processIdentity(processValue);
32
45
  if (options.run && !process)
33
46
  throw new Error("The System installed the Program without confirming that it is running");
34
47
  return {
35
- program: installed,
48
+ program: { identity: installed.identity, name: installed.name, version: installed.version },
36
49
  replaced,
37
50
  startupEnabled,
38
51
  process
39
52
  };
40
53
  }
41
- function processIdentity(value) {
42
- if (typeof value !== "string" || !value)
43
- throw new Error("The System returned an invalid Process identity");
44
- return value;
45
- }
46
- function installedProgram(value) {
47
- if (!record(value)
48
- || typeof value.identity !== "string"
49
- || typeof value.name !== "string"
50
- || value.version !== null && typeof value.version !== "string") {
51
- throw new Error("The System returned an invalid installed Program");
52
- }
53
- return { identity: value.identity, name: value.name, version: value.version };
54
- }
55
- function record(value) {
56
- return typeof value === "object" && value !== null && !Array.isArray(value);
57
- }
@@ -85,6 +85,7 @@ async function readProgram(directory, release) {
85
85
  throw new Error("A published Program package cannot choose its installed storage");
86
86
  return {
87
87
  ...value,
88
+ storage: join(directory, "storage"),
88
89
  ...pathField(value, directory, "icon", "icon.png"),
89
90
  ...pathField(value, directory, "agent", "agent.md"),
90
91
  ...half(value, directory, "server"),
package/dist/project.js CHANGED
@@ -1,126 +1,11 @@
1
- import { isRelativeValue, layers } from "@phreshos/core";
1
+ import { Project } from "@phreshos/node";
2
2
  import { existsSync } from "node:fs";
3
+ import { readFile } from "node:fs/promises";
3
4
  import { isAbsolute, normalize, resolve, sep } from "node:path";
4
- import { createJiti } from "jiti";
5
5
  export const configFile = "phresh.config.ts";
6
+ /** Read the current Project through the reusable Node project model. */
6
7
  export async function readConfig(directory = process.cwd()) {
7
- const path = resolve(directory, configFile);
8
- if (!existsSync(path))
9
- throw new Error(`There is no ${configFile} here — run: phresh init`);
10
- // Program declarations remain typed authoring files independently of the
11
- // Node version that happens to execute the CLI.
12
- const config = await createJiti(import.meta.url).import(path, { default: true }).catch(function (error) {
13
- throw new Error(`${configFile} could not be read (${error.message})`);
14
- });
15
- if (!config)
16
- throw new Error(`${configFile} must export its config as the default export`);
17
- coherent(config);
18
- return config;
19
- }
20
- // Whether the words agree with each other. Nothing here touches a disk
21
- // or asks whether a build ran: it is the same question the system asks
22
- // of a program.json when it is constructed, asked earlier, where the
23
- // person who mistyped it is still standing.
24
- function coherent(config) {
25
- // A name is the name of a directory before it is anything else, and
26
- // the system checks it again where it makes one. Checked here too so
27
- // the mistake is found by the CLI rather than at the runtime border.
28
- if (typeof config.identity !== "string" || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(config.identity))
29
- throw new Error("A program's identity is kebab-case, because it is also the name of its directory");
30
- if (!config.server && !config.client)
31
- throw new Error("A program must have a server half, a client half, or both");
32
- for (const field of ["name", "version", "description", "icon", "agent", "website"]) {
33
- if (config[field] !== undefined && typeof config[field] !== "string")
34
- throw new Error(`A program's ${field} must be text`);
35
- }
36
- if (config.agent !== undefined && config.agent.trim().length === 0)
37
- throw new Error("A program's agent documentation must be a non-empty path");
38
- if (config.website !== undefined) {
39
- try {
40
- new URL(config.website);
41
- }
42
- catch {
43
- throw new Error("A program's website must be a valid URL");
44
- }
45
- }
46
- for (const [field, maximum] of [["categories", 20], ["keywords", 50]]) {
47
- const values = config[field];
48
- if (values === undefined)
49
- continue;
50
- if (!Array.isArray(values) || values.length > maximum || values.some(value => typeof value !== "string" || value.trim().length === 0 || value.trim().length > 50)) {
51
- throw new Error(`A program's ${field} must contain at most ${maximum} non-empty values of at most 50 characters`);
52
- }
53
- }
54
- if (config.buildCommand !== undefined && (typeof config.buildCommand !== "string" || config.buildCommand.trim().length === 0))
55
- throw new Error("A program's buildCommand must be non-empty text");
56
- for (const half of ["server", "client"]) {
57
- const declared = config[half];
58
- if (declared === undefined)
59
- continue;
60
- if (typeof declared !== "object" || declared === null || Array.isArray(declared))
61
- throw new Error(`A program's ${half} half must be a declaration`);
62
- if (typeof declared.location !== "string")
63
- throw new Error(`A declared ${half} half must have a location`);
64
- if (declared.start !== undefined && typeof declared.start !== "boolean")
65
- throw new Error(`A declared ${half} endpoint's start default must be true or false`);
66
- }
67
- if (!(config.server && (config.server.start ?? true)) && !(config.client && (config.client.start ?? true)))
68
- throw new Error("A Program's default Process must start a server endpoint, a client endpoint, or both");
69
- if (config.server)
70
- execution(config.server, "A server half");
71
- if (config.server?.installCommand !== undefined && typeof config.server.installCommand !== "string")
72
- throw new Error("A server half's install command must be text");
73
- if (config.server?.uninstallCommand !== undefined && typeof config.server.uninstallCommand !== "string")
74
- throw new Error("A server half's uninstall command must be text");
75
- if (config.client?.title !== undefined && typeof config.client.title !== "string")
76
- throw new Error("A client half's title must be text");
77
- if (config.client?.minimize !== undefined && typeof config.client.minimize !== "boolean")
78
- throw new Error("A client half's minimize default must be true or false");
79
- if (config.client && /^https?:\/\//i.test(config.client.location)) {
80
- try {
81
- new URL(config.client.location);
82
- }
83
- catch {
84
- throw new Error("A client half's URL must be a valid HTTP or HTTPS URL");
85
- }
86
- }
87
- // Said wrong is caught here, where the author is standing, rather
88
- // than becoming a window in no layer at all.
89
- if (config.client?.layer !== undefined && !layers.includes(config.client.layer))
90
- throw new Error(`A client half's layer is one of ${layers.join(", ")} — not "${String(config.client.layer)}"`);
91
- const serverDevelopment = config.server?.development;
92
- if (serverDevelopment !== undefined && (typeof serverDevelopment !== "object" || serverDevelopment === null || Array.isArray(serverDevelopment)))
93
- throw new Error("server.development must be a declaration");
94
- if (serverDevelopment !== undefined)
95
- execution(serverDevelopment, "server.development");
96
- const clientDevelopment = config.client?.development;
97
- if (clientDevelopment !== undefined && (typeof clientDevelopment !== "object" || clientDevelopment === null || Array.isArray(clientDevelopment)))
98
- throw new Error("client.development must be a declaration");
99
- if (clientDevelopment !== undefined && !httpUrl(clientDevelopment.url))
100
- throw new Error("client.development.url must be a valid HTTP or HTTPS URL");
101
- if (clientDevelopment?.startCommand !== undefined && (typeof clientDevelopment.startCommand !== "string" || clientDevelopment.startCommand.trim().length === 0))
102
- throw new Error("client.development.startCommand must be non-empty text");
103
- for (const [what, value] of [["size", config.client?.size], ["position", config.client?.position]]) {
104
- if (value === undefined)
105
- continue;
106
- if (typeof value !== "object" || value === null || Array.isArray(value))
107
- throw new Error(`A window's ${what} must name both of its values`);
108
- const pair = what === "size" ? [value.width, value.height] : [value.x, value.y];
109
- if (!pair.every(isRelativeValue))
110
- throw new Error(`A window's ${what} is a finite pixel number or a relative expression such as "50% + 10"`);
111
- }
112
- }
113
- function execution(value, owner) {
114
- const command = typeof value.startCommand === "string" && value.startCommand.trim().length > 0;
115
- const entry = typeof value.entryFile === "string" && value.entryFile.trim().length > 0;
116
- if (value.startCommand !== undefined && !command)
117
- throw new Error(`${owner}'s startCommand must be non-empty text`);
118
- if (value.entryFile !== undefined && !entry)
119
- throw new Error(`${owner}'s entryFile must be a non-empty path`);
120
- if (command === entry)
121
- throw new Error(`${owner} must declare exactly one non-empty startCommand or entryFile`);
122
- if (entry && !containedServerEntry(value.entryFile))
123
- throw new Error(`${owner}'s entryFile must remain inside its Server directory`);
8
+ return (await Project.open(directory)).config;
124
9
  }
125
10
  export function containedServerEntry(entry) {
126
11
  if (isAbsolute(entry))
@@ -128,23 +13,13 @@ export function containedServerEntry(entry) {
128
13
  const path = normalize(entry);
129
14
  return path !== ".." && !path.startsWith(`..${sep}`);
130
15
  }
131
- function httpUrl(value) {
132
- if (typeof value !== "string" || value.trim().length === 0)
133
- return false;
134
- try {
135
- const url = new URL(value);
136
- return url.protocol === "http:" || url.protocol === "https:";
137
- }
138
- catch {
139
- return false;
140
- }
141
- }
142
16
  export async function readManifest(directory = process.cwd()) {
143
17
  const path = resolve(directory, "package.json");
144
18
  if (!existsSync(path))
145
19
  throw new Error("There is no package.json here");
146
- const manifest = JSON.parse(await import("node:fs/promises").then(fs => fs.readFile(path, "utf-8")));
147
- if (typeof manifest.name !== "string" || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(manifest.name))
20
+ const manifest = JSON.parse(await readFile(path, "utf8"));
21
+ if (typeof manifest.name !== "string" || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(manifest.name)) {
148
22
  throw new Error("package.json must have a kebab-case name");
23
+ }
149
24
  return manifest;
150
25
  }
@@ -22,7 +22,7 @@
22
22
  "react-dom": "^19.2.8"
23
23
  },
24
24
  "devDependencies": {
25
- "@phreshos/cli": "^0.1.31",
25
+ "@phreshos/cli": "^0.1.33",
26
26
  "@types/node": "^26.2.0",
27
27
  "@types/react": "^19.2.18",
28
28
  "@types/react-dom": "^19.2.4",
package/dist/uninstall.js CHANGED
@@ -1,20 +1,24 @@
1
1
  import { readConfig } from "./project.js";
2
2
  import { dim, heading } from "./style.js";
3
- import { streamProgram } from "./gateway.js";
4
3
  import writeProgramCommandOutput from "./program-command-output.js";
5
4
  /** Uninstall an installed Program by name or by the current project's identity. */
6
5
  export default async function uninstall(options = {}) {
7
6
  const identity = options.name ?? (await readConfig(options.directory ?? process.cwd())).identity;
8
7
  if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(identity))
9
8
  throw new Error(`The Program name "${identity}" is invalid`);
10
- await streamProgram({ word: "uninstall", identity, everything: options.everything === true }, function (event) {
11
- if (event.event === "output")
12
- return writeProgramCommandOutput(event);
13
- if (event.event !== "uninstalled")
14
- return;
9
+ const system = await (await import("@phreshos/node")).System.connect();
10
+ try {
11
+ const program = await system.program.find(identity);
12
+ if (!program)
13
+ throw new Error("The System does not know this Program");
14
+ for await (const chunk of program.uninstall(options.everything === true))
15
+ writeProgramCommandOutput(chunk);
15
16
  heading(identity, "uninstalled");
16
17
  console.log(options.everything
17
18
  ? ` ${dim("Its processes, installed files, stored data, and runtime record were removed.")}\n`
18
19
  : ` ${dim("Its installed files were removed. Processes, stored data, and runtime state were kept.")}\n`);
19
- });
20
+ }
21
+ finally {
22
+ await system.disconnect();
23
+ }
20
24
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@phreshos/cli",
3
3
  "type": "module",
4
- "version": "0.1.31",
4
+ "version": "0.1.33",
5
5
  "description": "The Phresh command-line interface for Program projects and system management.",
6
6
  "engines": {
7
7
  "node": ">=20.10"
@@ -49,7 +49,8 @@
49
49
  "packageManager": "bun@1.3.14",
50
50
  "dependencies": {
51
51
  "@clack/prompts": "^1.7.0",
52
- "@phreshos/core": "^0.1.20",
52
+ "@phreshos/core": "^0.1.22",
53
+ "@phreshos/node": "^0.1.1",
53
54
  "adm-zip": "^0.6.0",
54
55
  "commander": "^15.0.0",
55
56
  "jiti": "^2.7.0",
package/dist/attach.js DELETED
@@ -1,29 +0,0 @@
1
- import { gatewayPath, streamProgram } from "./gateway.js";
2
- /**
3
- * Run a program on this machine's system, and stay with it.
4
- *
5
- * **The connection is the tether, in both directions.** While it is open
6
- * the program runs; when it closes the system stops the program. So this
7
- * does not have to promise to clean up, and could not be trusted if it
8
- * did: a `kill -9`, a closed terminal and a dropped ssh session all end
9
- * this process without running a line of it, and every one of them still
10
- * closes the socket. The other direction is the same fact from the other
11
- * end — when the program ends, the system says so and closes, and this
12
- * resolves with the program's own status.
13
- */
14
- export default async function attach(program, options = {}, watching = {}, path = gatewayPath(), signal) {
15
- let ended = null;
16
- await streamProgram({ word: "run", program, options }, function (event) {
17
- if (event.event === "started")
18
- watching.started?.(String(event.process));
19
- if (event.event === "output")
20
- watching.output?.(event.stream === "err" ? "err" : "out", String(event.text));
21
- if (event.event === "exited")
22
- ended = { code: (event.code ?? null), signal: (event.signal ?? null) };
23
- }, path, signal);
24
- // Closed with no ending said: the system went away while the program
25
- // was running, which is a different thing from the program ending.
26
- if (!ended)
27
- throw new Error("The system closed before the program ended");
28
- return ended;
29
- }
@@ -1,22 +0,0 @@
1
- import { line } from "./style.js";
2
- import { spawn } from "node:child_process";
3
- import commandEnvironment from "./command-environment.js";
4
- /** Run the optional build owned by `start`, `install`, and `pack`, never by the system. */
5
- export default async function build(config, directory) {
6
- const command = config.buildCommand;
7
- if (!command)
8
- return;
9
- line("build", command);
10
- await new Promise(function (resolve, reject) {
11
- const child = spawn(command, { cwd: directory, env: commandEnvironment(directory), shell: true, stdio: "inherit" });
12
- child.once("error", error => reject(new Error(`Build command failed: ${error.message}`)));
13
- child.once("exit", function (code, signal) {
14
- if (signal)
15
- reject(new Error(`Build command ended on ${signal}`));
16
- else if (code !== 0)
17
- reject(new Error(`Build command exited with ${code ?? 0}`));
18
- else
19
- resolve();
20
- });
21
- });
22
- }
@@ -1,161 +0,0 @@
1
- import { spawn } from "node:child_process";
2
- import { connect } from "node:net";
3
- import { line } from "./style.js";
4
- import commandEnvironment from "./command-environment.js";
5
- const timeout = 15_000;
6
- const reportEvery = 2_000;
7
- const pollEvery = 200;
8
- const clientOrigin = "null";
9
- /** Refuse to start an owned command over a URL already served by something else. */
10
- export async function assertClientDevelopmentUrlFree(url) {
11
- if (!await occupied(url))
12
- return;
13
- throw new Error(`Client development URL is already in use: ${url}`);
14
- }
15
- function occupied(url) {
16
- const location = new URL(url);
17
- const port = Number(location.port || (location.protocol === "https:" ? 443 : 80));
18
- return new Promise(resolve => {
19
- const socket = connect({ host: location.hostname, port });
20
- let settled = false;
21
- const settle = (value) => {
22
- if (settled)
23
- return;
24
- settled = true;
25
- socket.destroy();
26
- resolve(value);
27
- };
28
- socket.setTimeout(500);
29
- socket.once("connect", () => settle(true));
30
- socket.once("error", () => settle(false));
31
- socket.once("timeout", () => settle(false));
32
- });
33
- }
34
- /** Start the author's client tool without making it part of ProgramConfig. */
35
- export function startClientDevelopment(command, directory) {
36
- if (!command)
37
- return null;
38
- const child = spawn(command, {
39
- cwd: directory,
40
- env: commandEnvironment(directory),
41
- shell: true,
42
- stdio: "inherit",
43
- // The command and everything it starts are one owned tool. A Vite
44
- // child must not survive after the shell that launched it is gone.
45
- detached: true
46
- });
47
- let result = null;
48
- let stopping = false;
49
- let stoppingTask = null;
50
- let settle = () => undefined;
51
- const exited = new Promise(resolve => { settle = resolve; });
52
- const finish = (exit) => {
53
- if (result)
54
- return;
55
- result = exit;
56
- settle(exit);
57
- };
58
- child.once("error", error => finish({ code: null, signal: null, error }));
59
- child.once("exit", (code, signal) => finish({ code, signal, error: null }));
60
- return {
61
- exited,
62
- get stopping() { return stopping; },
63
- async stop() {
64
- if (!stoppingTask) {
65
- stopping = true;
66
- stoppingTask = (async () => {
67
- if (!running(child))
68
- return;
69
- terminate(child, "SIGTERM");
70
- await waitUntilStopped(child, 1_000);
71
- if (running(child))
72
- terminate(child, "SIGKILL");
73
- await waitUntilStopped(child, 1_000);
74
- })();
75
- }
76
- await stoppingTask;
77
- }
78
- };
79
- }
80
- /** Wait for the exact client URL that the derived Program will open. */
81
- export async function waitForClientDevelopment(url, command, waiting = timeout, reporting = reportEvery) {
82
- const began = Date.now();
83
- let nextReport = began + reporting;
84
- const commandEnded = command?.exited.then(exit => { throw commandFailure(exit); });
85
- while (Date.now() - began < waiting) {
86
- const availability = await inspect(url, waiting - (Date.now() - began));
87
- if (availability === "ready")
88
- return;
89
- if (availability === "cors-blocked") {
90
- throw new Error([
91
- `Client development URL responded, but does not allow the sandboxed Client origin: ${url}`,
92
- `Enable CORS so the response includes Access-Control-Allow-Origin: * (for Vite, use server: { cors: true }).`
93
- ].join("\n"));
94
- }
95
- const now = Date.now();
96
- if (now >= nextReport) {
97
- line("waiting for", url);
98
- while (nextReport <= now)
99
- nextReport += reporting;
100
- }
101
- const remaining = waiting - (Date.now() - began);
102
- if (remaining <= 0)
103
- break;
104
- await Promise.race([
105
- pause(Math.min(pollEvery, remaining)),
106
- ...(commandEnded ? [commandEnded] : [])
107
- ]);
108
- }
109
- const seconds = waiting / 1_000;
110
- throw new Error(`Client development URL did not respond within ${seconds} ${seconds === 1 ? "second" : "seconds"}: ${url}`);
111
- }
112
- export function commandFailure(exit) {
113
- if (exit.error)
114
- return new Error(`Client development command failed: ${exit.error.message}`);
115
- if (exit.signal)
116
- return new Error(`Client development command ended on ${exit.signal}`);
117
- return new Error(`Client development command exited with ${exit.code ?? 0}`);
118
- }
119
- async function inspect(url, remaining) {
120
- try {
121
- const response = await fetch(url, {
122
- headers: { origin: clientOrigin },
123
- signal: AbortSignal.timeout(Math.max(1, Math.min(500, remaining)))
124
- });
125
- const allowedOrigin = response.headers.get("access-control-allow-origin")?.trim();
126
- await response.body?.cancel();
127
- return allowedOrigin === "*" || allowedOrigin === clientOrigin ? "ready" : "cors-blocked";
128
- }
129
- catch {
130
- return "unavailable";
131
- }
132
- }
133
- function terminate(child, signal) {
134
- if (!child.pid)
135
- return;
136
- try {
137
- process.kill(-child.pid, signal);
138
- }
139
- catch {
140
- child.kill(signal);
141
- }
142
- }
143
- function running(child) {
144
- if (!child.pid)
145
- return false;
146
- try {
147
- process.kill(-child.pid, 0);
148
- return true;
149
- }
150
- catch {
151
- return child.exitCode === null && child.signalCode === null;
152
- }
153
- }
154
- async function waitUntilStopped(child, milliseconds) {
155
- const deadline = Date.now() + milliseconds;
156
- while (running(child) && Date.now() < deadline)
157
- await pause(20);
158
- }
159
- function pause(milliseconds) {
160
- return new Promise(resolve => setTimeout(resolve, milliseconds));
161
- }
@@ -1,10 +0,0 @@
1
- import { delimiter, join } from "node:path";
2
- /** Give author commands the same project-local binary resolution as scripts. */
3
- export default function commandEnvironment(directory) {
4
- const key = Object.keys(process.env).find(name => name.toLowerCase() === "path") ?? "PATH";
5
- const inherited = process.env[key];
6
- return {
7
- ...process.env,
8
- [key]: [join(directory, "node_modules", ".bin"), inherited].filter(Boolean).join(delimiter)
9
- };
10
- }
package/dist/derive.js DELETED
@@ -1,98 +0,0 @@
1
- import { resolve } from "node:path";
2
- export default function derive(config, directory, which) {
3
- const server = serverHalf(config.server, which);
4
- const client = clientHalf(config.client, which);
5
- if (which === "development" && !config.server?.development && !config.client?.development) {
6
- // Where a `development` block is learned, now that nothing writes
7
- // one for you. It shows each half's distinct shape because their
8
- // development locations are deliberately not the same concept.
9
- throw new Error([
10
- "Nothing here says how this program is developed.",
11
- "",
12
- "Say how the server runs or where the client is served:",
13
- "",
14
- " server: {",
15
- " …",
16
- " development: { startCommand: \"tsx source/server/main.ts\" }",
17
- " // or: development: { entryFile: \"source/server/main.js\" }",
18
- " }",
19
- "",
20
- " client: {",
21
- " …",
22
- " development: { url: \"http://localhost:5173\", startCommand: \"bun run dev\" }",
23
- " }"
24
- ].join("\n"));
25
- }
26
- return {
27
- identity: config.identity,
28
- name: config.name,
29
- version: config.version,
30
- description: config.description,
31
- categories: config.categories,
32
- keywords: config.keywords,
33
- website: config.website,
34
- // Where it already is. Unlike pack, which gives it its canonical
35
- // name, this points into the authoring tree and leaves it alone.
36
- icon: config.icon && resolve(directory, config.icon),
37
- agent: config.agent && resolve(directory, config.agent),
38
- // Beside the source, and said out loud. A program built from an
39
- // object resolves what it leaves unsaid against the *system's*
40
- // working directory — so silence here means a program keeps what
41
- // it keeps wherever the system happened to be started, which is
42
- // nobody's idea of its own place.
43
- storage: resolve(directory, "storage"),
44
- ...server && { server: {
45
- location: resolve(directory, server.location),
46
- start: server.start,
47
- installCommand: config.server?.installCommand,
48
- uninstallCommand: config.server?.uninstallCommand,
49
- ...serverExecution(server)
50
- } },
51
- // A URL stands as written; a directory is made absolute. The
52
- // contract reads both from the one field, and which it is is
53
- // decided by the same test the system uses.
54
- ...client && { client: {
55
- location: /^https?:\/\//i.test(client.location) ? client.location : resolve(directory, client.location),
56
- start: client.start,
57
- title: config.client?.title,
58
- size: config.client?.size,
59
- position: config.client?.position,
60
- layer: config.client?.layer,
61
- minimize: config.client?.minimize
62
- } }
63
- };
64
- }
65
- // Only the fields each development shape owns are selected. Unknown
66
- // runtime values do not become program fields merely because they were
67
- // present in a JavaScript object.
68
- function serverHalf(half, which) {
69
- if (!half)
70
- return null;
71
- const { development, startCommand, entryFile, ...description } = half;
72
- if (which === "production" || !development)
73
- return {
74
- ...description,
75
- ...serverExecution({ startCommand, entryFile })
76
- };
77
- return {
78
- ...description,
79
- location: ".",
80
- ...serverExecution(development)
81
- };
82
- }
83
- function serverExecution(server) {
84
- return server.startCommand !== undefined
85
- ? { startCommand: server.startCommand }
86
- : { entryFile: server.entryFile };
87
- }
88
- function clientHalf(half, which) {
89
- if (!half)
90
- return null;
91
- const { development, ...declared } = half;
92
- if (which === "production" || !development)
93
- return declared;
94
- return {
95
- ...declared,
96
- location: development.url
97
- };
98
- }