@phreshos/cli 0.1.3 → 0.1.4

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
@@ -14,6 +14,7 @@ Its Program commands depend on `@phreshos/core`, and its runtime operations
14
14
  require a compatible system installation.
15
15
 
16
16
  ```bash
17
+ phresh create app # create a complete new Program
17
18
  phresh init # describe this program, once
18
19
  phresh pack # run the optional build, then package its result
19
20
  phresh install # lay this program out on this machine
@@ -33,6 +34,33 @@ and has no word for a process, a window, a store or a setting. The system's
33
34
  local intake accepts exactly the Program this project declares, and so does
34
35
  this tool.
35
36
 
37
+ ## create
38
+
39
+ `phresh create <directory>` creates a complete Server and Client Program from
40
+ the maintained Get Started project. Get Started is the only authored template:
41
+ the CLI build copies a filtered snapshot into the package, excluding build
42
+ output, storage, installed modules, lockfiles, and archives. The installed CLI
43
+ therefore creates projects offline without cloning a repository or maintaining
44
+ a second template by hand.
45
+
46
+ The directory name becomes the stable kebab-case Program identity. In a
47
+ terminal, `create` asks for the directory when it is omitted, the readable
48
+ Program name, and the package manager. With no terminal, the directory is the
49
+ first argument and every optional choice is named:
50
+
51
+ ```bash
52
+ phresh create status-board \
53
+ --name "Status Board" \
54
+ --package-manager npm
55
+ ```
56
+
57
+ Dependencies are installed by default. `--no-install` creates the same valid
58
+ project and leaves installation as the first reported next step. When the CLI
59
+ runs from this repository, generated SDK dependencies point to the sibling
60
+ dev-kits; a distributed CLI keeps the published versions embedded in its
61
+ template. Both choices pass through the same dependency resolver used by
62
+ `init`.
63
+
36
64
  ## Saying something to a program you start
37
65
 
38
66
  ```bash
