@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
package/dist/pack.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { readConfig, readManifest } from "./project.js";
|
|
2
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import AdmZip from "adm-zip";
|
|
5
|
+
import build from "./build-command.js";
|
|
6
|
+
/**
|
|
7
|
+
* Package what is at each half's location.
|
|
8
|
+
*
|
|
9
|
+
* When the author config declares `buildCommand`, it completes before
|
|
10
|
+
* any files are gathered. Pack remains an assembler rather than a
|
|
11
|
+
* build tool: it invokes the author's command, then packages its result.
|
|
12
|
+
*
|
|
13
|
+
* The authoring tree and the installable form differ. A program may
|
|
14
|
+
* leave its halves anywhere; the package always has them in the same
|
|
15
|
+
* places, so the artifact's shape is the contract's rather than the
|
|
16
|
+
* author's. The program.json names those canonical places explicitly:
|
|
17
|
+
* a declared half always has a location, even when the system chose it.
|
|
18
|
+
*
|
|
19
|
+
* There is **no wrapping directory**. `program.json`, `server/`,
|
|
20
|
+
* `client/` and `icons/` sit at the package's root, and the directory
|
|
21
|
+
* the system installs into is named from the program's own `identity`.
|
|
22
|
+
* The archive itself therefore needs no second naming layer.
|
|
23
|
+
*
|
|
24
|
+
* The files go into the archive directly rather than through a staging
|
|
25
|
+
* directory: a copy of a build is a thing that can be stale, and there
|
|
26
|
+
* is nothing a staging directory was doing that the archive does not.
|
|
27
|
+
*/
|
|
28
|
+
export default async function pack(directory = process.cwd()) {
|
|
29
|
+
const config = await readConfig(directory);
|
|
30
|
+
await build(config, directory);
|
|
31
|
+
const manifest = await readManifest(directory);
|
|
32
|
+
const version = config.version ?? manifest.version;
|
|
33
|
+
if (config.version && manifest.version && config.version !== manifest.version) {
|
|
34
|
+
console.log(`phresh: packing ${config.version}, though package.json says ${manifest.version}\n`);
|
|
35
|
+
}
|
|
36
|
+
const zip = new AdmZip();
|
|
37
|
+
if (config.server)
|
|
38
|
+
place(zip, directory, config.server.location, "server");
|
|
39
|
+
if (config.client) {
|
|
40
|
+
place(zip, directory, config.client.location, "client");
|
|
41
|
+
if (!zip.getEntry("client/index.html"))
|
|
42
|
+
throw new Error(`The client files have no index.html — ${config.client.location} is not where a client half is`);
|
|
43
|
+
}
|
|
44
|
+
// Icons are the program's own affair rather than either half's: a
|
|
45
|
+
// headless program is shown too. Packaged like a half, and like a
|
|
46
|
+
// half their place is not the program's to choose.
|
|
47
|
+
if (config.icons)
|
|
48
|
+
place(zip, directory, config.icons, "icons");
|
|
49
|
+
if (config.apiDocs)
|
|
50
|
+
document(zip, directory, config.apiDocs);
|
|
51
|
+
zip.addFile("program.json", Buffer.from(JSON.stringify(program(config, version), null, 4) + "\n"));
|
|
52
|
+
const archive = `${config.identity}@${version ?? "0.0.0"}.zip`;
|
|
53
|
+
zip.writeZip(resolve(directory, archive));
|
|
54
|
+
console.log(`\nPacked ${archive}`);
|
|
55
|
+
return archive;
|
|
56
|
+
}
|
|
57
|
+
// The derivation, and the whole of it. What the config says about the
|
|
58
|
+
// program passes through; what it says about the build does not. Half
|
|
59
|
+
// locations are rewritten to the canonical places inside the package.
|
|
60
|
+
function program(config, version) {
|
|
61
|
+
return {
|
|
62
|
+
identity: config.identity,
|
|
63
|
+
name: config.name,
|
|
64
|
+
version,
|
|
65
|
+
description: config.description,
|
|
66
|
+
apiDocs: config.apiDocs ? "api-docs.md" : undefined,
|
|
67
|
+
...config.server && { server: { location: "server", start: config.server.start, installCommand: config.server.installCommand, startCommand: config.server.startCommand } },
|
|
68
|
+
...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 } }
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
// What is at a half's location, where the package keeps it. A half that
|
|
72
|
+
// was declared and is not there is the answer to whether it was built.
|
|
73
|
+
function place(zip, directory, location, half) {
|
|
74
|
+
const from = resolve(directory, location);
|
|
75
|
+
if (!existsSync(from))
|
|
76
|
+
throw new Error(`The ${half} files are not at ${location} — nothing was built there`);
|
|
77
|
+
zip.addLocalFolder(from, half);
|
|
78
|
+
}
|
|
79
|
+
function document(zip, directory, location) {
|
|
80
|
+
const from = resolve(directory, location);
|
|
81
|
+
if (!existsSync(from) || !statSync(from).isFile())
|
|
82
|
+
throw new Error(`The API documentation file is not at ${location}`);
|
|
83
|
+
zip.addFile("api-docs.md", readFileSync(from));
|
|
84
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { connect } from "node:net";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
/**
|
|
5
|
+
* The local Program intake, from the CLI's side.
|
|
6
|
+
*
|
|
7
|
+
* A socket file rather than a port, because the file's permissions are
|
|
8
|
+
* the authorization: only the account that owns this machine can open
|
|
9
|
+
* it, and that account is exactly who may run and install programs on
|
|
10
|
+
* it. Nothing is sent to prove anything, because being able to connect
|
|
11
|
+
* is the proof.
|
|
12
|
+
*
|
|
13
|
+
* A message is a line. Closing our own side to mark the end of a
|
|
14
|
+
* question would make lifetime ambiguous. A line delimiter keeps the
|
|
15
|
+
* connection available for the stream of events that follows a launch.
|
|
16
|
+
*
|
|
17
|
+
* One question, then events until the system closes. Installing and
|
|
18
|
+
* uninstalling say one thing and end; running says one thing, then whatever
|
|
19
|
+
* the Program says, then how it ended. None pretends to be a remote method
|
|
20
|
+
* returning through an unrelated transport.
|
|
21
|
+
*/
|
|
22
|
+
export const socketPath = join(homedir(), ".phreshos", "intake.sock");
|
|
23
|
+
export default function speak(question, heard, path = socketPath, signal) {
|
|
24
|
+
return new Promise(function (settle, refuse) {
|
|
25
|
+
const socket = connect(path);
|
|
26
|
+
let said = "";
|
|
27
|
+
let failed = null;
|
|
28
|
+
let aborted = false;
|
|
29
|
+
const abort = () => {
|
|
30
|
+
aborted = true;
|
|
31
|
+
socket.destroy();
|
|
32
|
+
};
|
|
33
|
+
if (signal?.aborted)
|
|
34
|
+
abort();
|
|
35
|
+
else
|
|
36
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
37
|
+
socket.on("connect", () => socket.write(JSON.stringify(question) + "\n"));
|
|
38
|
+
socket.on("data", function (chunk) {
|
|
39
|
+
said += String(chunk);
|
|
40
|
+
const lines = said.split("\n");
|
|
41
|
+
said = lines.pop() ?? "";
|
|
42
|
+
for (const line of lines)
|
|
43
|
+
if (line.trim()) {
|
|
44
|
+
const event = JSON.parse(line);
|
|
45
|
+
if (event.event === "error")
|
|
46
|
+
failed = new Error(String(event.message));
|
|
47
|
+
else
|
|
48
|
+
heard(event);
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
// The system is not running, or is running as somebody else. Said
|
|
52
|
+
// plainly, because "ENOENT" is not what went wrong from here.
|
|
53
|
+
socket.on("error", () => { if (!aborted)
|
|
54
|
+
refuse(new Error(`No system is listening at ${path} — start one, or check that it is yours`)); });
|
|
55
|
+
socket.on("close", () => {
|
|
56
|
+
signal?.removeEventListener("abort", abort);
|
|
57
|
+
if (aborted)
|
|
58
|
+
refuse(new Error("The attached launch was stopped"));
|
|
59
|
+
else if (failed)
|
|
60
|
+
refuse(failed);
|
|
61
|
+
else
|
|
62
|
+
settle();
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { line } from "./style.js";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
4
|
+
import { resolve } from "node:path";
|
|
5
|
+
/** Returns the package-manager command that runs one project script. */
|
|
6
|
+
export function projectScript(directory, declared, script) {
|
|
7
|
+
return `${packageManager(directory, declared).name} run ${script}`;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Ensures one development dependency using the source appropriate to this CLI.
|
|
11
|
+
*
|
|
12
|
+
* A CLI running from this repository uses its sibling dev-kit, so Program
|
|
13
|
+
* authoring can be exercised before anything is published. An installed CLI
|
|
14
|
+
* has no such sibling and names the matching registry version instead. This
|
|
15
|
+
* decision belongs here once for both `init` and the future `create` command.
|
|
16
|
+
*/
|
|
17
|
+
export default async function ensureProjectDependency(name, version, directory = process.cwd()) {
|
|
18
|
+
const manifestPath = resolve(directory, "package.json");
|
|
19
|
+
const manifest = JSON.parse(readFileSync(manifestPath, "utf-8"));
|
|
20
|
+
if (manifest.dependencies?.[name] || manifest.devDependencies?.[name])
|
|
21
|
+
return;
|
|
22
|
+
const manager = packageManager(directory, manifest.packageManager);
|
|
23
|
+
const source = packageSource(name, version);
|
|
24
|
+
line("dependency", name, `${manager.name}, ${source.local ? "local dev-kit" : `^${version}`}`);
|
|
25
|
+
await new Promise(function (settle, refuse) {
|
|
26
|
+
const child = spawn(manager.name, [...manager.args, source.specifier], {
|
|
27
|
+
cwd: directory,
|
|
28
|
+
stdio: "inherit",
|
|
29
|
+
shell: process.platform === "win32"
|
|
30
|
+
});
|
|
31
|
+
child.once("error", error => refuse(new Error(`Could not run ${manager.name}: ${error.message}`)));
|
|
32
|
+
child.once("exit", function (code, signal) {
|
|
33
|
+
if (signal)
|
|
34
|
+
refuse(new Error(`${manager.name} ended on ${signal}`));
|
|
35
|
+
else if (code !== 0)
|
|
36
|
+
refuse(new Error(`${manager.name} exited with ${code ?? 0}`));
|
|
37
|
+
else
|
|
38
|
+
settle();
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
console.log("");
|
|
42
|
+
}
|
|
43
|
+
function packageSource(name, version) {
|
|
44
|
+
const directory = resolve(import.meta.dirname, "..", "..", localDirectories[name]);
|
|
45
|
+
const manifest = manifestAt(directory);
|
|
46
|
+
if (manifest?.name === name && manifest.version === version)
|
|
47
|
+
return { specifier: directory, local: true };
|
|
48
|
+
return { specifier: `${name}@^${version}`, local: false };
|
|
49
|
+
}
|
|
50
|
+
function manifestAt(directory) {
|
|
51
|
+
const path = resolve(directory, "package.json");
|
|
52
|
+
if (!existsSync(path))
|
|
53
|
+
return null;
|
|
54
|
+
try {
|
|
55
|
+
return JSON.parse(readFileSync(path, "utf-8"));
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
function packageManager(directory, declared) {
|
|
62
|
+
const named = declared?.split("@")[0];
|
|
63
|
+
if (named === "bun")
|
|
64
|
+
return { name: "bun", args: ["add", "--dev"] };
|
|
65
|
+
if (named === "pnpm")
|
|
66
|
+
return { name: "pnpm", args: ["add", "--save-dev"] };
|
|
67
|
+
if (named === "yarn")
|
|
68
|
+
return { name: "yarn", args: ["add", "--dev"] };
|
|
69
|
+
if (named === "npm")
|
|
70
|
+
return { name: "npm", args: ["install", "--save-dev", "--no-fund", "--no-audit"] };
|
|
71
|
+
if (existsSync(resolve(directory, "bun.lock")) || existsSync(resolve(directory, "bun.lockb")))
|
|
72
|
+
return { name: "bun", args: ["add", "--dev"] };
|
|
73
|
+
if (existsSync(resolve(directory, "pnpm-lock.yaml")))
|
|
74
|
+
return { name: "pnpm", args: ["add", "--save-dev"] };
|
|
75
|
+
if (existsSync(resolve(directory, "yarn.lock")))
|
|
76
|
+
return { name: "yarn", args: ["add", "--dev"] };
|
|
77
|
+
return { name: "npm", args: ["install", "--save-dev", "--no-fund", "--no-audit"] };
|
|
78
|
+
}
|
|
79
|
+
const localDirectories = {
|
|
80
|
+
"@phreshos/core": "core-sdk",
|
|
81
|
+
"@phreshos/client": "client-sdk",
|
|
82
|
+
"@phreshos/server": "server-sdk"
|
|
83
|
+
};
|
package/dist/project.js
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
|
|
2
|
+
if (typeof path === "string" && /^\.\.?\//.test(path)) {
|
|
3
|
+
return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
|
|
4
|
+
return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
|
|
5
|
+
});
|
|
6
|
+
}
|
|
7
|
+
return path;
|
|
8
|
+
};
|
|
9
|
+
import { layers } from "@phreshos/core";
|
|
10
|
+
import { existsSync } from "node:fs";
|
|
11
|
+
import { resolve } from "node:path";
|
|
12
|
+
import { pathToFileURL } from "node:url";
|
|
13
|
+
import { isRelativeValue } from "./relative-value.js";
|
|
14
|
+
export const configFile = "phresh.config.ts";
|
|
15
|
+
export async function readConfig(directory = process.cwd()) {
|
|
16
|
+
const path = resolve(directory, configFile);
|
|
17
|
+
if (!existsSync(path))
|
|
18
|
+
throw new Error(`There is no ${configFile} here — run: phresh init`);
|
|
19
|
+
// The supported Node runtime reads erasable TypeScript directly. Keeping
|
|
20
|
+
// this file typed gives an author editor assistance without another config
|
|
21
|
+
// format or a build step.
|
|
22
|
+
const loaded = await import(__rewriteRelativeImportExtension(pathToFileURL(path).href)).catch(function (error) {
|
|
23
|
+
throw new Error(`${configFile} could not be read (${error.message})`);
|
|
24
|
+
});
|
|
25
|
+
const config = loaded.default;
|
|
26
|
+
if (!config)
|
|
27
|
+
throw new Error(`${configFile} must export its config as the default export`);
|
|
28
|
+
coherent(config);
|
|
29
|
+
return config;
|
|
30
|
+
}
|
|
31
|
+
// Whether the words agree with each other. Nothing here touches a disk
|
|
32
|
+
// or asks whether a build ran: it is the same question the system asks
|
|
33
|
+
// of a program.json when it is constructed, asked earlier, where the
|
|
34
|
+
// person who mistyped it is still standing.
|
|
35
|
+
function coherent(config) {
|
|
36
|
+
// A name is the name of a directory before it is anything else, and
|
|
37
|
+
// the system checks it again where it makes one. Checked here too so
|
|
38
|
+
// the mistake is found by the CLI rather than at the runtime border.
|
|
39
|
+
if (typeof config.identity !== "string" || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(config.identity))
|
|
40
|
+
throw new Error("A program's identity is kebab-case, because it is also the name of its directory");
|
|
41
|
+
if (!config.server && !config.client)
|
|
42
|
+
throw new Error("A program must have a server half, a client half, or both");
|
|
43
|
+
for (const field of ["name", "version", "description", "icons"]) {
|
|
44
|
+
if (config[field] !== undefined && typeof config[field] !== "string")
|
|
45
|
+
throw new Error(`A program's ${field} must be text`);
|
|
46
|
+
}
|
|
47
|
+
if (config.apiDocs !== undefined && (typeof config.apiDocs !== "string" || config.apiDocs.trim().length === 0))
|
|
48
|
+
throw new Error("A program's apiDocs must be a non-empty path");
|
|
49
|
+
if (config.buildCommand !== undefined && (typeof config.buildCommand !== "string" || config.buildCommand.trim().length === 0))
|
|
50
|
+
throw new Error("A program's buildCommand must be non-empty text");
|
|
51
|
+
for (const half of ["server", "client"]) {
|
|
52
|
+
const declared = config[half];
|
|
53
|
+
if (declared === undefined)
|
|
54
|
+
continue;
|
|
55
|
+
if (typeof declared !== "object" || declared === null || Array.isArray(declared))
|
|
56
|
+
throw new Error(`A program's ${half} half must be a declaration`);
|
|
57
|
+
if (typeof declared.location !== "string")
|
|
58
|
+
throw new Error(`A declared ${half} half must have a location`);
|
|
59
|
+
if (declared.start !== undefined && typeof declared.start !== "boolean")
|
|
60
|
+
throw new Error(`A declared ${half} endpoint's start default must be true or false`);
|
|
61
|
+
}
|
|
62
|
+
if (!(config.server && (config.server.start ?? true)) && !(config.client && (config.client.start ?? true)))
|
|
63
|
+
throw new Error("A Program's default Process must start a server endpoint, a client endpoint, or both");
|
|
64
|
+
if (config.server && (typeof config.server.startCommand !== "string" || config.server.startCommand.length === 0))
|
|
65
|
+
throw new Error("A server half must say what starts it");
|
|
66
|
+
if (config.server?.installCommand !== undefined && typeof config.server.installCommand !== "string")
|
|
67
|
+
throw new Error("A server half's install command must be text");
|
|
68
|
+
if (config.client?.title !== undefined && typeof config.client.title !== "string")
|
|
69
|
+
throw new Error("A client half's title must be text");
|
|
70
|
+
if (config.client?.minimize !== undefined && typeof config.client.minimize !== "boolean")
|
|
71
|
+
throw new Error("A client half's minimize default must be true or false");
|
|
72
|
+
if (config.client && /^https?:\/\//i.test(config.client.location)) {
|
|
73
|
+
try {
|
|
74
|
+
new URL(config.client.location);
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
throw new Error("A client half's URL must be a valid HTTP or HTTPS URL");
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
// Said wrong is caught here, where the author is standing, rather
|
|
81
|
+
// than becoming a window in no layer at all.
|
|
82
|
+
if (config.client?.layer !== undefined && !layers.includes(config.client.layer))
|
|
83
|
+
throw new Error(`A client half's layer is one of ${layers.join(", ")} — not "${String(config.client.layer)}"`);
|
|
84
|
+
const serverDevelopment = config.server?.development;
|
|
85
|
+
if (serverDevelopment !== undefined && (typeof serverDevelopment !== "object" || serverDevelopment === null || Array.isArray(serverDevelopment)))
|
|
86
|
+
throw new Error("server.development must be a declaration");
|
|
87
|
+
if (serverDevelopment !== undefined && (typeof serverDevelopment.startCommand !== "string" || serverDevelopment.startCommand.trim().length === 0))
|
|
88
|
+
throw new Error("server.development.startCommand must be non-empty text");
|
|
89
|
+
const clientDevelopment = config.client?.development;
|
|
90
|
+
if (clientDevelopment !== undefined && (typeof clientDevelopment !== "object" || clientDevelopment === null || Array.isArray(clientDevelopment)))
|
|
91
|
+
throw new Error("client.development must be a declaration");
|
|
92
|
+
if (clientDevelopment !== undefined && !httpUrl(clientDevelopment.url))
|
|
93
|
+
throw new Error("client.development.url must be a valid HTTP or HTTPS URL");
|
|
94
|
+
if (clientDevelopment?.startCommand !== undefined && (typeof clientDevelopment.startCommand !== "string" || clientDevelopment.startCommand.trim().length === 0))
|
|
95
|
+
throw new Error("client.development.startCommand must be non-empty text");
|
|
96
|
+
for (const [what, value] of [["size", config.client?.size], ["position", config.client?.position]]) {
|
|
97
|
+
if (value === undefined)
|
|
98
|
+
continue;
|
|
99
|
+
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
100
|
+
throw new Error(`A window's ${what} must name both of its values`);
|
|
101
|
+
const pair = what === "size" ? [value.width, value.height] : [value.x, value.y];
|
|
102
|
+
if (!pair.every(isRelativeValue))
|
|
103
|
+
throw new Error(`A window's ${what} is a finite pixel number or a relative expression such as "50% + 10"`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
function httpUrl(value) {
|
|
107
|
+
if (typeof value !== "string" || value.trim().length === 0)
|
|
108
|
+
return false;
|
|
109
|
+
try {
|
|
110
|
+
const url = new URL(value);
|
|
111
|
+
return url.protocol === "http:" || url.protocol === "https:";
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
export async function readManifest(directory = process.cwd()) {
|
|
118
|
+
const path = resolve(directory, "package.json");
|
|
119
|
+
if (!existsSync(path))
|
|
120
|
+
throw new Error("There is no package.json here");
|
|
121
|
+
const manifest = JSON.parse(await import("node:fs/promises").then(fs => fs.readFile(path, "utf-8")));
|
|
122
|
+
if (typeof manifest.name !== "string" || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(manifest.name))
|
|
123
|
+
throw new Error("package.json must have a kebab-case name");
|
|
124
|
+
return manifest;
|
|
125
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reduces a strict linear expression into `span * relative + pixels`.
|
|
3
|
+
*
|
|
4
|
+
* Numbers are absolute values. Percentages and `n/d` fractions are relative
|
|
5
|
+
* values. Addition and subtraction combine terms; multiplication and division
|
|
6
|
+
* scale the preceding term by a number. No arbitrary code or CSS is accepted.
|
|
7
|
+
*/
|
|
8
|
+
export function parseRelativeValue(input) {
|
|
9
|
+
if (typeof input === "number")
|
|
10
|
+
return Number.isFinite(input) ? { relative: 0, pixels: clean(input) } : null;
|
|
11
|
+
if (typeof input !== "string" || input.trim().length === 0)
|
|
12
|
+
return null;
|
|
13
|
+
return new Reader(input).parse();
|
|
14
|
+
}
|
|
15
|
+
/** Returns whether a value belongs to the linear relative-value grammar. */
|
|
16
|
+
export function isRelativeValue(value) {
|
|
17
|
+
return parseRelativeValue(value) !== null;
|
|
18
|
+
}
|
|
19
|
+
class Reader {
|
|
20
|
+
position = 0;
|
|
21
|
+
source;
|
|
22
|
+
constructor(source) {
|
|
23
|
+
this.source = source;
|
|
24
|
+
}
|
|
25
|
+
parse() {
|
|
26
|
+
const value = { relative: 0, pixels: 0 };
|
|
27
|
+
let direction = this.sign();
|
|
28
|
+
while (this.position < this.source.length) {
|
|
29
|
+
const term = this.term();
|
|
30
|
+
if (!term)
|
|
31
|
+
return null;
|
|
32
|
+
value.relative += term.relative * direction;
|
|
33
|
+
value.pixels += term.pixels * direction;
|
|
34
|
+
if (!finite(value))
|
|
35
|
+
return null;
|
|
36
|
+
this.space();
|
|
37
|
+
if (this.position === this.source.length)
|
|
38
|
+
return { relative: clean(value.relative), pixels: clean(value.pixels) };
|
|
39
|
+
const operator = this.source[this.position];
|
|
40
|
+
if (operator !== "+" && operator !== "-")
|
|
41
|
+
return null;
|
|
42
|
+
this.position += 1;
|
|
43
|
+
direction = (operator === "+" ? 1 : -1) * this.sign();
|
|
44
|
+
}
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
term() {
|
|
48
|
+
const value = this.measurement();
|
|
49
|
+
if (!value)
|
|
50
|
+
return null;
|
|
51
|
+
while (true) {
|
|
52
|
+
this.space();
|
|
53
|
+
const operator = this.source[this.position];
|
|
54
|
+
if (operator !== "*" && operator !== "/")
|
|
55
|
+
return value;
|
|
56
|
+
this.position += 1;
|
|
57
|
+
const scalar = this.scalar();
|
|
58
|
+
if (scalar === null || operator === "/" && scalar === 0)
|
|
59
|
+
return null;
|
|
60
|
+
const factor = operator === "*" ? scalar : 1 / scalar;
|
|
61
|
+
value.relative *= factor;
|
|
62
|
+
value.pixels *= factor;
|
|
63
|
+
if (!finite(value))
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
measurement() {
|
|
68
|
+
this.space();
|
|
69
|
+
const remainder = this.source.slice(this.position);
|
|
70
|
+
const fraction = remainder.match(/^(\d+(?:\.\d+)?|\.\d+)\s*\/\s*(\d+(?:\.\d+)?|\.\d+)(?![\d.])/);
|
|
71
|
+
if (fraction) {
|
|
72
|
+
const denominator = Number(fraction[2]);
|
|
73
|
+
if (!denominator)
|
|
74
|
+
return null;
|
|
75
|
+
this.position += fraction[0].length;
|
|
76
|
+
return { relative: Number(fraction[1]) / denominator, pixels: 0 };
|
|
77
|
+
}
|
|
78
|
+
const percentage = remainder.match(/^(\d+(?:\.\d+)?|\.\d+)\s*%/);
|
|
79
|
+
if (percentage) {
|
|
80
|
+
this.position += percentage[0].length;
|
|
81
|
+
return { relative: Number(percentage[1]) / 100, pixels: 0 };
|
|
82
|
+
}
|
|
83
|
+
const number = this.number();
|
|
84
|
+
return number === null ? null : { relative: 0, pixels: number };
|
|
85
|
+
}
|
|
86
|
+
scalar() {
|
|
87
|
+
const direction = this.sign();
|
|
88
|
+
const value = this.number();
|
|
89
|
+
return value === null ? null : value * direction;
|
|
90
|
+
}
|
|
91
|
+
number() {
|
|
92
|
+
this.space();
|
|
93
|
+
const number = this.source.slice(this.position).match(/^(\d+(?:\.\d+)?|\.\d+)/)?.[0];
|
|
94
|
+
if (!number)
|
|
95
|
+
return null;
|
|
96
|
+
this.position += number.length;
|
|
97
|
+
return Number(number);
|
|
98
|
+
}
|
|
99
|
+
sign() {
|
|
100
|
+
this.space();
|
|
101
|
+
const sign = this.source[this.position];
|
|
102
|
+
if (sign !== "+" && sign !== "-")
|
|
103
|
+
return 1;
|
|
104
|
+
this.position += 1;
|
|
105
|
+
this.space();
|
|
106
|
+
return sign === "-" ? -1 : 1;
|
|
107
|
+
}
|
|
108
|
+
space() {
|
|
109
|
+
while (/\s/.test(this.source[this.position] ?? ""))
|
|
110
|
+
this.position += 1;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
function finite(value) {
|
|
114
|
+
return Number.isFinite(value.relative) && Number.isFinite(value.pixels);
|
|
115
|
+
}
|
|
116
|
+
function clean(value) {
|
|
117
|
+
return Object.is(value, -0) ? 0 : value;
|
|
118
|
+
}
|
package/dist/style.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How every command looks.
|
|
3
|
+
*
|
|
4
|
+
* Six escapes rather than a dependency, and nothing at all where a
|
|
5
|
+
* terminal would print them instead of obeying them. Shared because four
|
|
6
|
+
* commands speaking three visual languages is worse than any one of
|
|
7
|
+
* them: a person reading `phresh install` after `phresh init` should not
|
|
8
|
+
* have to work out that it is the same tool.
|
|
9
|
+
*/
|
|
10
|
+
const colour = process.stdout.isTTY && !process.env.NO_COLOR;
|
|
11
|
+
const escape = String.fromCharCode(27);
|
|
12
|
+
export const dim = (text) => colour ? `${escape}[2m${text}${escape}[22m` : text;
|
|
13
|
+
export const bold = (text) => colour ? `${escape}[1m${text}${escape}[22m` : text;
|
|
14
|
+
// A label, what it says, and where that came from. The label is quiet
|
|
15
|
+
// and the value is not, because the value is the thing being reported.
|
|
16
|
+
//
|
|
17
|
+
// Wide enough for the longest label any command uses, and a space kept
|
|
18
|
+
// even when one overruns: a label that runs into its value is worse than
|
|
19
|
+
// a column that does not line up.
|
|
20
|
+
export function line(label, value, note) {
|
|
21
|
+
console.log(` ${dim(label.padEnd(12))}${value}${note ? ` ${dim(note)}` : ""}`);
|
|
22
|
+
}
|
|
23
|
+
export function heading(title, note) {
|
|
24
|
+
console.log("");
|
|
25
|
+
console.log(` ${bold(title)}${note ? ` ${dim("·")} ${dim(note)}` : ""}`);
|
|
26
|
+
console.log("");
|
|
27
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { readConfig } from "./project.js";
|
|
2
|
+
import { dim, heading } from "./style.js";
|
|
3
|
+
import speak from "./program-intake.js";
|
|
4
|
+
/** Uninstall the Program declared by the current project. */
|
|
5
|
+
export default async function uninstall(everything = false, directory = process.cwd()) {
|
|
6
|
+
const config = await readConfig(directory);
|
|
7
|
+
await speak({ word: "uninstall", identity: config.identity, everything }, function (event) {
|
|
8
|
+
if (event.event !== "uninstalled")
|
|
9
|
+
return;
|
|
10
|
+
heading(config.name ?? config.identity, "uninstalled");
|
|
11
|
+
console.log(everything
|
|
12
|
+
? ` ${dim("Its processes, installed files, stored data, and runtime record were removed.")}\n`
|
|
13
|
+
: ` ${dim("Its installed files were removed. Processes, stored data, and runtime state were kept.")}\n`);
|
|
14
|
+
});
|
|
15
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@phreshos/cli",
|
|
3
|
+
"type": "module",
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"description": "The Phresh command-line interface for Program projects and system management.",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"build": "tsc --noEmit false --outDir dist --rootDir source --rewriteRelativeImportExtensions true --allowImportingTsExtensions true",
|
|
8
|
+
"prepack": "node --run build"
|
|
9
|
+
},
|
|
10
|
+
"bin": {
|
|
11
|
+
"phresh": "dist/cli.js"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"dist",
|
|
15
|
+
"README.md"
|
|
16
|
+
],
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"@phreshos/core": "0.1.0",
|
|
19
|
+
"adm-zip": "^0.6.0"
|
|
20
|
+
},
|
|
21
|
+
"devDependencies": {
|
|
22
|
+
"@types/adm-zip": "^0.5.8",
|
|
23
|
+
"@types/node": "^25.9.5",
|
|
24
|
+
"typescript": "^6.0.3"
|
|
25
|
+
}
|
|
26
|
+
}
|