@phreshos/node 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 CHANGED
@@ -49,6 +49,11 @@ await system.disconnect()
49
49
  definitions, installation, execution shortcuts, and packaging. The System
50
50
  receives only the resulting Program definition.
51
51
 
52
+ `Project.dev()` starts and supervises a declared Client development command. It
53
+ assigns an available port when no URL is declared and provides the port and
54
+ public Program asset base through `PHRESHOS_CLIENT_PORT` and
55
+ `PHRESHOS_CLIENT_BASE`.
56
+
52
57
  `Project.open()` starts from the current working directory by default.
53
58
  `System.connect()` resolves an explicit System home, then `PHRESHOS_HOME`,
54
59
  then the current owner's default System home.
@@ -0,0 +1,20 @@
1
+ import type { ClientDevelopment, ProgramProcessRunEvent } from "@phreshos/core";
2
+ /** One prepared Client development source owned by a Project development run. */
3
+ export default class DevelopmentClient {
4
+ readonly url: string;
5
+ private readonly startCommand;
6
+ private readonly directory;
7
+ private command;
8
+ private controller;
9
+ private releaseSignal;
10
+ private constructor();
11
+ /** Select the development address without starting the authored command. */
12
+ static prepare(development: ClientDevelopment, directory: string): Promise<DevelopmentClient>;
13
+ /** Start and verify the Client beneath its System-created asset address. */
14
+ start(assetId: string, signal?: AbortSignal): Promise<void>;
15
+ processSignal(fallback?: AbortSignal): AbortSignal | undefined;
16
+ supervise(lifecycle: AsyncGenerator<ProgramProcessRunEvent, void, void>): AsyncGenerator<ProgramProcessRunEvent, void, void>;
17
+ dispose(reason: unknown): Promise<void>;
18
+ private waitUntilReady;
19
+ private supervisedLifecycle;
20
+ }
@@ -0,0 +1,259 @@
1
+ import { spawn } from "node:child_process";
2
+ import { connect, createServer } from "node:net";
3
+ import { delimiter, join } from "node:path";
4
+ const readinessTimeout = 15_000;
5
+ const pollingInterval = 200;
6
+ /** One prepared Client development source owned by a Project development run. */
7
+ export default class DevelopmentClient {
8
+ url;
9
+ startCommand;
10
+ directory;
11
+ command = null;
12
+ controller = null;
13
+ releaseSignal = () => undefined;
14
+ constructor(url, startCommand, directory) {
15
+ this.url = url;
16
+ this.startCommand = startCommand;
17
+ this.directory = directory;
18
+ }
19
+ /** Select the development address without starting the authored command. */
20
+ static async prepare(development, directory) {
21
+ const url = development.url ?? `http://localhost:${await availablePort()}/`;
22
+ if (development.startCommand)
23
+ await assertAvailable(url);
24
+ return new DevelopmentClient(url, development.startCommand ?? null, directory);
25
+ }
26
+ /** Start and verify the Client beneath its System-created asset address. */
27
+ async start(assetId, signal) {
28
+ if (this.controller)
29
+ throw new Error("The Client development source has already started");
30
+ const base = `/program/${assetId}/assets/`;
31
+ const controller = new AbortController();
32
+ this.controller = controller;
33
+ this.releaseSignal = forwardAbort(signal, controller);
34
+ try {
35
+ if (this.startCommand) {
36
+ this.command = new OwnedCommand(this.startCommand, this.directory, {
37
+ PHRESHOS_CLIENT_BASE: base,
38
+ PHRESHOS_CLIENT_PORT: String(portOf(this.url))
39
+ });
40
+ }
41
+ await this.waitUntilReady(new URL(base, this.url).href);
42
+ }
43
+ catch (error) {
44
+ await this.dispose(error);
45
+ throw error;
46
+ }
47
+ }
48
+ processSignal(fallback) {
49
+ return this.command ? this.controller?.signal : fallback;
50
+ }
51
+ supervise(lifecycle) {
52
+ if (!this.command) {
53
+ this.releaseSignal();
54
+ return lifecycle;
55
+ }
56
+ return this.supervisedLifecycle(lifecycle);
57
+ }
58
+ async dispose(reason) {
59
+ this.releaseSignal();
60
+ this.controller?.abort(reason);
61
+ await this.command?.stop();
62
+ }
63
+ async waitUntilReady(url) {
64
+ const deadline = Date.now() + readinessTimeout;
65
+ while (Date.now() < deadline) {
66
+ this.controller.signal.throwIfAborted();
67
+ const exit = this.command?.exitResult();
68
+ if (exit)
69
+ throw commandFailure(exit);
70
+ try {
71
+ const response = await fetch(url, {
72
+ signal: AbortSignal.timeout(Math.max(1, Math.min(500, deadline - Date.now())))
73
+ });
74
+ await response.body?.cancel();
75
+ if (response.ok)
76
+ return;
77
+ }
78
+ catch { /* The development server is still starting. */ }
79
+ await pause(Math.min(pollingInterval, Math.max(0, deadline - Date.now())), this.controller.signal);
80
+ }
81
+ throw new Error(`Client development URL did not respond within 15 seconds: ${url}`);
82
+ }
83
+ async *supervisedLifecycle(lifecycle) {
84
+ const iterator = lifecycle[Symbol.asyncIterator]();
85
+ const command = this.command;
86
+ try {
87
+ while (true) {
88
+ const next = iterator.next();
89
+ const outcome = await Promise.race([
90
+ next.then(result => ({ source: "system", result }), error => ({ source: "system-error", error })),
91
+ command.exited().then(result => ({ source: "client", result }))
92
+ ]);
93
+ if (outcome.source === "client") {
94
+ const error = commandFailure(outcome.result);
95
+ this.controller?.abort(error);
96
+ await next.catch(() => undefined);
97
+ throw error;
98
+ }
99
+ if (outcome.source === "system-error")
100
+ throw outcome.error;
101
+ if (outcome.result.done)
102
+ return;
103
+ yield outcome.result.value;
104
+ }
105
+ }
106
+ finally {
107
+ await this.dispose(new Error("The development lifecycle ended"));
108
+ await iterator.return?.();
109
+ }
110
+ }
111
+ }
112
+ /** One operating-system command supervised as a complete process tree. */
113
+ class OwnedCommand {
114
+ child;
115
+ completion;
116
+ result = null;
117
+ stopped = false;
118
+ constructor(command, directory, environment) {
119
+ this.child = spawn(command, {
120
+ cwd: directory,
121
+ env: commandEnvironment(directory, environment),
122
+ shell: true,
123
+ stdio: ["ignore", "pipe", "pipe"],
124
+ detached: true
125
+ });
126
+ this.child.stdout?.pipe(process.stdout, { end: false });
127
+ this.child.stderr?.pipe(process.stderr, { end: false });
128
+ this.completion = new Promise(resolve => {
129
+ const finish = (exit) => {
130
+ if (this.result)
131
+ return;
132
+ this.result = exit;
133
+ resolve(exit);
134
+ };
135
+ this.child.once("error", error => finish({ code: null, signal: null, error }));
136
+ this.child.once("exit", (code, signal) => finish({ code, signal, error: null }));
137
+ });
138
+ }
139
+ exited() { return this.completion; }
140
+ exitResult() { return this.result; }
141
+ async stop() {
142
+ if (this.stopped)
143
+ return;
144
+ this.stopped = true;
145
+ if (!running(this.child))
146
+ return;
147
+ terminate(this.child, "SIGTERM");
148
+ await waitUntilStopped(this.child, 1_000);
149
+ if (running(this.child))
150
+ terminate(this.child, "SIGKILL");
151
+ await waitUntilStopped(this.child, 1_000);
152
+ }
153
+ }
154
+ async function availablePort() {
155
+ const server = createServer();
156
+ return await new Promise((done, fail) => {
157
+ server.once("error", fail);
158
+ server.listen(0, "localhost", () => {
159
+ const address = server.address();
160
+ if (!address || typeof address === "string") {
161
+ server.close();
162
+ fail(new Error("An available Client development port could not be selected"));
163
+ return;
164
+ }
165
+ server.close(error => error ? fail(error) : done(address.port));
166
+ });
167
+ });
168
+ }
169
+ async function assertAvailable(url) {
170
+ const location = new URL(url);
171
+ const occupied = await new Promise(done => {
172
+ const socket = connect({ host: location.hostname, port: portOf(url) });
173
+ let finished = false;
174
+ const finish = (value) => {
175
+ if (finished)
176
+ return;
177
+ finished = true;
178
+ socket.destroy();
179
+ done(value);
180
+ };
181
+ socket.setTimeout(500);
182
+ socket.once("connect", () => finish(true));
183
+ socket.once("error", () => finish(false));
184
+ socket.once("timeout", () => finish(false));
185
+ });
186
+ if (occupied)
187
+ throw new Error(`Client development URL is already in use: ${url}`);
188
+ }
189
+ function portOf(url) {
190
+ const location = new URL(url);
191
+ return Number(location.port || (location.protocol === "https:" ? 443 : 80));
192
+ }
193
+ function commandEnvironment(directory, additions) {
194
+ const key = Object.keys(process.env).find(name => name.toLowerCase() === "path") ?? "PATH";
195
+ const inherited = process.env[key];
196
+ return { ...process.env, ...additions, [key]: [join(directory, "node_modules", ".bin"), inherited].filter(Boolean).join(delimiter) };
197
+ }
198
+ function forwardAbort(source, target) {
199
+ if (!source)
200
+ return () => undefined;
201
+ const abort = () => target.abort(source.reason);
202
+ if (source.aborted)
203
+ abort();
204
+ else
205
+ source.addEventListener("abort", abort, { once: true });
206
+ return () => source.removeEventListener("abort", abort);
207
+ }
208
+ function commandFailure(exit) {
209
+ if (exit.error)
210
+ return new Error(`Client development command failed: ${exit.error.message}`);
211
+ if (exit.signal)
212
+ return new Error(`Client development command ended on ${exit.signal}`);
213
+ return new Error(`Client development command exited with ${exit.code ?? 0}`);
214
+ }
215
+ function terminate(child, signal) {
216
+ if (!child.pid)
217
+ return;
218
+ try {
219
+ process.kill(-child.pid, signal);
220
+ }
221
+ catch {
222
+ child.kill(signal);
223
+ }
224
+ }
225
+ function running(child) {
226
+ if (!child.pid)
227
+ return false;
228
+ try {
229
+ process.kill(-child.pid, 0);
230
+ return true;
231
+ }
232
+ catch {
233
+ return child.exitCode === null && child.signalCode === null;
234
+ }
235
+ }
236
+ async function waitUntilStopped(child, milliseconds) {
237
+ const deadline = Date.now() + milliseconds;
238
+ while (running(child) && Date.now() < deadline)
239
+ await new Promise(resolve => setTimeout(resolve, 20));
240
+ }
241
+ function pause(milliseconds, signal) {
242
+ return new Promise((done, fail) => {
243
+ if (signal.aborted) {
244
+ fail(signal.reason);
245
+ return;
246
+ }
247
+ const timeout = setTimeout(finish, milliseconds);
248
+ const abort = () => finish(signal.reason);
249
+ signal.addEventListener("abort", abort, { once: true });
250
+ function finish(error) {
251
+ clearTimeout(timeout);
252
+ signal.removeEventListener("abort", abort);
253
+ if (error !== undefined)
254
+ fail(error);
255
+ else
256
+ done();
257
+ }
258
+ });
259
+ }
package/dist/main.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export { gatewayAddress } from "./address.js";
2
- export { Client, ClientService, Endpoint, Process, Program, Server, ServerService, System, type ProgramProcessRunEvent, type ProgramProcessRunOptions } from "./system.js";
3
- export { Service, type ClientLaunch, type Launch, type ProgramDefinition, type ServerLaunch, type ServiceKey, } from "@phreshos/core";
2
+ export { ClientEndpoint, ClientService, Endpoint, Process, Program, ServerEndpoint, ServerService, System, type ProgramProcessRunEvent, type ProgramProcessRunOptions } from "./system.js";
3
+ export { Service, type ClientLaunch, type Launch, type Permission, type PermissionChange, type PermissionInput, type Permissions, type ProgramPermissions, type ProgramDefinition, type ServerLaunch, type ServiceKey, type ShellEvent, type ShellOptions, type ProgramStartup, type Storage, } from "@phreshos/core";
4
4
  export { resolveHome } from "./home.js";
