@phreshos/cli 0.1.0
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 +341 -0
- package/dist/attach.js +29 -0
- package/dist/build-command.js +22 -0
- package/dist/cli.js +220 -0
- package/dist/client-development.js +149 -0
- package/dist/command-environment.js +10 -0
- package/dist/derive.js +89 -0
- package/dist/init.js +201 -0
- package/dist/install.js +45 -0
- package/dist/launch.js +111 -0
- package/dist/pack.js +84 -0
- package/dist/program-intake.js +65 -0
- package/dist/project-dependency.js +83 -0
- package/dist/project.js +125 -0
- package/dist/relative-value.js +118 -0
- package/dist/style.js +27 -0
- package/dist/uninstall.js +15 -0
- package/package.json +26 -0
|
@@ -0,0 +1,149 @@
|
|
|
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
|
+
/** Refuse to start an owned command over a URL already served by something else. */
|
|
9
|
+
export async function assertClientDevelopmentUrlFree(url) {
|
|
10
|
+
if (!await occupied(url))
|
|
11
|
+
return;
|
|
12
|
+
throw new Error(`Client development URL is already in use: ${url}`);
|
|
13
|
+
}
|
|
14
|
+
function occupied(url) {
|
|
15
|
+
const location = new URL(url);
|
|
16
|
+
const port = Number(location.port || (location.protocol === "https:" ? 443 : 80));
|
|
17
|
+
return new Promise(resolve => {
|
|
18
|
+
const socket = connect({ host: location.hostname, port });
|
|
19
|
+
let settled = false;
|
|
20
|
+
const settle = (value) => {
|
|
21
|
+
if (settled)
|
|
22
|
+
return;
|
|
23
|
+
settled = true;
|
|
24
|
+
socket.destroy();
|
|
25
|
+
resolve(value);
|
|
26
|
+
};
|
|
27
|
+
socket.setTimeout(500);
|
|
28
|
+
socket.once("connect", () => settle(true));
|
|
29
|
+
socket.once("error", () => settle(false));
|
|
30
|
+
socket.once("timeout", () => settle(false));
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
/** Start the author's client tool without making it part of ProgramConfig. */
|
|
34
|
+
export function startClientDevelopment(command, directory) {
|
|
35
|
+
if (!command)
|
|
36
|
+
return null;
|
|
37
|
+
const child = spawn(command, {
|
|
38
|
+
cwd: directory,
|
|
39
|
+
env: commandEnvironment(directory),
|
|
40
|
+
shell: true,
|
|
41
|
+
stdio: "inherit",
|
|
42
|
+
// The command and everything it starts are one owned tool. A Vite
|
|
43
|
+
// child must not survive after the shell that launched it is gone.
|
|
44
|
+
detached: true
|
|
45
|
+
});
|
|
46
|
+
let result = null;
|
|
47
|
+
let stopping = false;
|
|
48
|
+
let stoppingTask = null;
|
|
49
|
+
let settle = () => undefined;
|
|
50
|
+
const exited = new Promise(resolve => { settle = resolve; });
|
|
51
|
+
const finish = (exit) => {
|
|
52
|
+
if (result)
|
|
53
|
+
return;
|
|
54
|
+
result = exit;
|
|
55
|
+
settle(exit);
|
|
56
|
+
};
|
|
57
|
+
child.once("error", error => finish({ code: null, signal: null, error }));
|
|
58
|
+
child.once("exit", (code, signal) => finish({ code, signal, error: null }));
|
|
59
|
+
return {
|
|
60
|
+
exited,
|
|
61
|
+
get stopping() { return stopping; },
|
|
62
|
+
async stop() {
|
|
63
|
+
if (!stoppingTask) {
|
|
64
|
+
stopping = true;
|
|
65
|
+
stoppingTask = (async () => {
|
|
66
|
+
if (!running(child))
|
|
67
|
+
return;
|
|
68
|
+
terminate(child, "SIGTERM");
|
|
69
|
+
await waitUntilStopped(child, 1_000);
|
|
70
|
+
if (running(child))
|
|
71
|
+
terminate(child, "SIGKILL");
|
|
72
|
+
await waitUntilStopped(child, 1_000);
|
|
73
|
+
})();
|
|
74
|
+
}
|
|
75
|
+
await stoppingTask;
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
/** Wait for the exact client URL that the derived Program will open. */
|
|
80
|
+
export async function waitForClientDevelopment(url, command, waiting = timeout, reporting = reportEvery) {
|
|
81
|
+
const began = Date.now();
|
|
82
|
+
let nextReport = began + reporting;
|
|
83
|
+
const commandEnded = command?.exited.then(exit => { throw commandFailure(exit); });
|
|
84
|
+
while (Date.now() - began < waiting) {
|
|
85
|
+
if (await available(url, waiting - (Date.now() - began)))
|
|
86
|
+
return;
|
|
87
|
+
const now = Date.now();
|
|
88
|
+
if (now >= nextReport) {
|
|
89
|
+
line("waiting for", url);
|
|
90
|
+
while (nextReport <= now)
|
|
91
|
+
nextReport += reporting;
|
|
92
|
+
}
|
|
93
|
+
const remaining = waiting - (Date.now() - began);
|
|
94
|
+
if (remaining <= 0)
|
|
95
|
+
break;
|
|
96
|
+
await Promise.race([
|
|
97
|
+
pause(Math.min(pollEvery, remaining)),
|
|
98
|
+
...(commandEnded ? [commandEnded] : [])
|
|
99
|
+
]);
|
|
100
|
+
}
|
|
101
|
+
const seconds = waiting / 1_000;
|
|
102
|
+
throw new Error(`Client development URL did not respond within ${seconds} ${seconds === 1 ? "second" : "seconds"}: ${url}`);
|
|
103
|
+
}
|
|
104
|
+
export function commandFailure(exit) {
|
|
105
|
+
if (exit.error)
|
|
106
|
+
return new Error(`Client development command failed: ${exit.error.message}`);
|
|
107
|
+
if (exit.signal)
|
|
108
|
+
return new Error(`Client development command ended on ${exit.signal}`);
|
|
109
|
+
return new Error(`Client development command exited with ${exit.code ?? 0}`);
|
|
110
|
+
}
|
|
111
|
+
async function available(url, remaining) {
|
|
112
|
+
try {
|
|
113
|
+
const response = await fetch(url, { signal: AbortSignal.timeout(Math.max(1, Math.min(500, remaining))) });
|
|
114
|
+
await response.body?.cancel();
|
|
115
|
+
return true;
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
function terminate(child, signal) {
|
|
122
|
+
if (!child.pid)
|
|
123
|
+
return;
|
|
124
|
+
try {
|
|
125
|
+
process.kill(-child.pid, signal);
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
child.kill(signal);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
function running(child) {
|
|
132
|
+
if (!child.pid)
|
|
133
|
+
return false;
|
|
134
|
+
try {
|
|
135
|
+
process.kill(-child.pid, 0);
|
|
136
|
+
return true;
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
return child.exitCode === null && child.signalCode === null;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
async function waitUntilStopped(child, milliseconds) {
|
|
143
|
+
const deadline = Date.now() + milliseconds;
|
|
144
|
+
while (running(child) && Date.now() < deadline)
|
|
145
|
+
await pause(20);
|
|
146
|
+
}
|
|
147
|
+
function pause(milliseconds) {
|
|
148
|
+
return new Promise(resolve => setTimeout(resolve, milliseconds));
|
|
149
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
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
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
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
|
+
" }",
|
|
18
|
+
"",
|
|
19
|
+
" client: {",
|
|
20
|
+
" …",
|
|
21
|
+
" development: { url: \"http://localhost:5173\", startCommand: \"bun run dev\" }",
|
|
22
|
+
" }"
|
|
23
|
+
].join("\n"));
|
|
24
|
+
}
|
|
25
|
+
return {
|
|
26
|
+
identity: config.identity,
|
|
27
|
+
name: config.name,
|
|
28
|
+
version: config.version,
|
|
29
|
+
description: config.description,
|
|
30
|
+
// Like icons, the document stays where the author put it for an
|
|
31
|
+
// attached run. Installation and packaging give it its canonical
|
|
32
|
+
// name; the runtime receives an absolute source path here because a
|
|
33
|
+
// derived description has no file beside which to resolve it.
|
|
34
|
+
apiDocs: config.apiDocs && resolve(directory, config.apiDocs),
|
|
35
|
+
// Where they already are. Unlike pack, which stages them, this
|
|
36
|
+
// points into the authoring tree and leaves it alone.
|
|
37
|
+
icons: config.icons && resolve(directory, config.icons),
|
|
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
|
+
startCommand: server.startCommand
|
|
49
|
+
} },
|
|
50
|
+
// A URL stands as written; a directory is made absolute. The
|
|
51
|
+
// contract reads both from the one field, and which it is is
|
|
52
|
+
// decided by the same test the system uses.
|
|
53
|
+
...client && { client: {
|
|
54
|
+
location: /^https?:\/\//i.test(client.location) ? client.location : resolve(directory, client.location),
|
|
55
|
+
start: client.start,
|
|
56
|
+
title: config.client?.title,
|
|
57
|
+
size: config.client?.size,
|
|
58
|
+
position: config.client?.position,
|
|
59
|
+
layer: config.client?.layer,
|
|
60
|
+
minimize: config.client?.minimize
|
|
61
|
+
} }
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
// Only the fields each development shape owns are selected. Unknown
|
|
65
|
+
// runtime values do not become program fields merely because they were
|
|
66
|
+
// present in a JavaScript object.
|
|
67
|
+
function serverHalf(half, which) {
|
|
68
|
+
if (!half)
|
|
69
|
+
return null;
|
|
70
|
+
const { development, ...declared } = half;
|
|
71
|
+
if (which === "production" || !development)
|
|
72
|
+
return declared;
|
|
73
|
+
return {
|
|
74
|
+
...declared,
|
|
75
|
+
location: ".",
|
|
76
|
+
startCommand: development.startCommand
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
function clientHalf(half, which) {
|
|
80
|
+
if (!half)
|
|
81
|
+
return null;
|
|
82
|
+
const { development, ...declared } = half;
|
|
83
|
+
if (which === "production" || !development)
|
|
84
|
+
return declared;
|
|
85
|
+
return {
|
|
86
|
+
...declared,
|
|
87
|
+
location: development.url
|
|
88
|
+
};
|
|
89
|
+
}
|
package/dist/init.js
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import {} from "@phreshos/core";
|
|
2
|
+
import { configFile, readManifest } from "./project.js";
|
|
3
|
+
import { dim, heading, line } from "./style.js";
|
|
4
|
+
import ensureProjectDependency, { projectScript } from "./project-dependency.js";
|
|
5
|
+
import { createInterface } from "node:readline/promises";
|
|
6
|
+
import { existsSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { resolve } from "node:path";
|
|
8
|
+
/**
|
|
9
|
+
* Initialize an existing project as a Program.
|
|
10
|
+
*
|
|
11
|
+
* A real terminal gets a short interview. Everywhere else, every choice is a
|
|
12
|
+
* named option and the command never waits for input. Both routes produce the
|
|
13
|
+
* same config and ensure the matching Core dependency through this one
|
|
14
|
+
* function.
|
|
15
|
+
*/
|
|
16
|
+
export default async function init(options = {}, directory = process.cwd(), coreVersion = "0.1.0") {
|
|
17
|
+
const manifest = await readManifest(directory);
|
|
18
|
+
const path = resolve(directory, configFile);
|
|
19
|
+
const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
20
|
+
const explicitShape = options.server === true || options.client === true;
|
|
21
|
+
if (existsSync(path) && options.force !== true && !interactive)
|
|
22
|
+
throw new Error(`${configFile} already exists — use --force to replace it`);
|
|
23
|
+
const readline = interactive ? createInterface({ input: process.stdin, output: process.stdout }) : null;
|
|
24
|
+
async function ask(question, fallback) {
|
|
25
|
+
if (!readline)
|
|
26
|
+
throw new Error(`${question} Supply the corresponding option when no terminal is attached`);
|
|
27
|
+
const suffix = fallback === undefined ? "" : ` ${dim(`(${fallback})`)}`;
|
|
28
|
+
const said = (await readline.question(` ${question}${suffix} `)).trim();
|
|
29
|
+
return said || fallback || "";
|
|
30
|
+
}
|
|
31
|
+
async function yes(question, fallback) {
|
|
32
|
+
return (await ask(question, fallback ? "Y/n" : "y/N")).toLowerCase().startsWith("y");
|
|
33
|
+
}
|
|
34
|
+
try {
|
|
35
|
+
if (existsSync(path) && options.force !== true) {
|
|
36
|
+
heading(configFile, "already initialized");
|
|
37
|
+
if (!await yes("Replace it?", false)) {
|
|
38
|
+
console.log(`\n ${dim("No changes made.")}\n`);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
heading("Initialize Program", manifest.name);
|
|
43
|
+
line("identity", manifest.name, "package.json");
|
|
44
|
+
if (manifest.version)
|
|
45
|
+
line("version", manifest.version, "package.json");
|
|
46
|
+
if (manifest.description)
|
|
47
|
+
line("description", manifest.description, "package.json");
|
|
48
|
+
console.log("");
|
|
49
|
+
let serverSelected = options.server === true;
|
|
50
|
+
let clientSelected = options.client === true;
|
|
51
|
+
if (!explicitShape) {
|
|
52
|
+
if (!interactive)
|
|
53
|
+
throw new Error("Choose at least one half with --server, --client, or both");
|
|
54
|
+
serverSelected = await yes("Include a server half?", true);
|
|
55
|
+
clientSelected = !serverSelected || await yes("Include a client half?", true);
|
|
56
|
+
}
|
|
57
|
+
if (!serverSelected && !clientSelected)
|
|
58
|
+
throw new Error("A Program must have a server half, a client half, or both");
|
|
59
|
+
const name = options.name ?? (interactive ? await ask("What name should people see?", manifest.name) : undefined);
|
|
60
|
+
if (name !== undefined && name.trim().length === 0)
|
|
61
|
+
throw new Error("--name must not be empty");
|
|
62
|
+
let apiDocs = options.apiDocs;
|
|
63
|
+
if (interactive && apiDocs === undefined) {
|
|
64
|
+
const suggested = ["api-docs.md", "README.md"].find(file => existsSync(resolve(directory, file)));
|
|
65
|
+
if (await yes("Provide an API documentation entry point?", Boolean(suggested)))
|
|
66
|
+
apiDocs = await ask("Where is its Markdown file?", suggested);
|
|
67
|
+
}
|
|
68
|
+
if (apiDocs !== undefined && apiDocs.trim().length === 0)
|
|
69
|
+
throw new Error("An API documentation path must not be empty");
|
|
70
|
+
let buildCommand = options.buildCommand;
|
|
71
|
+
if (interactive && buildCommand === undefined) {
|
|
72
|
+
const suggested = manifest.scripts?.build && projectScript(directory, manifest.packageManager, "build");
|
|
73
|
+
const builds = await yes("Run a command before production start, install, and pack?", Boolean(suggested));
|
|
74
|
+
if (builds)
|
|
75
|
+
buildCommand = await ask("What builds the production files?", suggested || undefined);
|
|
76
|
+
}
|
|
77
|
+
if (buildCommand !== undefined && buildCommand.trim().length === 0)
|
|
78
|
+
throw new Error("A build command must not be empty");
|
|
79
|
+
let server;
|
|
80
|
+
if (serverSelected) {
|
|
81
|
+
const location = options.serverLocation ?? (interactive ? await ask("Where are the production server files?", "build/server") : "");
|
|
82
|
+
const startCommand = options.serverStartCommand ?? (interactive ? await ask("What starts the production server?", "node main.js") : "");
|
|
83
|
+
if (!location)
|
|
84
|
+
throw new Error("--server-location is required without a terminal");
|
|
85
|
+
if (!startCommand)
|
|
86
|
+
throw new Error("--server-start-command is required without a terminal");
|
|
87
|
+
let development = options.serverDevelopmentStartCommand;
|
|
88
|
+
if (interactive && development === undefined) {
|
|
89
|
+
const suggested = manifest.scripts?.dev && projectScript(directory, manifest.packageManager, "dev");
|
|
90
|
+
if (await yes("Develop the server from source?", Boolean(suggested && !clientSelected)))
|
|
91
|
+
development = await ask("What starts the development server?", suggested || undefined);
|
|
92
|
+
}
|
|
93
|
+
if (development !== undefined && development.trim().length === 0)
|
|
94
|
+
throw new Error("A server development command must not be empty");
|
|
95
|
+
server = { location, startCommand, ...development && { development: { startCommand: development } } };
|
|
96
|
+
}
|
|
97
|
+
let client;
|
|
98
|
+
if (clientSelected) {
|
|
99
|
+
const location = options.clientLocation ?? (interactive ? await ask("Where are the production client files?", "dist") : "");
|
|
100
|
+
if (!location)
|
|
101
|
+
throw new Error("--client-location is required without a terminal");
|
|
102
|
+
let developmentUrl = options.clientDevelopmentUrl;
|
|
103
|
+
let developmentStartCommand = options.clientDevelopmentStartCommand;
|
|
104
|
+
if (interactive && developmentUrl === undefined && developmentStartCommand === undefined) {
|
|
105
|
+
const suggested = manifest.scripts?.dev && projectScript(directory, manifest.packageManager, "dev");
|
|
106
|
+
if (await yes("Use a client development server?", Boolean(suggested))) {
|
|
107
|
+
developmentUrl = await ask("Where is the development client served?", "http://localhost:5173/");
|
|
108
|
+
if (await yes("Should the CLI start that development server?", Boolean(suggested)))
|
|
109
|
+
developmentStartCommand = await ask("What starts it?", suggested || undefined);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (developmentStartCommand !== undefined && developmentUrl === undefined)
|
|
113
|
+
throw new Error("--client-development-url is required with --client-development-start-command");
|
|
114
|
+
if (developmentUrl !== undefined && developmentUrl.trim().length === 0)
|
|
115
|
+
throw new Error("A client development URL must not be empty");
|
|
116
|
+
if (developmentUrl !== undefined && !httpUrl(developmentUrl))
|
|
117
|
+
throw new Error("A client development URL must use HTTP or HTTPS");
|
|
118
|
+
if (developmentStartCommand !== undefined && developmentStartCommand.trim().length === 0)
|
|
119
|
+
throw new Error("A client development command must not be empty");
|
|
120
|
+
client = {
|
|
121
|
+
location,
|
|
122
|
+
...developmentUrl && { development: { url: developmentUrl, ...developmentStartCommand && { startCommand: developmentStartCommand } } }
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
const described = {
|
|
126
|
+
identity: manifest.name,
|
|
127
|
+
name,
|
|
128
|
+
version: manifest.version,
|
|
129
|
+
description: manifest.description,
|
|
130
|
+
apiDocs,
|
|
131
|
+
buildCommand
|
|
132
|
+
};
|
|
133
|
+
const config = server
|
|
134
|
+
? { ...described, server, ...client && { client } }
|
|
135
|
+
: { ...described, client: client };
|
|
136
|
+
await ensureProjectDependency("@phreshos/core", coreVersion, directory);
|
|
137
|
+
writeFileSync(path, compose(config));
|
|
138
|
+
heading(configFile, "created");
|
|
139
|
+
if (config.server)
|
|
140
|
+
line("server", config.server.startCommand, `./${config.server.location}`);
|
|
141
|
+
if (config.client)
|
|
142
|
+
line("client", `./${config.client.location}`);
|
|
143
|
+
console.log("");
|
|
144
|
+
const next = [config.server?.development || config.client?.development ? "phresh dev" : null, "phresh start", "phresh install"].filter(Boolean);
|
|
145
|
+
console.log(` ${dim("Next:")} ${next.join(` ${dim("or")} `)}\n`);
|
|
146
|
+
}
|
|
147
|
+
finally {
|
|
148
|
+
readline?.close();
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
function compose(config) {
|
|
152
|
+
const blocks = [
|
|
153
|
+
field("identity", config.identity),
|
|
154
|
+
field("name", config.name),
|
|
155
|
+
field("version", config.version),
|
|
156
|
+
field("description", config.description),
|
|
157
|
+
field("apiDocs", config.apiDocs),
|
|
158
|
+
field("buildCommand", config.buildCommand),
|
|
159
|
+
half("server", config.server && {
|
|
160
|
+
location: config.server.location,
|
|
161
|
+
startCommand: config.server.startCommand,
|
|
162
|
+
...config.server.development && { development: config.server.development }
|
|
163
|
+
}),
|
|
164
|
+
half("client", config.client && {
|
|
165
|
+
location: config.client.location,
|
|
166
|
+
...config.client.development && { development: config.client.development }
|
|
167
|
+
})
|
|
168
|
+
];
|
|
169
|
+
return [
|
|
170
|
+
`import { defineConfig } from "@phreshos/core"`,
|
|
171
|
+
"",
|
|
172
|
+
"export default defineConfig({",
|
|
173
|
+
"",
|
|
174
|
+
blocks.filter(Boolean).join(",\n\n"),
|
|
175
|
+
"})",
|
|
176
|
+
""
|
|
177
|
+
].join("\n");
|
|
178
|
+
}
|
|
179
|
+
function field(name, value) {
|
|
180
|
+
return value === undefined ? "" : ` ${name}: ${JSON.stringify(value)}`;
|
|
181
|
+
}
|
|
182
|
+
function half(name, values) {
|
|
183
|
+
if (!values)
|
|
184
|
+
return "";
|
|
185
|
+
const inside = Object.entries(values).map(function ([key, value]) {
|
|
186
|
+
if (typeof value === "string")
|
|
187
|
+
return ` ${key}: ${JSON.stringify(value)}`;
|
|
188
|
+
const nested = Object.entries(value).map(([nestedKey, nestedValue]) => ` ${nestedKey}: ${JSON.stringify(nestedValue)}`);
|
|
189
|
+
return ` ${key}: {\n\n${nested.join(",\n\n")}\n }`;
|
|
190
|
+
});
|
|
191
|
+
return ` ${name}: {\n\n${inside.join(",\n\n")}\n }`;
|
|
192
|
+
}
|
|
193
|
+
function httpUrl(value) {
|
|
194
|
+
try {
|
|
195
|
+
const url = new URL(value);
|
|
196
|
+
return url.protocol === "http:" || url.protocol === "https:";
|
|
197
|
+
}
|
|
198
|
+
catch {
|
|
199
|
+
return false;
|
|
200
|
+
}
|
|
201
|
+
}
|
package/dist/install.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import derive from "./derive.js";
|
|
2
|
+
import { readConfig } from "./project.js";
|
|
3
|
+
import { dim, heading } from "./style.js";
|
|
4
|
+
import speak from "./program-intake.js";
|
|
5
|
+
import build from "./build-command.js";
|
|
6
|
+
/**
|
|
7
|
+
* Lay this program out on this machine's system.
|
|
8
|
+
*
|
|
9
|
+
* What is sent is the description the config derives — the same one
|
|
10
|
+
* `phresh start` runs from — and the system copies what it names into
|
|
11
|
+
* place. A program's parts are already on this disk at the locations it
|
|
12
|
+
* names, so there is nothing an archive would carry that the description
|
|
13
|
+
* does not already point at.
|
|
14
|
+
*
|
|
15
|
+
* **Installing takes no package, and neither does the system.** A
|
|
16
|
+
* package is a way of *carrying* a program to another machine, which is
|
|
17
|
+
* a different act from laying one out — and installing programs at all
|
|
18
|
+
* is work for a program rather than for the core, so what a person
|
|
19
|
+
* installs *from* is not this command's business either. `phresh pack`
|
|
20
|
+
* makes a package when you have somewhere to send it, and stops there.
|
|
21
|
+
*
|
|
22
|
+
* When the author config declares `buildCommand`, it runs here before the
|
|
23
|
+
* production Program is derived and sent. The command remains authoring
|
|
24
|
+
* metadata and never becomes part of the installed Program.
|
|
25
|
+
*
|
|
26
|
+
* Installing is not running. A program laid out here stays until
|
|
27
|
+
* something removes it, and `phresh start` and `phresh dev` are for the
|
|
28
|
+
* other way a program is used — attached to the terminal that began it,
|
|
29
|
+
* and gone when that ends.
|
|
30
|
+
*/
|
|
31
|
+
export default async function install(directory = process.cwd()) {
|
|
32
|
+
const config = await readConfig(directory);
|
|
33
|
+
await build(config, directory);
|
|
34
|
+
const program = derive(config, directory, "production");
|
|
35
|
+
await speak({ word: "install", program }, function (event) {
|
|
36
|
+
if (event.event !== "installed")
|
|
37
|
+
return;
|
|
38
|
+
const said = event.program;
|
|
39
|
+
heading(`${said.name ?? String(said.identity)}${said.version ? ` ${said.version}` : ""}`, event.replaced ? "reinstalled" : "installed");
|
|
40
|
+
// What it kept. Every process ended before the installed paths
|
|
41
|
+
// changed, while storage stayed in its canonical place.
|
|
42
|
+
if (event.replaced)
|
|
43
|
+
console.log(` ${dim("its storage was kept, and its previous processes were ended")}\n`);
|
|
44
|
+
});
|
|
45
|
+
}
|
package/dist/launch.js
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import derive, {} from "./derive.js";
|
|
2
|
+
import { readConfig } from "./project.js";
|
|
3
|
+
import { dim, heading, line } from "./style.js";
|
|
4
|
+
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);
|
|
52
|
+
if (program.server)
|
|
53
|
+
line("server", String(program.server.startCommand), place(directory, program.server.location));
|
|
54
|
+
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)));
|
|
57
|
+
console.log("");
|
|
58
|
+
if (clientStartCommand && program.client)
|
|
59
|
+
await assertClientDevelopmentUrlFree(program.client.location);
|
|
60
|
+
const clientDevelopment = clientDevelopmentConfig
|
|
61
|
+
? startClientDevelopment(clientStartCommand, directory)
|
|
62
|
+
: null;
|
|
63
|
+
const controller = new AbortController();
|
|
64
|
+
let signalled = false;
|
|
65
|
+
const stopOnSignal = () => {
|
|
66
|
+
if (signalled)
|
|
67
|
+
return;
|
|
68
|
+
signalled = true;
|
|
69
|
+
controller.abort();
|
|
70
|
+
void (clientDevelopment?.stop() ?? Promise.resolve()).finally(() => process.exit(130));
|
|
71
|
+
};
|
|
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
|
+
for (const signal of ["SIGINT", "SIGTERM"])
|
|
76
|
+
process.on(signal, stopOnSignal);
|
|
77
|
+
let status = 0;
|
|
78
|
+
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(` ${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;
|
|
98
|
+
}
|
|
99
|
+
finally {
|
|
100
|
+
for (const signal of ["SIGINT", "SIGTERM"])
|
|
101
|
+
process.off(signal, stopOnSignal);
|
|
102
|
+
await clientDevelopment?.stop();
|
|
103
|
+
}
|
|
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);
|
|
107
|
+
}
|
|
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)}`;
|
|
111
|
+
}
|