@@ -0,0 +1,59 @@
1
+ import { cpSync, existsSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
2
+ import { basename, relative, resolve, sep } from "node:path";
3
+ const source = resolve(import.meta.dirname, "..", "..", "get-started");
4
+ const output = resolve(import.meta.dirname, "template");
5
+ const excludedDirectories = new Set(["dist", "node_modules", "storage"]);
6
+ const excludedFiles = new Set([
7
+ ".DS_Store",
8
+ "bun.lock",
9
+ "bun.lockb",
10
+ "package-lock.json",
11
+ "pnpm-lock.yaml",
12
+ "yarn.lock"
13
+ ]);
14
+ if (!existsSync(source))
15
+ throw new Error(`The Get Started source is not there: ${source}`);
16
+ rmSync(output, { recursive: true, force: true });
17
+ cpSync(source, output, { recursive: true, filter: included });
18
+ renameSync(resolve(output, ".gitignore"), resolve(output, "gitignore"));
19
+ const manifestPath = resolve(output, "package.json");
20
+ const manifest = JSON.parse(readFileSync(manifestPath, "utf-8"));
21
+ const versions = workspaceVersions();
22
+ for (const dependencies of [manifest.dependencies, manifest.devDependencies]) {
23
+ if (!dependencies)
24
+ continue;
25
+ for (const [name, range] of Object.entries(dependencies)) {
26
+ if (!range.startsWith("workspace:"))
27
+ continue;
28
+ const version = versions.get(name);
29
+ if (!version)
30
+ throw new Error(`The template depends on ${name}, but no matching workspace package exists`);
31
+ dependencies[name] = `^${version}`;
32
+ }
33
+ }
34
+ writeFileSync(manifestPath, JSON.stringify(manifest, null, 4) + "\n");
35
+ function included(path) {
36
+ const local = relative(source, path);
37
+ if (!local)
38
+ return true;
39
+ const parts = local.split(sep);
40
+ if (parts.some(part => excludedDirectories.has(part)))
41
+ return false;
42
+ const name = basename(path);
43
+ return !excludedFiles.has(name) && !name.endsWith(".zip");
44
+ }
45
+ function workspaceVersions() {
46
+ const versions = new Map();
47
+ const devKit = resolve(source, "..");
48
+ for (const entry of readdirSync(devKit, { withFileTypes: true })) {
49
+ if (!entry.isDirectory())
50
+ continue;
51
+ const path = resolve(devKit, entry.name, "package.json");
52
+ if (!existsSync(path))
53
+ continue;
54
+ const manifest = JSON.parse(readFileSync(path, "utf-8"));
55
+ if (manifest.name && manifest.version)
56
+ versions.set(manifest.name, manifest.version);
57
+ }
58
+ return versions;
59
+ }
package/dist/cli.js CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { bold, dim, heading } from "./style.js";
3
3
  import metadata from "../package.json" with { type: "json" };
4
+ import create from "./create.js";
4
5
  import install from "./install.js";
5
6
  import launch from "./launch.js";
6
7
  import init from "./init.js";
@@ -16,6 +17,30 @@ const coreRange = metadata.dependencies["@phreshos/core"];
16
17
  * behavior belongs to the command that needs it; parsing never guesses.
17
18
  */
18
19
  const commands = [
20
+ {
21
+ name: "create",
22
+ summary: "create a new Program project",
23
+ detail: [
24
+ "Creates a complete Server and Client project from the maintained",
25
+ "Get Started template bundled with this CLI.",
26
+ "",
27
+ "In a terminal, an omitted directory and the readable Program name",
28
+ "are collected interactively. Automation supplies the directory as",
29
+ "the first argument and may use named options."
30
+ ],
31
+ positionals: [{ name: "directory", required: false }],
32
+ options: [
33
+ { name: "name", value: "name", summary: "human-readable Program name" },
34
+ { name: "package-manager", value: "manager", summary: "bun, npm, pnpm, or yarn" },
35
+ { name: "no-install", summary: "create files without installing dependencies" }
36
+ ],
37
+ run: options => create({
38
+ directory: options.positionals[0],
39
+ name: text(options, "name"),
40
+ packageManager: packageManager(text(options, "package-manager")),
41
+ install: options["no-install"] !== true
42
+ })
43
+ },
19
44
  {
20
45
  name: "init",
21
46
  summary: "initialize an existing Program project",
@@ -150,7 +175,7 @@ catch (error) {
150
175
  process.exit(1);
151
176
  }
152
177
  function parse(command, args) {
153
- const options = { run: {} };
178
+ const options = { run: {}, positionals: [] };
154
179
  for (let index = 0; index < args.length; index += 1) {
155
180
  const argument = args[index];
156
181
  if (command.runOptions && argument.startsWith(runOptionPrefix)) {
@@ -164,8 +189,13 @@ function parse(command, args) {
164
189
  options.run[name] = said.slice(at + 1);
165
190
  continue;
166
191
  }
167
- if (!argument.startsWith("--"))
168
- throw new Error(`${command.name} takes no positional arguments, and I was given "${argument}"`);
192
+ if (!argument.startsWith("--")) {
193
+ const positional = command.positionals?.[options.positionals.length];
194
+ if (!positional)
195
+ throw new Error(`${command.name} takes no more positional arguments, and I was given "${argument}"`);
196
+ options.positionals.push(argument);
197
+ continue;
198
+ }
169
199
  const at = argument.indexOf("=");
170
200
  const name = argument.slice(2, at < 0 ? undefined : at);
171
201
  const declared = command.options?.find(option => option.name === name);
@@ -184,12 +214,20 @@ function parse(command, args) {
184
214
  throw new Error(`--${name} needs <${declared.value}>`);
185
215
  options[name] = value;
186
216
  }
217
+ const missing = command.positionals?.slice(options.positionals.length).find(positional => positional.required);
218
+ if (missing)
219
+ throw new Error(`${command.name} needs <${missing.name}>`);
187
220
  return options;
188
221
  }
189
222
  function text(options, name) {
190
223
  const value = options[name];
191
224
  return typeof value === "string" ? value : undefined;
192
225
  }
226
+ function packageManager(value) {
227
+ if (value === undefined || value === "bun" || value === "npm" || value === "pnpm" || value === "yarn")
228
+ return value;
229
+ throw new Error(`--package-manager must be bun, npm, pnpm, or yarn; received "${value}"`);
230
+ }
193
231
  function usage() {
194
232
  heading(`phresh ${version}`, "create and manage Programs");
195
233
  for (const command of commands)
@@ -199,7 +237,9 @@ function usage() {
199
237
  console.log("");
200
238
  }
201
239
  function about(command) {
202
- heading(`phresh ${command.name}${command.options?.length || command.runOptions ? " [options]" : ""}`, command.summary);
240
+ const positionals = command.positionals?.map(positional => positional.required ? `<${positional.name}>` : `[${positional.name}]`).join(" ");
241
+ const signature = [`phresh ${command.name}`, positionals, command.options?.length || command.runOptions ? "[options]" : ""].filter(Boolean).join(" ");
242
+ heading(signature, command.summary);
203
243
  for (const said of command.detail)
204
244
  console.log(said ? ` ${said}` : "");
205
245
  if (command.options?.length || command.runOptions) {
package/dist/create.js ADDED
@@ -0,0 +1,130 @@
1
+ import { projectDependency, installProjectDependencies, projectPackageManager } from "./project-dependency.js";
2
+ import questions from "./questions.js";
3
+ import { dim, heading, line } from "./style.js";
4
+ import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
5
+ import { basename, dirname, extname, relative, resolve } from "node:path";
6
+ /** Creates a complete Program from the bundled Get Started snapshot. */
7
+ export default async function create(options = {}, directory = process.cwd()) {
8
+ const prompts = questions();
9
+ try {
10
+ const requested = options.directory ?? (prompts.interactive
11
+ ? await prompts.ask("A new directory will contain the complete Server and Client project.", "Where should the Program be created?", "my-program")
12
+ : undefined);
13
+ if (!requested)
14
+ throw new Error("create needs a <directory> when no terminal is attached");
15
+ const target = resolve(directory, requested);
16
+ const identity = basename(target);
17
+ if (!programIdentity.test(identity))
18
+ throw new Error(`The directory name must be a kebab-case Program identity; received "${identity}"`);
19
+ if (existsSync(target))
20
+ throw new Error(`The destination already exists: ${target}`);
21
+ const name = options.name ?? (prompts.interactive
22
+ ? await prompts.ask("This name is shown to people; the directory name remains the Program identity.", "What name should people see?", title(identity))
23
+ : title(identity));
24
+ if (!name.trim())
25
+ throw new Error("--name must not be empty");
26
+ const install = options.install !== false;
27
+ const detected = projectPackageManager(directory).name;
28
+ const manager = options.packageManager ?? (install && prompts.interactive
29
+ ? packageManager(await prompts.ask("The generated project remains portable; this choice installs its dependencies now.", "Which package manager should be used?", detected))
30
+ : detected);
31
+ heading("Create Program", name);
32
+ line("identity", identity);
33
+ line("directory", target);
34
+ line("template", "Get Started");
35
+ if (install)
36
+ line("packages", manager);
37
+ console.log("");
38
+ const parent = dirname(target);
39
+ mkdirSync(parent, { recursive: true });
40
+ const staging = mkdtempSync(resolve(parent, `.${identity}-`));
41
+ try {
42
+ cpSync(template(), staging, { recursive: true });
43
+ renameSync(resolve(staging, "gitignore"), resolve(staging, ".gitignore"));
44
+ customize(staging, target, identity, name, manager);
45
+ if (install)
46
+ await installProjectDependencies(staging, manager);
47
+ renameSync(staging, target);
48
+ }
49
+ catch (error) {
50
+ rmSync(staging, { recursive: true, force: true });
51
+ throw error;
52
+ }
53
+ heading(name, "created");
54
+ line("directory", target);
55
+ line("packages", install ? "installed" : "not installed");
56
+ console.log("");
57
+ console.log(` ${dim("Next:")} cd ${relative(directory, target) || "."}`);
58
+ if (!install)
59
+ console.log(` ${manager} install`);
60
+ console.log(" phresh dev");
61
+ console.log("");
62
+ }
63
+ finally {
64
+ prompts.close();
65
+ }
66
+ }
67
+ function template() {
68
+ const candidates = [
69
+ resolve(import.meta.dirname, "template"),
70
+ resolve(import.meta.dirname, "..", "dist", "template")
71
+ ];
72
+ const found = candidates.find(existsSync);
73
+ if (!found)
74
+ throw new Error("The CLI template has not been built — run its build command and try again");
75
+ return found;
76
+ }
77
+ function customize(directory, finalDirectory, identity, name, manager) {
78
+ for (const path of textFiles(directory)) {
79
+ let content = readFileSync(path, "utf-8");
80
+ content = content.replaceAll("get-started", identity).replaceAll("Get Started", name);
81
+ if (basename(path) === "README.md") {
82
+ content = content
83
+ .replaceAll("bun install", `${manager} install`)
84
+ .replaceAll("bun phresh ", "phresh ");
85
+ }
86
+ writeFileSync(path, content);
87
+ }
88
+ const manifestPath = resolve(directory, "package.json");
89
+ const manifest = JSON.parse(readFileSync(manifestPath, "utf-8"));
90
+ manifest.name = identity;
91
+ for (const dependencies of [manifest.dependencies, manifest.devDependencies]) {
92
+ if (!dependencies)
93
+ continue;
94
+ for (const [dependency, range] of Object.entries(dependencies)) {
95
+ if (!isProjectPackage(dependency))
96
+ continue;
97
+ dependencies[dependency] = projectDependency(dependency, range, finalDirectory).manifestSpecifier;
98
+ }
99
+ }
100
+ writeFileSync(manifestPath, JSON.stringify(manifest, null, 4) + "\n");
101
+ }
102
+ function textFiles(directory) {
103
+ const files = [];
104
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
105
+ const path = resolve(directory, entry.name);
106
+ if (entry.isDirectory())
107
+ files.push(...textFiles(path));
108
+ else if (entry.isFile() && (textExtensions.has(extname(entry.name)) || textNames.has(entry.name)))
109
+ files.push(path);
110
+ }
111
+ return files;
112
+ }
113
+ function packageManager(value) {
114
+ if (value === "bun" || value === "npm" || value === "pnpm" || value === "yarn")
115
+ return value;
116
+ throw new Error(`The package manager must be bun, npm, pnpm, or yarn; received "${value}"`);
117
+ }
118
+ function isProjectPackage(value) {
119
+ return value === "@phreshos/core"
120
+ || value === "@phreshos/client"
121
+ || value === "@phreshos/server"
122
+ || value === "@phreshos/react"
123
+ || value === "@phreshos/cli";
124
+ }
125
+ function title(identity) {
126
+ return identity.split("-").map(word => word[0].toUpperCase() + word.slice(1)).join(" ");
127
+ }
128
+ const programIdentity = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
129
+ const textExtensions = new Set([".css", ".html", ".json", ".md", ".ts", ".tsx"]);
130
+ const textNames = new Set([".gitignore", "gitignore"]);
package/dist/init.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import {} from "@phreshos/core";
2
2
  import { configFile, readManifest } from "./project.js";
3
- import { bold, dim, heading, line } from "./style.js";
3
+ import { dim, heading, line } from "./style.js";
4
4
  import ensureProjectDependency, { projectScript } from "./project-dependency.js";
5
- import { createInterface } from "node:readline/promises";
5
+ import questions from "./questions.js";
6
6
  import { existsSync, writeFileSync } from "node:fs";
7
7
  import { resolve } from "node:path";
8
8
  /**
@@ -16,23 +16,11 @@ import { resolve } from "node:path";
16
16
  export default async function init(options = {}, directory = process.cwd(), coreRange = "^0.1.0") {
17
17
  const manifest = await readManifest(directory);
18
18
  const path = resolve(directory, configFile);
19
- const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY);
19
+ const prompts = questions();
20
+ const { interactive, ask, yes } = prompts;
20
21
  const explicitShape = options.server === true || options.client === true;
21
22
  if (existsSync(path) && options.force !== true && !interactive)
22
23
  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(explanation, 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
- console.log(` ${dim(explanation)}`);
29
- const said = (await readline.question(` ${bold(question)}${suffix} `)).trim();
30
- console.log("");
31
- return said || fallback || "";
32
- }
33
- async function yes(explanation, question, fallback) {
34
- return (await ask(explanation, question, fallback ? "Y/n" : "y/N")).toLowerCase().startsWith("y");
35
- }
36
24
  try {
37
25
  if (existsSync(path) && options.force !== true) {
38
26
  heading(configFile, "already initialized");
@@ -159,7 +147,7 @@ export default async function init(options = {}, directory = process.cwd(), core
159
147
  console.log("");
160
148
  }
161
149
  finally {
162
- readline?.close();
150
+ prompts.close();
163
151
  }
164
152
  }
165
153
  function compose(config) {
@@ -184,8 +172,7 @@ function compose(config) {
184
172
  `import { defineConfig } from "@phreshos/core"`,
185
173
  "",
186
174
  "export default defineConfig({",
187
- "",
188
- blocks.filter(Boolean).join(",\n\n"),
175
+ blocks.filter(Boolean).join(",\n"),
189
176
  "})",
190
177
  ""
191
178
  ].join("\n");
@@ -200,9 +187,9 @@ function half(name, values) {
200
187
  if (typeof value === "string")
201
188
  return ` ${key}: ${JSON.stringify(value)}`;
202
189
  const nested = Object.entries(value).map(([nestedKey, nestedValue]) => ` ${nestedKey}: ${JSON.stringify(nestedValue)}`);
203
- return ` ${key}: {\n\n${nested.join(",\n\n")}\n }`;
190
+ return ` ${key}: {\n${nested.join(",\n")}\n }`;
204
191
  });
205
- return ` ${name}: {\n\n${inside.join(",\n\n")}\n }`;
192
+ return ` ${name}: {\n${inside.join(",\n")}\n }`;
206
193
  }
207
194
  function httpUrl(value) {
208
195
  try {
@@ -1,51 +1,83 @@
1
1
  import { line } from "./style.js";
2
2
  import { spawn } from "node:child_process";
3
3
  import { existsSync, readFileSync } from "node:fs";
4
- import { resolve } from "node:path";
4
+ import { relative, resolve } from "node:path";
5
5
  /** Returns the package-manager command that runs one project script. */
6
6
  export function projectScript(directory, declared, script) {
7
- return `${packageManager(directory, declared).name} run ${script}`;
7
+ return `${projectPackageManager(directory, declared).name} run ${script}`;
8
8
  }
9
9
  /**
10
- * Ensures one development dependency using the source appropriate to this CLI.
10
+ * Resolves an SDK through the one source policy shared by `init` and `create`.
11
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 declared registry range instead. This
15
- * decision belongs here once for both `init` and the future `create` command.
12
+ * A CLI running from this repository uses its sibling dev-kit. A distributed
13
+ * CLI has no such sibling and keeps the published range embedded in its
14
+ * generated template.
16
15
  */
17
- export default async function ensureProjectDependency(name, range, directory = process.cwd()) {
16
+ export function projectDependency(name, range, directory) {
17
+ const workspace = manifestAt(resolve(import.meta.dirname, "..", "..", ".."));
18
+ const sourceDirectory = resolve(import.meta.dirname, "..", "..", localDirectories[name]);
19
+ const manifest = manifestAt(sourceDirectory);
20
+ if (workspace?.name === "@phreshos/workspace" && manifest?.name === name && manifest.version && accepts(range, manifest.version)) {
21
+ const local = relative(directory, sourceDirectory).replaceAll("\\", "/") || ".";
22
+ return {
23
+ installSpecifier: sourceDirectory,
24
+ manifestSpecifier: `file:${local}`,
25
+ local: true
26
+ };
27
+ }
28
+ return {
29
+ installSpecifier: `${name}@${range}`,
30
+ manifestSpecifier: range,
31
+ local: false
32
+ };
33
+ }
34
+ /** Detects the package manager declared by, present in, or invoking a project. */
35
+ export function projectPackageManager(directory, declared, preferred) {
36
+ const named = preferred ?? packageManagerName(declared) ?? packageManagerFromLocks(directory) ?? packageManagerFromInvocation();
37
+ return managers[named ?? "npm"];
38
+ }
39
+ /** Installs the dependencies already declared by a generated project. */
40
+ export async function installProjectDependencies(directory, preferred) {
41
+ const manifest = manifestAt(directory);
42
+ const manager = projectPackageManager(directory, manifest?.packageManager, preferred);
43
+ await run(manager.name, manager.installArgs, directory);
44
+ return manager.name;
45
+ }
46
+ /**
47
+ * Ensures one project dependency using the source appropriate to this CLI.
48
+ *
49
+ * `init` uses development dependencies because Core supplies the authoring
50
+ * contract for an existing project. `create` preserves the canonical
51
+ * template's own dependency sections instead.
52
+ */
53
+ export default async function ensureProjectDependency(name, range, directory = process.cwd(), section = "devDependencies") {
18
54
  const manifestPath = resolve(directory, "package.json");
19
55
  const manifest = JSON.parse(readFileSync(manifestPath, "utf-8"));
20
56
  if (manifest.dependencies?.[name] || manifest.devDependencies?.[name])
21
57
  return;
22
- const manager = packageManager(directory, manifest.packageManager);
23
- const source = packageSource(name, range);
58
+ const manager = projectPackageManager(directory, manifest.packageManager);
59
+ const source = projectDependency(name, range, directory);
24
60
  line("dependency", name, `${manager.name}, ${source.local ? "local dev-kit" : range}`);
25
- await new Promise(function (settle, refuse) {
26
- const child = spawn(manager.name, [...manager.args, source.specifier], {
61
+ await run(manager.name, [...manager.addArgs(section), source.installSpecifier], directory);
62
+ console.log("");
63
+ }
64
+ function run(command, args, directory) {
65
+ return new Promise(function (settle, refuse) {
66
+ const child = spawn(command, args, {
27
67
  cwd: directory,
28
68
  stdio: "inherit",
29
69
  shell: process.platform === "win32"
30
70
  });
31
- child.once("error", error => refuse(new Error(`Could not run ${manager.name}: ${error.message}`)));
71
+ child.once("error", error => refuse(new Error(`Could not run ${command}: ${error.message}`)));
32
72
  child.once("exit", function (code, signal) {
33
73
  if (signal)
34
- refuse(new Error(`${manager.name} ended on ${signal}`));
74
+ refuse(new Error(`${command} ended on ${signal}`));
35
75
  else if (code !== 0)
36
- refuse(new Error(`${manager.name} exited with ${code ?? 0}`));
76
+ refuse(new Error(`${command} exited with ${code ?? 0}`));
37
77
  else
38
78
  settle();
39
79
  });
40
80
  });
41
- console.log("");
42
- }
43
- function packageSource(name, range) {
44
- const directory = resolve(import.meta.dirname, "..", "..", localDirectories[name]);
45
- const manifest = manifestAt(directory);
46
- if (manifest?.name === name && manifest.version && accepts(range, manifest.version))
47
- return { specifier: directory, local: true };
48
- return { specifier: `${name}@${range}`, local: false };
49
81
  }
50
82
  function accepts(range, version) {
51
83
  return range === "workspace:*" || range === version || range === `^${version}` || range === `~${version}`;
@@ -61,26 +93,53 @@ function manifestAt(directory) {
61
93
  return null;
62
94
  }
63
95
  }
64
- function packageManager(directory, declared) {
96
+ function packageManagerName(declared) {
65
97
  const named = declared?.split("@")[0];
66
- if (named === "bun")
67
- return { name: "bun", args: ["add", "--dev"] };
68
- if (named === "pnpm")
69
- return { name: "pnpm", args: ["add", "--save-dev"] };
70
- if (named === "yarn")
71
- return { name: "yarn", args: ["add", "--dev"] };
72
- if (named === "npm")
73
- return { name: "npm", args: ["install", "--save-dev", "--no-fund", "--no-audit"] };
98
+ return isPackageManager(named) ? named : undefined;
99
+ }
100
+ function packageManagerFromLocks(directory) {
74
101
  if (existsSync(resolve(directory, "bun.lock")) || existsSync(resolve(directory, "bun.lockb")))
75
- return { name: "bun", args: ["add", "--dev"] };
102
+ return "bun";
76
103
  if (existsSync(resolve(directory, "pnpm-lock.yaml")))
77
- return { name: "pnpm", args: ["add", "--save-dev"] };
104
+ return "pnpm";
78
105
  if (existsSync(resolve(directory, "yarn.lock")))
79
- return { name: "yarn", args: ["add", "--dev"] };
80
- return { name: "npm", args: ["install", "--save-dev", "--no-fund", "--no-audit"] };
106
+ return "yarn";
107
+ if (existsSync(resolve(directory, "package-lock.json")))
108
+ return "npm";
109
+ }
110
+ function packageManagerFromInvocation() {
111
+ const named = process.env.npm_config_user_agent?.split("/")[0];
112
+ return isPackageManager(named) ? named : undefined;
113
+ }
114
+ function isPackageManager(value) {
115
+ return value === "bun" || value === "npm" || value === "pnpm" || value === "yarn";
81
116
  }
82
117
  const localDirectories = {
83
118
  "@phreshos/core": "core-sdk",
84
119
  "@phreshos/client": "client-sdk",
85
- "@phreshos/server": "server-sdk"
120
+ "@phreshos/server": "server-sdk",
121
+ "@phreshos/react": "react-sdk",
122
+ "@phreshos/cli": "cli"
123
+ };
124
+ const managers = {
125
+ bun: {
126
+ name: "bun",
127
+ installArgs: ["install"],
128
+ addArgs: section => ["add", ...section === "devDependencies" ? ["--dev"] : []]
129
+ },
130
+ npm: {
131
+ name: "npm",
132
+ installArgs: ["install", "--no-fund", "--no-audit"],
133
+ addArgs: section => ["install", ...section === "devDependencies" ? ["--save-dev"] : ["--save"], "--no-fund", "--no-audit"]
134
+ },
135
+ pnpm: {
136
+ name: "pnpm",
137
+ installArgs: ["install"],
138
+ addArgs: section => ["add", ...section === "devDependencies" ? ["--save-dev"] : ["--save-prod"]]
139
+ },
140
+ yarn: {
141
+ name: "yarn",
142
+ installArgs: ["install"],
143
+ addArgs: section => ["add", ...section === "devDependencies" ? ["--dev"] : []]
144
+ }
86
145
  };
@@ -0,0 +1,25 @@
1
+ import { bold, dim } from "./style.js";
2
+ import { createInterface } from "node:readline/promises";
3
+ /** One prompt language shared by every interactive command. */
4
+ export default function questions() {
5
+ const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY);
6
+ const readline = interactive ? createInterface({ input: process.stdin, output: process.stdout }) : null;
7
+ async function ask(explanation, question, fallback) {
8
+ if (!readline)
9
+ throw new Error(`${question} Supply the corresponding option when no terminal is attached`);
10
+ const suffix = fallback === undefined ? "" : ` ${dim(`(${fallback})`)}`;
11
+ console.log(` ${dim(explanation)}`);
12
+ const said = (await readline.question(` ${bold(question)}${suffix} `)).trim();
13
+ console.log("");
14
+ return said || fallback || "";
15
+ }
16
+ async function yes(explanation, question, fallback) {
17
+ return (await ask(explanation, question, fallback ? "Y/n" : "y/N")).toLowerCase().startsWith("y");
18
+ }
19
+ return {
20
+ interactive,
21
+ ask,
22
+ yes,
23
+ close: () => readline?.close()
24
+ };
25
+ }
@@ -0,0 +1,28 @@
1
+ # Get Started
2
+
3
+ A small Counter built as a complete Program. Its Node.js Server owns the
4
+ number, while its React Client reads and increments that same authoritative
5
+ state.
6
+
7
+ ```bash
8
+ bun install
9
+ bun phresh dev
10
+ ```
11
+
12
+ Development starts the Server source and Vite Client together. For the
13
+ production shape, build and attach the Program with:
14
+
15
+ ```bash
16
+ bun phresh start
17
+ ```
18
+
19
+ The structure follows the endpoint boundaries directly:
20
+
21
+ ```text
22
+ source/
23
+ ├── client/ React interface
24
+ └── server/ authoritative counter and API
25
+ ```
26
+
27
+ The Program declaration lives in `phresh.config.ts`. Its own service contract
28
+ is documented in `api-docs.md`.
@@ -0,0 +1,38 @@
1
+ # Counter API
2
+
3
+ The Server owns one number for the lifetime of its Process.
4
+
5
+ | Name | Destination | Interaction | Payload | Answer |
6
+ | --- | --- | --- | --- | --- |
7
+ | `read` | Server | `ask()` | `undefined` | `number` |
8
+ | `increment` | Server | `publish()` | `undefined` | None |
9
+ | `changed` | Server | `subscribe()` | `number` | None |
10
+
11
+ ## Ask the Server: `read`
12
+
13
+ `read` is a question addressed to this Process's Server. It answers with the
14
+ current counter value.
15
+
16
+ ```ts
17
+ const count = await process.server.ask<number>("read")
18
+ ```
19
+
20
+ ## Publish to the Server: `increment`
21
+
22
+ `increment` is a one-way event addressed to this Process's Server. It adds one
23
+ to the counter and does not produce an answer.
24
+
25
+ ```ts
26
+ process.server.publish("increment")
27
+ ```
28
+
29
+ ## Subscribe to the Server: `changed`
30
+
31
+ After an increment, the Server emits `changed` with the new counter value.
32
+ Anyone holding that Process's Server can subscribe to the event.
33
+
34
+ ```ts
35
+ process.server.subscribe<number>("changed", count => {
36
+ // use the latest count
37
+ })
38
+ ```
@@ -0,0 +1,30 @@
1
+ # Logs
2
+ logs
3
+ *.log
4
+ npm-debug.log*
5
+ yarn-debug.log*
6
+ yarn-error.log*
7
+ pnpm-debug.log*
8
+ lerna-debug.log*
9
+
10
+ node_modules
11
+ dist
12
+ dist-ssr
13
+ *.local
14
+
15
+ # Made by `phresh pack` in the project root.
16
+ /*.zip
17
+
18
+ # Editor directories and files
19
+ .vscode/*
20
+ !.vscode/extensions.json
21
+ .idea
22
+ .DS_Store
23
+ *.suo
24
+ *.ntvs*
25
+ *.njsproj
26
+ *.sln
27
+ *.sw?
28
+
29
+ # Made by the system the first time this program runs.
30
+ storage
Binary file
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "get-started",
3
+ "private": true,
4
+ "version": "0.1.0",
5
+ "type": "module",
6
+ "scripts": {
7
+ "phresh": "phresh"
8
+ },
9
+ "dependencies": {
10
+ "@phreshos/client": "^0.1.0",
11
+ "@phreshos/react": "^0.1.0",
12
+ "@phreshos/server": "^0.1.0",
13
+ "@phreshos/core": "^0.1.0",
14
+ "react": "^19.2.8",
15
+ "react-dom": "^19.2.8"
16
+ },
17
+ "devDependencies": {
18
+ "@phreshos/cli": "^0.1.4",
19
+ "@types/node": "^26.2.0",
20
+ "@types/react": "^19.2.18",
21
+ "@types/react-dom": "^19.2.4",
22
+ "@vitejs/plugin-react": "^6.0.5",
23
+ "tsx": "^4.23.12",
24
+ "typescript": "^7.0.2",
25
+ "vite": "^8.2.1"
26
+ }
27
+ }
@@ -0,0 +1,69 @@
1
+ import { defineConfig } from "@phreshos/core"
2
+
3
+ export default defineConfig({
4
+
5
+ // The stable, kebab-case address used by programs and the system.
6
+ identity: "get-started",
7
+
8
+ // Human-facing details shown by the desktop and development tools.
9
+ name: "Get Started",
10
+ description: "A simple counter whose state lives on the Server.",
11
+ version: "0.1.0",
12
+
13
+ // The official entry point for this Program's own API.
14
+ apiDocs: "api-docs.md",
15
+
16
+ // A directory containing files named by size, such as 128x128.png.
17
+ icons: "icons",
18
+
19
+ // Runs before production start, install, and pack; dev uses the
20
+ // development declarations below instead.
21
+ buildCommand: "node --import tsx source/build.ts",
22
+
23
+ server: {
24
+
25
+ // The built server directory and the command run from within it.
26
+ location: "dist/server",
27
+ startCommand: "node main.js",
28
+
29
+ // Optional preparation performed once before the installed server
30
+ // first starts. This bundled example does not need it.
31
+ // installCommand: "npm install",
32
+
33
+ // A declared half starts by default. Set this to false when it
34
+ // should exist but only be included when explicitly requested.
35
+ // start: false,
36
+
37
+ development: {
38
+
39
+ // Development server commands run from the project root.
40
+ startCommand: "node --watch --import tsx source/server/main.ts"
41
+ }
42
+ },
43
+
44
+ client: {
45
+
46
+ // The directory containing the built browser application.
47
+ location: "dist/client",
48
+
49
+ title: "Get Started",
50
+ size: { width: 600, height: 500 },
51
+
52
+ // Optional defaults for the window created with this client half.
53
+ // title: "Get Started",
54
+ // Fractions and percentages are relative to the Window's layer. Pixel
55
+ // offsets may be combined with them in one linear expression.
56
+ // size: { width: "50% + 20", height: 440 },
57
+ // position: { x: "1/2 + 10", y: 40 },
58
+ // layer: "window", // "under" and "over" are frameless desktop layers.
59
+ // minimize: true,
60
+
61
+ development: {
62
+
63
+ // The CLI starts this command, then waits for the URL before it
64
+ // launches the development Program.
65
+ url: "http://localhost:5200/",
66
+ startCommand: "vite dev"
67
+ }
68
+ }
69
+ })
@@ -0,0 +1,17 @@
1
+ import { externalDependencies } from "@/vite.config"
2
+ import { writeFile } from "node:fs/promises"
3
+ import packageConfig from "@/package.json"
4
+ import { build } from "vite"
5
+
6
+ const dependencies: Partial<typeof packageConfig.dependencies> = {}
7
+
8
+ for (const externalDependency of externalDependencies) {
9
+
10
+ dependencies[externalDependency] = packageConfig.dependencies[externalDependency]
11
+ }
12
+
13
+ await build({ build: { ssr: true } })
14
+
15
+ await build()
16
+
17
+ await writeFile("dist/server/package.json", JSON.stringify({ type: "module", dependencies }))
@@ -0,0 +1,41 @@
1
+ import { CurrentProvider, useSubscribe } from "@phreshos/react"
2
+ import { useEffect, useState } from "react"
3
+ import { current } from "@phreshos/client"
4
+
5
+ function Counter() {
6
+
7
+ const [count, setCount] = useState<number>()
8
+
9
+ useSubscribe(current.server, "changed", setCount)
10
+
11
+ useEffect(function () {
12
+
13
+ current.server.ask<number>("read").then(setCount)
14
+
15
+ }, [])
16
+
17
+ return <main className="counter-card">
18
+
19
+ <span className="label">Get Started</span>
20
+
21
+ <h1>Server counter</h1>
22
+
23
+ <p>The value belongs to the Server and is shared with every Client representation.</p>
24
+
25
+ <button type="button" onClick={() => current.server.publish("increment")}>
26
+
27
+ count is {count ?? "…"}
28
+
29
+ </button>
30
+
31
+ </main>
32
+ }
33
+
34
+ export default function App() {
35
+
36
+ return <CurrentProvider waitServer fallback={<main className="loading">Connecting to the Server…</main>}>
37
+
38
+ <Counter />
39
+
40
+ </CurrentProvider>
41
+ }
@@ -0,0 +1 @@
1
+ /// <reference types="vite/client" />
@@ -0,0 +1,13 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+
4
+ <head>
5
+ <meta charset="UTF-8" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+ <script type="module" src="./main.tsx"></script>
8
+ </head>
9
+
10
+ <body>
11
+ </body>
12
+
13
+ </html>
@@ -0,0 +1,8 @@
1
+ import client from "react-dom/client"
2
+ import { StrictMode } from "react"
3
+ import App from "./app"
4
+ import "./style.css"
5
+
6
+ const root = client.createRoot(document.body)
7
+
8
+ root.render(<StrictMode><App /></StrictMode>)
@@ -0,0 +1,81 @@
1
+ :root {
2
+ color: #20242a;
3
+ background: #f3f4f6;
4
+ font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
5
+ font-synthesis: none;
6
+ }
7
+
8
+ * { box-sizing: border-box; }
9
+
10
+ html, body, #root { min-height: 100vh; display: grid; }
11
+
12
+ body { margin: 0; }
13
+
14
+ button { font: inherit; }
15
+
16
+ #root {
17
+ min-height: 100vh;
18
+ display: grid;
19
+ place-items: center;
20
+ padding: 1.5rem;
21
+ }
22
+
23
+ .counter-card {
24
+ width: min(26rem, 100%);
25
+ display: grid;
26
+ justify-items: start;
27
+ gap: 1rem;
28
+ padding: 2rem;
29
+ border: 1px solid #d8dce2;
30
+ border-radius: 0.75rem;
31
+ background: #ffffff;
32
+ margin: auto;
33
+ }
34
+
35
+ .label {
36
+ color: #646b76;
37
+ font-size: 0.72rem;
38
+ font-weight: 700;
39
+ letter-spacing: 0.1em;
40
+ text-transform: uppercase;
41
+ }
42
+
43
+ h1 {
44
+ margin: 0;
45
+ font-size: 2rem;
46
+ line-height: 1.1;
47
+ letter-spacing: -0.04em;
48
+ }
49
+
50
+ p {
51
+ margin: 0;
52
+ color: #5f6670;
53
+ font-size: 0.95rem;
54
+ line-height: 1.55;
55
+ }
56
+
57
+ .counter-card button {
58
+ min-width: 9rem;
59
+ min-height: 2.75rem;
60
+ padding: 0.65rem 1rem;
61
+ border: 0;
62
+ border-radius: 0.5rem;
63
+ color: #ffffff;
64
+ background: #2563eb;
65
+ cursor: pointer;
66
+ font-weight: 650;
67
+ }
68
+
69
+ .counter-card button:hover { background: #1d4ed8; }
70
+
71
+ .counter-card button:active { background: #1e40af; }
72
+
73
+ .counter-card button:focus-visible {
74
+ outline: 3px solid rgba(37, 99, 235, 0.25);
75
+ outline-offset: 2px;
76
+ }
77
+
78
+ .loading {
79
+ color: #646b76;
80
+ font-size: 0.9rem;
81
+ }
@@ -0,0 +1 @@
1
+ /// <reference types="@types/node" />
@@ -0,0 +1,14 @@
1
+ import { current } from "@phreshos/server"
2
+
3
+ // The Server owns the value. Every Client representation reads and changes
4
+ // this same state instead of keeping an independent browser counter.
5
+ let count = 0
6
+
7
+ current.answer("read", () => count)
8
+
9
+ current.subscribe("increment", function () {
10
+
11
+ count += 1
12
+
13
+ current.publish("changed", count)
14
+ })
@@ -0,0 +1,24 @@
1
+ {
2
+ "compilerOptions": {
3
+ "moduleResolution": "bundler",
4
+ "noUnusedParameters": true,
5
+ "noUnusedLocals": true,
6
+ "skipLibCheck": true,
7
+ "module": "ESNext",
8
+ "target": "ESNext",
9
+ "jsx": "react-jsx",
10
+ "strict": true,
11
+ "noEmit": true,
12
+ "paths": {
13
+ "@/*": [
14
+ "./*"
15
+ ],
16
+ "@server/*": [
17
+ "./source/server/*"
18
+ ],
19
+ "@client/*": [
20
+ "./source/client/*"
21
+ ]
22
+ }
23
+ }
24
+ }
@@ -0,0 +1,38 @@
1
+ import packageConfig from "./package.json" with { type: "json" }
2
+ import react from "@vitejs/plugin-react"
3
+ import { defineConfig } from "vite"
4
+ import { resolve } from "node:path"
5
+
6
+ export const externalDependencies: (keyof typeof packageConfig.dependencies)[] = [
7
+
8
+ // Add packages here that should NOT be bundled during server, Example: "sqlite3"
9
+ ]
10
+
11
+ export default defineConfig(function (config) {
12
+
13
+ return {
14
+ base: "./",
15
+ resolve: {
16
+ tsconfigPaths: true,
17
+ dedupe: ["react"]
18
+ },
19
+ server: {
20
+ cors: true,
21
+ port: 5200,
22
+ strictPort: true
23
+ },
24
+ root: config.isSsrBuild ? "source/server" : "source/client",
25
+ plugins: config.isSsrBuild ? [] : [react()],
26
+ ssr: {
27
+ noExternal: true,
28
+ external: externalDependencies
29
+ },
30
+ build: {
31
+ emptyOutDir: true,
32
+ rolldownOptions: {
33
+ input: config.isSsrBuild ? "main.ts" : "index.html"
34
+ },
35
+ outDir: resolve(import.meta.dirname, config.isSsrBuild ? "dist/server" : "dist/client"),
36
+ }
37
+ }
38
+ })
package/package.json CHANGED
@@ -1,10 +1,11 @@
1
1
  {
2
2
  "name": "@phreshos/cli",
3
3
  "type": "module",
4
- "version": "0.1.3",
4
+ "version": "0.1.4",
5
5
  "description": "The Phresh command-line interface for Program projects and system management.",
6
6
  "scripts": {
7
- "build": "tsc --noEmit false --outDir dist --rootDir source --rewriteRelativeImportExtensions true --allowImportingTsExtensions true",
7
+ "compile": "tsc --noEmit false --outDir dist --rootDir source --rewriteRelativeImportExtensions true --allowImportingTsExtensions true",
8
+ "build": "node --run compile && node dist/build-template.js",
8
9
  "prepack": "node --run build"
9
10
  },
10
11
  "bin": {