5
5
  export { Project, type Manifest, type PackedProject, type ProjectMode, type ProjectOptions, type ProjectRunOptions } from "./project.js";
package/dist/main.js CHANGED
@@ -1,5 +1,5 @@
1
1
  export { gatewayAddress } from "./address.js";
2
- export { Client, ClientService, Endpoint, Process, Program, Server, ServerService, System } from "./system.js";
2
+ export { ClientEndpoint, ClientService, Endpoint, Process, Program, ServerEndpoint, ServerService, System } from "./system.js";
3
3
  export { Service, } from "@phreshos/core";
4
4
  export { resolveHome } from "./home.js";
5
5
  export { Project } from "./project.js";
@@ -0,0 +1,10 @@
1
+ import type { ChildProcess } from "node:child_process";
2
+ /** The complete operating-system process tree beneath one shell command. */
3
+ export default class ProcessTree {
4
+ private readonly child;
5
+ private readonly ended;
6
+ private forcing;
7
+ constructor(child: ChildProcess, ended: (code: number | null, signal: NodeJS.Signals | null) => void | Promise<void>);
8
+ stop(): void;
9
+ private finish;
10
+ }
@@ -0,0 +1,67 @@
1
+ const terminationGrace = 1_000;
2
+ /** The complete operating-system process tree beneath one shell command. */
3
+ export default class ProcessTree {
4
+ child;
5
+ ended;
6
+ forcing = null;
7
+ constructor(child, ended) {
8
+ this.child = child;
9
+ this.ended = ended;
10
+ child.on("exit", (code, signal) => { this.finish(code, signal).catch(() => undefined); });
11
+ }
12
+ stop() {
13
+ signalTree(this.child, "SIGTERM");
14
+ if (this.forcing)
15
+ return;
16
+ this.forcing = setTimeout(() => signalTree(this.child, "SIGKILL"), terminationGrace);
17
+ this.forcing.unref();
18
+ }
19
+ async finish(code, signal) {
20
+ if (this.forcing)
21
+ clearTimeout(this.forcing);
22
+ this.forcing = null;
23
+ await finishTree(this.child);
24
+ await this.ended(code, signal);
25
+ }
26
+ }
27
+ function signalTree(child, signal) {
28
+ if (!child.pid)
29
+ return;
30
+ try {
31
+ process.kill(-child.pid, signal);
32
+ }
33
+ catch (error) {
34
+ if (error.code === "ESRCH")
35
+ return;
36
+ if (child.exitCode === null && child.signalCode === null)
37
+ child.kill(signal);
38
+ }
39
+ }
40
+ async function finishTree(child) {
41
+ const pid = child.pid;
42
+ if (!pid || !treeExists(pid))
43
+ return;
44
+ signalTree(child, "SIGTERM");
45
+ if (await waitForTree(pid))
46
+ return;
47
+ signalTree(child, "SIGKILL");
48
+ await waitForTree(pid);
49
+ }
50
+ async function waitForTree(pid) {
51
+ const began = Date.now();
52
+ while (treeExists(pid)) {
53
+ if (Date.now() - began >= terminationGrace)
54
+ return false;
55
+ await new Promise(resolve => setTimeout(resolve, 20));
56
+ }
57
+ return true;
58
+ }
59
+ function treeExists(pid) {
60
+ try {
61
+ process.kill(-pid, 0);
62
+ return true;
63
+ }
64
+ catch (error) {
65
+ return error.code !== "ESRCH";
66
+ }
67
+ }
@@ -1,7 +1,13 @@
1
- import type { ProgramSql, ProgramStore } from "@phreshos/core";
1
+ import { type ProgramPermissions, type ProgramSql, type ProgramStore } from "@phreshos/core";
2
2
  type Request = (value: object) => Promise<unknown>;
