@phreshos/cli 0.1.45 → 0.1.46

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
@@ -31,8 +31,8 @@ phresh pack
31
31
  ```
32
32
 
33
33
  `create` produces the official starter Program. The remaining commands operate
34
- on the current Program project, derive its concrete definition, and delegate
35
- runtime operations to the connected System.
34
+ on the current Program project through the Node Project contract and present
35
+ its build, development, installation, and runtime lifecycles.
36
36
 
37
37
  ## System
38
38
 
@@ -89,9 +89,10 @@ for the command model.
89
89
 
90
90
  ## Repository boundary
91
91
 
92
- This repository owns terminal interaction, project commands, packaging, System
93
- acquisition, and host service integration. Node owns the external JavaScript
94
- interface, Core owns shared contracts, and the System owns authoritative state.
92
+ This repository owns terminal interaction, command routing, System acquisition,
93
+ and host service integration. Node owns the external JavaScript interface and
94
+ Project workflows, Core owns shared contracts, and the System owns authoritative
95
+ state.
95
96
 
96
97
  See [CONTRIBUTING.md](CONTRIBUTING.md) for the repository workflow and
97
98
  [SECURITY.md](SECURITY.md) for private vulnerability reporting.
package/dist/cli.js CHANGED
@@ -61,7 +61,7 @@ describe(program.command("init")
61
61
  .option("--server-development-entry-file <path>", "development Server worker entry")
62
62
  .option("--client", "include a Client endpoint")
63
63
  .option("--client-location <path>", "production Client directory")
64
- .option("--client-development-url <url>", "development Client URL")
64
+ .option("--client-development-url <url>", "fixed or external development Client URL")
65
65
  .option("--client-development-start-command <command>", "development Client command")
66
66
  .option("--force", "replace an existing phresh.config.ts")
67
67
  .action(async function (options) {
@@ -74,7 +74,10 @@ describe(program.command("init")
74
74
  serverEntryFile: options.serverEntryFile,
75
75
  serverDevelopmentStartCommand: options.serverDevelopmentStartCommand,
76
76
  serverDevelopmentEntryFile: options.serverDevelopmentEntryFile,
77
- client: options.client === true || options.clientLocation !== undefined,
77
+ client: options.client === true
78
+ || options.clientLocation !== undefined
79
+ || options.clientDevelopmentUrl !== undefined
80
+ || options.clientDevelopmentStartCommand !== undefined,
78
81
  clientLocation: options.clientLocation,
79
82
  clientDevelopmentUrl: options.clientDevelopmentUrl,
80
83
  clientDevelopmentStartCommand: options.clientDevelopmentStartCommand,
@@ -30,6 +30,7 @@ export function endpoint(process, name) {
30
30
  export async function programView(program) {
31
31
  return {
32
32
  identity: program.identity,
33
+ assetId: program.assetId,
33
34
  name: program.name,
34
35
  version: program.version,
35
36
  description: program.description,
package/dist/init.js CHANGED
@@ -83,24 +83,28 @@ export default async function init(options = {}, directory = process.cwd(), core
83
83
  if (interactive && developmentUrl === undefined && developmentStartCommand === undefined) {
84
84
  const suggested = manifest.scripts?.dev && projectScript(directory, manifest.packageManager, "dev");
85
85
  if (await yes("A development server can provide live updates instead of built Client files.", "Use a Client development server?", Boolean(suggested))) {
86
- developmentUrl = await ask("The desktop opens this exact HTTP or HTTPS address during phresh dev.", "What URL serves the development Client?", "http://localhost:5173/");
87
- if (await yes("The CLI can own the development server and stop it when the session ends.", "Should phresh dev start the Client server?", Boolean(suggested))) {
86
+ if (await yes("The Project can own the development server and stop it when the session ends.", "Should phresh dev start the Client server?", Boolean(suggested))) {
88
87
  developmentStartCommand = await ask("This command remains attached to the phresh dev session.", "What command starts the Client development server?", suggested || undefined);
89
88
  }
89
+ else
90
+ developmentUrl = await ask("The System routes the Program asset path to this HTTP or HTTPS address during phresh dev.", "What URL serves the external development Client?", "http://localhost:5173/");
90
91
  }
91
92
  }
92
- if (developmentStartCommand !== undefined && developmentUrl === undefined)
93
- throw new Error("--client-development-url is required with --client-development-start-command");
94
93
  if (developmentUrl !== undefined && developmentUrl.trim().length === 0)
95
94
  throw new Error("A client development URL must not be empty");
96
95
  if (developmentUrl !== undefined && !httpUrl(developmentUrl))
97
96
  throw new Error("A client development URL must use HTTP or HTTPS");
98
97
  if (developmentStartCommand !== undefined && developmentStartCommand.trim().length === 0)
99
98
  throw new Error("A client development command must not be empty");
100
- client = {
101
- location,
102
- ...developmentUrl && { development: { url: developmentUrl, ...developmentStartCommand && { startCommand: developmentStartCommand } } }
103
- };
99
+ let development;
100
+ if (developmentStartCommand !== undefined)
101
+ development = {
102
+ startCommand: developmentStartCommand,
103
+ ...developmentUrl !== undefined && { url: developmentUrl }
104
+ };
105
+ else if (developmentUrl !== undefined)
106
+ development = { url: developmentUrl };
107
+ client = { location, ...development && { development } };
104
108
  }
105
109
  const described = {
106
110
  identity: manifest.name,
@@ -124,8 +128,6 @@ export default async function init(options = {}, directory = process.cwd(), core
124
128
  if (config.client) {
125
129
  interaction.message();
126
130
  interaction.detail("base URL", "./", "required for production assets");
127
- if (config.client.development)
128
- interaction.detail("CORS", "enabled", "required on the development server");
129
131
  }
130
132
  interaction.finish(`${configFile} created`);
131
133
  }
package/dist/launch.js CHANGED
@@ -1,6 +1,5 @@
1
1
  import { Project } from "@phreshos/node";
2
2
  import { relative } from "node:path";
3
- import { assertAvailable, commandFailure, DevelopmentClient, waitForDevelopmentClient } from "./development-client.js";
4
3
  import { blank, dim, heading, line } from "./style.js";
5
4
  /** Run the current project through one connected System. */
6
5
  export default async function launch(mode, directory = process.cwd(), options = {}) {
@@ -19,14 +18,9 @@ export default async function launch(mode, directory = process.cwd(), options =
19
18
  blank();
20
19
  const system = await (await import("@phreshos/node")).System.connect();
21
20
  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;
27
21
  let interrupted = false;
28
22
  let ended = null;
29
- let program = null;
23
+ let attached = false;
30
24
  const stop = () => {
31
25
  if (interrupted)
32
26
  return;
@@ -36,18 +30,11 @@ export default async function launch(mode, directory = process.cwd(), options =
36
30
  for (const signal of ["SIGINT", "SIGTERM"])
37
31
  process.on(signal, stop);
38
32
  try {
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);
33
+ const lifecycle = mode === "development"
34
+ ? await project.dev(system, { options, signal: controller.signal })
35
+ : await project.start(system, { options, signal: controller.signal });
36
+ attached = true;
37
+ ended = await consume(lifecycle);
51
38
  }
52
39
  catch (error) {
53
40
  if (!interrupted)
@@ -55,16 +42,25 @@ export default async function launch(mode, directory = process.cwd(), options =
55
42
  }
56
43
  finally {
57
44
  controller.abort(new Error("The local Program run ended"));
58
- const cleanup = await Promise.allSettled([
59
- client?.stop(),
60
- program ? forget(program) : undefined
61
- ]);
45
+ let cleanupFailure;
46
+ try {
47
+ const program = attached ? await system.program.find(definition.identity) : null;
48
+ if (program)
49
+ await forget(program);
50
+ }
51
+ catch (error) {
52
+ cleanupFailure = error;
53
+ }
54
+ try {
55
+ await system.disconnect();
56
+ }
57
+ catch (error) {
58
+ cleanupFailure ??= error;
59
+ }
62
60
  for (const signal of ["SIGINT", "SIGTERM"])
63
61
  process.off(signal, stop);
64
- await system.disconnect();
65
- const failure = cleanup.find(result => result.status === "rejected");
66
- if (failure?.status === "rejected")
67
- throw failure.reason;
62
+ if (cleanupFailure)
63
+ throw cleanupFailure;
68
64
  }
69
65
  if (interrupted)
70
66
  process.exit(130);
@@ -75,34 +71,10 @@ export default async function launch(mode, directory = process.cwd(), options =
75
71
  blank();
76
72
  process.exit(ended.signal ? 128 : ended.code ?? 0);
77
73
  }
78
- async function consume(lifecycle, client) {
79
- const iterator = lifecycle[Symbol.asyncIterator]();
80
- let next = iterator.next();
81
- let developmentExit = client?.exited();
82
- let developmentOutput = client?.outputAvailable();
74
+ async function consume(lifecycle) {
83
75
  let process = null;
84
76
  let ending = null;
85
- while (true) {
86
- for (const event of client?.drain() ?? [])
87
- presentDevelopment(event);
88
- const outcome = await Promise.race([
89
- next.then(result => ({ source: "system", result })),
90
- ...(developmentExit ? [developmentExit.then(result => ({ source: "client", result }))] : []),
91
- ...(developmentOutput ? [developmentOutput.then(() => ({ source: "output" }))] : [])
92
- ]);
93
- if (outcome.source === "output") {
94
- developmentOutput = client?.outputAvailable();
95
- continue;
96
- }
97
- if (outcome.source === "client") {
98
- developmentExit = undefined;
99
- if (!client?.endingWasRequested())
100
- throw commandFailure(outcome.result);
101
- continue;
102
- }
103
- if (outcome.result.done)
104
- break;
105
- const event = outcome.result.value;
77
+ for await (const event of lifecycle) {
106
78
  if (event.event === "started") {
107
79
  process = event.process.identity;
108
80
  line("process", process);
@@ -111,16 +83,11 @@ async function consume(lifecycle, client) {
111
83
  write(event.stream, event.text);
112
84
  else
113
85
  ending = { code: event.exit.code, signal: event.exit.signal };
114
- next = iterator.next();
115
86
  }
116
87
  if (!process || !ending)
117
88
  throw new Error("The System ended the Program run without a complete Process lifecycle");
118
89
  return { process, ...ending };
119
90
  }
120
- function presentDevelopment(event) {
121
- if (event.event === "output")
122
- write(event.stream === "err" ? "stderr" : "stdout", event.text);
123
- }
124
91
  function write(stream, text) {
125
92
  (stream === "stderr" ? process.stderr : process.stdout).write(text);
126
93
  }
@@ -7,7 +7,7 @@ import nodeExecutable from "./node.js";
7
7
  import installProgram from "../install.js";
8
8
  import phreshosHome from "../home.js";
9
9
  import { existsSync } from "node:fs";
10
- import { mkdir, rm, writeFile } from "node:fs/promises";
10
+ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
11
11
  import { homedir } from "node:os";
12
12
  import { dirname, join } from "node:path";
13
13
  /** Coordinates acquisition, immutable files, and the native service as one transaction. */
@@ -115,9 +115,10 @@ export default class SystemLifecycle {
115
115
  const { installation, service } = this.dependencies;
116
116
  const [installed, state] = await Promise.all([installation.current(), service.inspect()]);
117
117
  const ready = state.running && await this.dependencies.ready(installation.paths.gateway);
118
+ const desktop = await desktopOrigin(installation.paths.storage);
118
119
  return {
119
120
  ...(installed ? { installed } : {}),
120
- desktop: "http://localhost:4300",
121
+ desktop,
121
122
  ...state,
122
123
  ready,
123
124
  root: installation.paths.root,
@@ -185,17 +186,20 @@ export default class SystemLifecycle {
185
186
  }
186
187
  async launchService() {
187
188
  const { installation, service } = this.dependencies;
188
- const { homeRequest, transientHome } = installation.paths;
189
- await rm(homeRequest, { force: true });
190
- if (transientHome) {
189
+ const { homeRequest, portRequest, transientHome, transientPorts } = installation.paths;
190
+ await Promise.all([rm(homeRequest, { force: true }), rm(portRequest, { force: true })]);
191
+ if (transientHome || transientPorts !== undefined) {
191
192
  await mkdir(dirname(homeRequest), { recursive: true });
192
- await writeFile(homeRequest, transientHome, { mode: 0o600 });
193
+ await Promise.all([
194
+ ...transientHome ? [writeFile(homeRequest, transientHome, { mode: 0o600 })] : [],
195
+ ...transientPorts === undefined ? [] : [writeFile(portRequest, transientPorts, { mode: 0o600 })]
196
+ ]);
193
197
  }
194
198
  try {
195
199
  await service.start();
196
200
  }
197
201
  catch (error) {
198
- await rm(homeRequest, { force: true });
202
+ await Promise.all([rm(homeRequest, { force: true }), rm(portRequest, { force: true })]);
199
203
  throw error;
200
204
  }
201
205
  }
@@ -211,9 +215,22 @@ function definition(installation, executable) {
211
215
  "--default-home",
212
216
  phreshosHome({}, homedir()),
213
217
  "--home-request",
214
- installation.paths.homeRequest
218
+ installation.paths.homeRequest,
219
+ "--port-request",
220
+ installation.paths.portRequest
215
221
  ],
216
222
  directory: installation.paths.current,
217
223
  output: installation.paths.log
218
224
  };
219
225
  }
226
+ async function desktopOrigin(storage) {
227
+ try {
228
+ const value = (await readFile(join(storage, "desktop"), "utf8")).trim();
229
+ return /^http:\/\/localhost:\d+$/.test(value) ? value : "http://localhost:4300";
230
+ }
231
+ catch (error) {
232
+ if (error.code === "ENOENT")
233
+ return "http://localhost:4300";
234
+ throw error;
235
+ }
236
+ }
@@ -19,7 +19,9 @@ export default function systemPaths(platform = process.platform, userHome = home
19
19
  storage,
20
20
  gateway: gatewayPath(storage, platform),
21
21
  homeRequest: join(root, "next-home"),
22
+ portRequest: join(root, "next-port"),
22
23
  ...(variables.PHRESHOS_HOME === undefined ? {} : { transientHome: storage }),
24
+ ...(variables.PHRESHOS_PORT === undefined ? {} : { transientPorts: variables.PHRESHOS_PORT }),
23
25
  log: join(storage, "service.log")
24
26
  };
25
27
  }
@@ -22,7 +22,7 @@
22
22
  "react-dom": "^19.2.8"
23
23
  },
24
24
  "devDependencies": {
25
- "@phreshos/cli": "^0.1.45",
25
+ "@phreshos/cli": "^0.1.46",
26
26
  "@types/node": "^26.4.0",
27
27
  "@types/react": "^19.2.18",
28
28
  "@types/react-dom": "^19.2.5",
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@phreshos/cli",
3
3
  "type": "module",
4
- "version": "0.1.45",
4
+ "version": "0.1.46",
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.36",
53
- "@phreshos/node": "^0.1.12",
52
+ "@phreshos/core": "^0.1.37",
53
+ "@phreshos/node": "^0.1.13",
54
54
  "adm-zip": "^0.6.0",
55
55
  "commander": "^15.0.0",
56
56
  "picocolors": "^1.1.1"
@@ -1,197 +0,0 @@
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
- }