3
+ type ProgramAddress = Readonly<{
4
+ identity: string;
5
+ reference: string;
6
+ }>;
3
7
  /** Program-owned key-value storage carried through the owner-local Gateway. */
4
- export declare function programStore(request: Request, program: string): ProgramStore;
8
+ export declare function programStore(request: Request, handle: ProgramAddress): ProgramStore;
5
9
  /** Program-owned SQL capability carried through the owner-local Gateway. */
6
- export declare function programSql(request: Request, program: string, database: "database" | "logs"): ProgramSql;
10
+ export declare function programSql(request: Request, handle: ProgramAddress, database: "database" | "logs"): ProgramSql;
11
+ /** Program permission management carried through the owner-local Gateway. */
12
+ export declare function programPermissions(request: Request, handle: ProgramAddress): ProgramPermissions;
7
13
  export {};
@@ -1,9 +1,10 @@
1
+ import { parsePermission, parsePermissionChange, parsePermissions } from "@phreshos/core";
1
2
  /** Program-owned key-value storage carried through the owner-local Gateway. */
2
- export function programStore(request, program) {
3
+ export function programStore(request, handle) {
3
4
  const operate = (storeOperation, key, value, ttl) => request({
4
5
  capability: "program",
5
6
  operation: "store",
6
- program,
7
+ handle,
7
8
  storeOperation,
8
9
  key,
9
10
  value,
@@ -18,14 +19,31 @@ export function programStore(request, program) {
18
19
  };
19
20
  }
20
21
  /** Program-owned SQL capability carried through the owner-local Gateway. */
21
- export function programSql(request, program, database) {
22
+ export function programSql(request, handle, database) {
22
23
  return {
23
24
  query(statement, ...rest) {
24
25
  const [text, values] = written(statement, rest);
25
- return request({ capability: "program", operation: "query", program, database, statement: text, values });
26
+ return request({ capability: "program", operation: "query", handle, database, statement: text, values });
26
27
  }
27
28
  };
28
29
  }
30
+ /** Program permission management carried through the owner-local Gateway. */
31
+ export function programPermissions(request, handle) {
32
+ const operate = (permissionOperation, name, permission) => request({
33
+ capability: "program",
34
+ operation: "permissions",
35
+ handle,
36
+ permissionOperation,
37
+ name,
38
+ permission
39
+ });
40
+ return {
41
+ async get(name) { return parsePermission(await operate("get", name)); },
42
+ async all() { return parsePermissions(await operate("all")); },
43
+ async set(name, permission) { return parsePermissionChange(await operate("set", name, permission)); },
44
+ async delete(name) { return parsePermissionChange(await operate("delete", name)); }
45
+ };
46
+ }
29
47
  function written(statement, rest) {
30
48
  if (typeof statement === "string")
31
49
  return [statement, Array.isArray(rest[0]) ? rest[0] : []];
package/dist/project.d.ts CHANGED
@@ -18,9 +18,11 @@ export declare class Project {
18
18
  /** Run the optional author-owned production build command. */
19
19
  build(): Promise<void>;
20
20
  /** Build this Project and return its production Process lifecycle generator. */
21
- start(system: SystemContract, options?: ProjectRunOptions): Promise<AsyncGenerator<import("@phreshos/core").SystemProcessRunEvent, void, void>>;
22
- /** Return this Project's development Process lifecycle generator. */
23
- dev(system: SystemContract, options?: ProjectRunOptions): Promise<AsyncGenerator<import("@phreshos/core").SystemProcessRunEvent, void, void>>;
21
+ start(system: SystemContract, options?: ProjectRunOptions): Promise<AsyncGenerator<import("@phreshos/core").ProgramProcessRunEvent, void, void>>;
22
+ /** Prepare this Project's Client development source and return its Process lifecycle. */
23
+ dev(system: SystemContract, options?: ProjectRunOptions): Promise<AsyncGenerator<import("@phreshos/core").ProgramProcessRunEvent, void, void>>;
24
+ private developmentRun;
25
+ private prepareDevelopment;
24
26
  /** Build this Project and return its Program installation generator. */
25
27
  install(system: SystemContract): Promise<AsyncGenerator<Readonly<{
26
28
  stream: "stdout" | "stderr";
package/dist/project.js CHANGED
@@ -6,7 +6,9 @@ import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs";
6
6
  import { readFile } from "node:fs/promises";
7
7
  import { delimiter, isAbsolute, join, normalize, resolve, sep } from "node:path";
8
8
  import { createJiti } from "jiti";
9
+ import DevelopmentClient from "./development-client.js";
9
10
  const configFile = "phresh.config.ts";
11
+ const defaultClientPort = 5200;
10
12
  /** One loaded Program authoring project rooted at an absolute directory. */
11
13
  export class Project {
12
14
  directory;
@@ -52,10 +54,10 @@ export class Project {
52
54
  developmentDefinition() {
53
55
  return this.definition("development");
54
56
  }
55
- definition(mode) {
57
+ definition(mode, developmentClientUrl) {
56
58
  const config = this.config;
57
59
  const server = serverHalf(config.server, mode);
58
- const client = clientHalf(config.client, mode);
60
+ const client = clientHalf(config.client, mode, developmentClientUrl);
59
61
  if (mode === "development" && !config.server?.development && !config.client?.development) {
60
62
  throw new Error("Nothing here says how this Program is developed");
61
63
  }
@@ -83,7 +85,8 @@ export class Project {
83
85
  size: config.client?.size,
84
86
  position: config.client?.position,
85
87
  layer: config.client?.layer,
86
- minimize: config.client?.minimize
88
+ minimize: config.client?.minimize,
89
+ permissions: config.client?.permissions
87
90
  } }
88
91
  };
89
92
  }
@@ -115,9 +118,34 @@ export class Project {
115
118
  await this.build();
116
119
  return await this.run(system, this.productionDefinition(), options);
117
120
  }
118
- /** Return this Project's development Process lifecycle generator. */
121
+ /** Prepare this Project's Client development source and return its Process lifecycle. */
119
122
  async dev(system, options = {}) {
120
- return await this.run(system, this.developmentDefinition(), options);
123
+ const development = this.config.client?.development;
124
+ const startsClient = Boolean(development && (this.config.client?.start ?? true));
125
+ if (!startsClient || !development)
126
+ return await this.run(system, this.developmentDefinition(), options);
127
+ if (development.startCommand)
128
+ return this.developmentRun(system, development, options);
129
+ const prepared = await this.prepareDevelopment(system, development, options);
130
+ return prepared.client.supervise(prepared.lifecycle);
131
+ }
132
+ async *developmentRun(system, development, options) {
133
+ const prepared = await this.prepareDevelopment(system, development, options);
134
+ yield* prepared.client.supervise(prepared.lifecycle);
135
+ }
136
+ async prepareDevelopment(system, development, options) {
137
+ const client = await DevelopmentClient.prepare(development, this.directory);
138
+ const program = await system.forceCreateProgram(this.definition("development", client.url));
139
+ try {
140
+ await client.start(program.assetId, options.signal);
141
+ const lifecycle = program.process.run({ options: options.options ?? {} }, { signal: client.processSignal(options.signal) });
142
+ return { client, lifecycle };
143
+ }
144
+ catch (error) {
145
+ await client.dispose(error);
146
+ await program.forget().catch(() => undefined);
147
+ throw error;
148
+ }
121
149
  }
122
150
  /** Build this Project and return its Program installation generator. */
123
151
  async install(system) {
@@ -171,18 +199,20 @@ function serverHalf(half, mode) {
171
199
  function serverExecution(server) {
172
200
  return server.startCommand !== undefined ? { startCommand: server.startCommand } : { entryFile: server.entryFile };
173
201
  }
174
- function clientHalf(half, mode) {
202
+ function clientHalf(half, mode, developmentUrl) {
175
203
  if (!half)
176
204
  return null;
177
205
  const { development, ...declared } = half;
178
- return mode === "development" && development ? { ...declared, location: development.url } : declared;
206
+ return mode === "development" && development
207
+ ? { ...declared, location: developmentUrl ?? development.url ?? `http://localhost:${defaultClientPort}/` }
208
+ : declared;
179
209
  }
180
210
  function validateConfig(config) {
181
211
  if (typeof config.identity !== "string" || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(config.identity)) {
182
212
  throw new Error("A Program's identity must be kebab-case");
183
213
  }
184
214
  if (!config.server && !config.client)
185
- throw new Error("A Program must have a Server half, a Client half, or both");
215
+ throw new Error("A Program must have a Server Endpoint, a Client Endpoint, or both");
186
216
  for (const field of ["name", "version", "description", "icon", "agent", "website"]) {
187
217
  if (config[field] !== undefined && typeof config[field] !== "string")
188
218
  throw new Error(`A Program's ${field} must be text`);
@@ -212,14 +242,23 @@ function validateConfig(config) {
212
242
  throw new Error("A Program's default Process must start a Server Endpoint, a Client Endpoint, or both");
213
243
  }
214
244
  if (config.server)
215
- execution(config.server, "A Server half");
245
+ execution(config.server, "A Server Endpoint");
216
246
  if (config.server?.development)
217
247
  execution(config.server.development, "server.development");
218
248
  if (config.client?.layer !== undefined && !layers.includes(config.client.layer)) {
219
- throw new Error(`A Client half's layer must be one of ${layers.join(", ")}`);
249
+ throw new Error(`A Client Endpoint's layer must be one of ${layers.join(", ")}`);
220
250
  }
221
- if (config.client?.development && !httpUrl(config.client.development.url)) {
222
- throw new Error("client.development.url must be a valid HTTP or HTTPS URL");
251
+ if (config.client?.development) {
252
+ const development = config.client.development;
253
+ if (development.url !== undefined && !httpUrl(development.url)) {
254
+ throw new Error("client.development.url must be a valid HTTP or HTTPS URL");
255
+ }
256
+ if (development.startCommand !== undefined && (typeof development.startCommand !== "string" || !development.startCommand.trim())) {
257
+ throw new Error("client.development.startCommand must be non-empty text");
258
+ }
259
+ if (development.url === undefined && development.startCommand === undefined) {
260
+ throw new Error("client.development must declare a URL or start command");
261
+ }
223
262
  }
224
263
  for (const [name, value] of [["size", config.client?.size], ["position", config.client?.position]]) {
225
264
  if (value === undefined)
@@ -286,7 +325,8 @@ function packageDefinition(config, version) {
286
325
  size: config.client.size,
287
326
  position: config.client.position,
288
327
  layer: config.client.layer,
289
- minimize: config.client.minimize
328
+ minimize: config.client.minimize,
329
+ permissions: config.client.permissions
290
330
  } }
291
331
  };
292
332
  }
@@ -0,0 +1,3 @@
1
+ import type { ShellEvent, ShellOptions } from "@phreshos/core";
2
+ /** Execute one command locally while its iterator owns the complete process tree. */
3
+ export default function shell(command: string, options?: ShellOptions): AsyncGenerator<ShellEvent, void, void>;