From 97a5ff84c59b4732d2ffb9123cb85b4ceb4c9bbd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Cort=C3=B3n=20Cobas?= <104267232+carloscortonc@users.noreply.github.com> Date: Sat, 17 Jan 2026 10:52:40 +0100 Subject: [PATCH 01/52] chore: base logic & mechanism implemented --- examples/web/cli.js | 4 - examples/web/index.html | 70 --------------- {examples/web => site}/.gitignore | 0 {examples/web => site}/README.md | 3 +- site/cli.js | 4 + site/commands/commands.js | 7 ++ {examples/web => site}/definition.json | 0 site/index.html | 103 +++++++++++++++++++++++ {examples/web => site}/package-lock.json | 0 {examples/web => site}/package.json | 2 +- {examples/web => site/shims}/fs.js | 5 +- {examples/web => site/shims}/shims.js | 0 site/shims/url.js | 3 + 13 files changed, 124 insertions(+), 77 deletions(-) delete mode 100644 examples/web/cli.js delete mode 100644 examples/web/index.html rename {examples/web => site}/.gitignore (100%) rename {examples/web => site}/README.md (97%) create mode 100644 site/cli.js create mode 100644 site/commands/commands.js rename {examples/web => site}/definition.json (100%) create mode 100644 site/index.html rename {examples/web => site}/package-lock.json (100%) rename {examples/web => site}/package.json (72%) rename {examples/web => site/shims}/fs.js (50%) rename {examples/web => site/shims}/shims.js (100%) create mode 100644 site/shims/url.js diff --git a/examples/web/cli.js b/examples/web/cli.js deleted file mode 100644 index e15904c..0000000 --- a/examples/web/cli.js +++ /dev/null @@ -1,4 +0,0 @@ -import "./shims.js"; -import Cli from "../../dist/index.js"; - -window.Cli = Cli; diff --git a/examples/web/index.html b/examples/web/index.html deleted file mode 100644 index 3288ce9..0000000 --- a/examples/web/index.html +++ /dev/null @@ -1,70 +0,0 @@ - - - - - - - - - Cli on the Web - - -
$
-

-    
-  
-
diff --git a/examples/web/.gitignore b/site/.gitignore
similarity index 100%
rename from examples/web/.gitignore
rename to site/.gitignore
diff --git a/examples/web/README.md b/site/README.md
similarity index 97%
rename from examples/web/README.md
rename to site/README.md
index 11410db..f92514e 100644
--- a/examples/web/README.md
+++ b/site/README.md
@@ -8,4 +8,5 @@ $ npm run bundle
 
 # start the application (replace "bunx" with "npx" if not installed )
 $ npm run start
-```
\ No newline at end of file
+```
+
diff --git a/site/cli.js b/site/cli.js
new file mode 100644
index 0000000..48360b3
--- /dev/null
+++ b/site/cli.js
@@ -0,0 +1,4 @@
+import "./shims/shims.js";
+import Cli from "../dist/index.js";
+
+window.Cli = Cli;
diff --git a/site/commands/commands.js b/site/commands/commands.js
new file mode 100644
index 0000000..b80c4ac
--- /dev/null
+++ b/site/commands/commands.js
@@ -0,0 +1,7 @@
+export default [
+  {},
+  { cliDescription: "List available commands" },
+  (p) => {
+    Cli.logger.log("[Command::action]", p);
+  },
+];
diff --git a/examples/web/definition.json b/site/definition.json
similarity index 100%
rename from examples/web/definition.json
rename to site/definition.json
diff --git a/site/index.html b/site/index.html
new file mode 100644
index 0000000..1fbfe08
--- /dev/null
+++ b/site/index.html
@@ -0,0 +1,103 @@
+
+
+  
+    
+    
+    
+    
+    
+    Cli on the Web
+  
+  
+    

cli-er - Modular Command-Line Interface Builder

+
$
+

+    
+  
+
diff --git a/examples/web/package-lock.json b/site/package-lock.json
similarity index 100%
rename from examples/web/package-lock.json
rename to site/package-lock.json
diff --git a/examples/web/package.json b/site/package.json
similarity index 72%
rename from examples/web/package.json
rename to site/package.json
index b3b3cfe..d24edc9 100644
--- a/examples/web/package.json
+++ b/site/package.json
@@ -4,7 +4,7 @@
   "main": "cli.js",
   "type": "module",
   "scripts": {
-    "bundle": "browserify cli.js -p esmify --require ./fs.js:fs  --igv=__filename,__dirname,Buffer,global > cli.web.js",
+    "bundle": "browserify cli.js -p esmify --require ./shims/fs.js:fs --require ./shims/url.js:url --igv=__filename,__dirname,Buffer,global > cli.web.js",
     "start": "bunx http-server . -p 3000",
     "test": "echo \"Error: no test specified\" && exit 1"
   },
diff --git a/examples/web/fs.js b/site/shims/fs.js
similarity index 50%
rename from examples/web/fs.js
rename to site/shims/fs.js
index 7c42cb6..970c42c 100644
--- a/examples/web/fs.js
+++ b/site/shims/fs.js
@@ -1,5 +1,8 @@
 module.exports = {
   readFileSync: () => undefined,
-  existsSync: () => false,
+  existsSync: (p) => {
+    console.log("[existsSync]", p);
+    return true;
+  },
   realpathSync: () => "",
 };
diff --git a/examples/web/shims.js b/site/shims/shims.js
similarity index 100%
rename from examples/web/shims.js
rename to site/shims/shims.js
diff --git a/site/shims/url.js b/site/shims/url.js
new file mode 100644
index 0000000..6eef58f
--- /dev/null
+++ b/site/shims/url.js
@@ -0,0 +1,3 @@
+module.exports = {
+  pathToFileURL: () => ({ href: "" }),
+};

From d4f9de427ac8503aa3d4213fb12d65cd7abfea0d Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Carlos=20Cort=C3=B3n=20Cobas?=
 <104267232+carloscortonc@users.noreply.github.com>
Date: Sat, 17 Jan 2026 10:54:36 +0100
Subject: [PATCH 02/52] feat: add help-cmd & clean

---
 site/browserify.js        | 23 ++++++++++++++++
 site/commands/commands.js |  4 +--
 site/commands/help.js     |  5 ++++
 site/index.html           | 58 +++++++++++++++++++++++----------------
 site/package.json         |  2 +-
 site/shims/shims.js       |  3 --
 6 files changed, 65 insertions(+), 30 deletions(-)
 create mode 100644 site/browserify.js
 create mode 100644 site/commands/help.js

diff --git a/site/browserify.js b/site/browserify.js
new file mode 100644
index 0000000..d28860c
--- /dev/null
+++ b/site/browserify.js
@@ -0,0 +1,23 @@
+import browserify from "browserify";
+import fs from "fs";
+
+const b = browserify({
+  entries: ["cli.js"],
+  insertGlobalVars: {
+    __filename: undefined,
+    __dirname: undefined,
+    Buffer: undefined,
+    global: undefined,
+    process: undefined,
+  },
+});
+
+// plugin: esmify
+b.plugin("esmify");
+
+// requires with aliases
+b.require("./shims/fs.js", { expose: "fs" });
+b.require("./shims/url.js", { expose: "url" });
+
+// bundle output
+b.bundle().pipe(fs.createWriteStream("cli.web.js"));
diff --git a/site/commands/commands.js b/site/commands/commands.js
index b80c4ac..b963765 100644
--- a/site/commands/commands.js
+++ b/site/commands/commands.js
@@ -1,7 +1,7 @@
 export default [
   {},
   { cliDescription: "List available commands" },
-  (p) => {
-    Cli.logger.log("[Command::action]", p);
+  () => {
+    Cli.logger.log("Available commands: ", Object.keys(CLI_COMMANDS).join(", "));
   },
 ];
diff --git a/site/commands/help.js b/site/commands/help.js
new file mode 100644
index 0000000..cf92bf4
--- /dev/null
+++ b/site/commands/help.js
@@ -0,0 +1,5 @@
+export default [
+  {},
+  { cliDescription: "Display help" },
+  () => Cli.logger.log("This is a sandboxed environment to test cli-definitions"),
+];
diff --git a/site/index.html b/site/index.html
index 1fbfe08..cd39be6 100644
--- a/site/index.html
+++ b/site/index.html
@@ -6,7 +6,7 @@
     
     
     
@@ -74,9 +78,10 @@
       

cli-er - Modular Command-Line Interface Builder

Type commands to get a list of available commands

+
$
-

     
   
diff --git a/site/public/key-handler.js b/site/public/key-handler.js
new file mode 100644
index 0000000..bb6dc69
--- /dev/null
+++ b/site/public/key-handler.js
@@ -0,0 +1,5 @@
+export default function handleKey(e, cbMap) {
+  e.addEventListener("keydown", (e) => {
+    cbMap[e.key]?.(e);
+  });
+}
diff --git a/site/public/renderer.js b/site/public/renderer.js
index d3af357..d0307fb 100644
--- a/site/public/renderer.js
+++ b/site/public/renderer.js
@@ -7,6 +7,11 @@ export function renderInput(value) {
   o.appendChild(input);
 }
 
+export function updateInputValue(value) {
+  i.value = value;
+  i.setSelectionRange(value.length, value.length);
+}
+
 export function renderOutput(value, error) {
   const e = document.createElement("pre");
   e.innerText = value;

From 22c74ead3c86b0fdb2628f8d471ad9a61cf7116e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Carlos=20Cort=C3=B3n=20Cobas?=
 <104267232+carloscortonc@users.noreply.github.com>
Date: Sun, 18 Jan 2026 12:45:53 +0100
Subject: [PATCH 16/52] feat: tab

---
 site/public/index.html  | 23 ++++++++++++++++++-----
 site/public/renderer.js | 13 +++++++++----
 2 files changed, 27 insertions(+), 9 deletions(-)

diff --git a/site/public/index.html b/site/public/index.html
index ab25dba..f5056fc 100644
--- a/site/public/index.html
+++ b/site/public/index.html
@@ -64,7 +64,7 @@
         padding: 0;
         color: var(--text-color);
       }
-      #output > pre {
+      pre {
         margin: 6px 0 10px;
       }
       #output > pre.error {
@@ -79,7 +79,10 @@
       

Type commands to get a list of available commands

-
$
+
+ $ +
+

     
-    
+    
     
     Cli on the Web
   
   
@@ -119,70 +32,6 @@
         

       
     
-
-    
+    
   
 
diff --git a/site/src/index.css b/site/src/index.css
new file mode 100644
index 0000000..56860a3
--- /dev/null
+++ b/site/src/index.css
@@ -0,0 +1,85 @@
+:root {
+  --text-color: #e0dede;
+}
+* {
+  font-family: monospace;
+  font-size: 15px;
+}
+html,
+body {
+  height: 100%;
+  width: 100%;
+  margin: 0;
+  overflow: hidden;
+}
+body {
+  background: #212221;
+  color: var(--text-color);
+  margin: 20px 40px;
+  width: calc(100% - 80px);
+  height: calc(100% - 40px);
+}
+.page {
+  display: flex;
+  flex-direction: column;
+  height: 100%;
+}
+.header {
+  flex-shrink: 0;
+  margin-bottom: 40px;
+  text-align: center;
+}
+.title {
+  font-size: 17px;
+}
+.title > span {
+  font-size: inherit;
+  font-weight: bolder;
+}
+.hint {
+  font-size: 14px;
+}
+.hint > span {
+  font-size: inherit;
+  font-style: italic;
+}
+#shell {
+  flex-grow: 1;
+  overflow-y: auto;
+}
+#shell::-webkit-scrollbar {
+  width: 9px;
+  height: 12px;
+}
+#shell::-webkit-scrollbar-track {
+  background: transparent;
+}
+#shell::-webkit-scrollbar-thumb {
+  background-color: rgba(255, 255, 255, 0.05);
+  border-radius: 6px;
+  border: 3px solid transparent;
+}
+#shell::-webkit-scrollbar-thumb:hover {
+  background-color: rgba(255, 255, 255, 0.2);
+}
+.input-wrapper {
+  display: flex;
+  align-items: center;
+  width: 100%;
+  gap: 8px;
+}
+#input {
+  width: 100%;
+  border: none;
+  background: transparent;
+  outline: none;
+  padding: 0;
+  color: var(--text-color);
+}
+pre {
+  margin: 6px 0 10px;
+  white-space: pre-wrap;
+}
+#output > pre.error {
+  color: #d96464;
+}
diff --git a/site/src/main.js b/site/src/main.js
new file mode 100644
index 0000000..8259846
--- /dev/null
+++ b/site/src/main.js
@@ -0,0 +1,63 @@
+import handleKey from "./key-handler.js";
+import * as renderer from "./renderer.js";
+import * as history from "./history.js";
+import "./index.css";
+
+const builtins = { history: history.spec, clear: renderer.clearSpec };
+for (const c of Object.keys(builtins)) {
+  window.CLI_COMMANDS[c] = builtins[c];
+}
+
+// Create handler for command actions
+const blobSource = "export default (...args) => window.CLI_ACTION_REF(...args);";
+const blob = new Blob([blobSource], { type: "text/javascript" });
+const url = URL.createObjectURL(blob);
+require("url").pathToFileURL = () => ({ href: url });
+
+let [i, o, oa] = ["input", "output", "output-after"].map((id) => document.getElementById(id));
+document.addEventListener("click", () => i.focus());
+o.addEventListener("click", (e) => e.stopPropagation());
+
+// Capture cli output
+process.stdout.write = (v) => renderer.renderOutput(v);
+process.stderr.write = (v) => renderer.renderOutput(v, { error: true });
+
+handleKey(i, {
+  Enter: () => {
+    const [cmd, ...args] = i.value.split(" ");
+    if (!cmd) return;
+    renderer.renderInput(i.value);
+    history.add(i.value);
+    i.value = "";
+
+    const cliSpec = CLI_COMMANDS[cmd];
+    if (!cliSpec) {
+      return process.stderr.write(`Command not found: "${cmd}"`);
+    }
+
+    window.CLI_ACTION_REF = cliSpec[2];
+    new Cli(cliSpec[0], { ...cliSpec[1], cliName: cmd }).run(args);
+  },
+  Tab: (e) => {
+    e.preventDefault();
+    const candidates = Object.keys(CLI_COMMANDS).filter((k) => k.startsWith(i.value));
+    if (!candidates.length) return;
+    if (candidates.length === 1) {
+      i.value = candidates[0];
+      return renderer.clearOutput(oa);
+    }
+    renderer.updateOutput(candidates.join("  "));
+  },
+  ArrowUp: (e) => {
+    e.preventDefault();
+    let p = history.previous();
+    if (!p) return;
+    renderer.updateInputValue(p);
+  },
+  ArrowDown: (e) => {
+    e.preventDefault();
+    let n = history.next();
+    if (!n) return;
+    renderer.updateInputValue(n);
+  },
+});

From 0bc756ab1d669a4d15e39710ca5c57c3e97f6497 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Carlos=20Cort=C3=B3n=20Cobas?=
 <104267232+carloscortonc@users.noreply.github.com>
Date: Wed, 21 Jan 2026 20:47:34 +0100
Subject: [PATCH 23/52] chore: fix bundle script

---
 site/package.json | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/site/package.json b/site/package.json
index 48afc80..ac59def 100644
--- a/site/package.json
+++ b/site/package.json
@@ -5,7 +5,7 @@
   "type": "module",
   "scripts": {
     "build": "npm --prefix .. ci && npm --prefix .. run build && node scripts/browserify.js > ${OUTPUT_DIR:-dist}/cli.web.js",
-    "bundle": "npm run build && vite build --base=cli-er",
+    "bundle": "mkdir dist && npm run build && vite build --base=cli-er",
     "start": "OUTPUT_DIR=public npm run build && vite",
     "test": "echo \"Error: no test specified\" && exit 1"
   },

From ed02a7b58d8fba68bd60e50fbe7974f2f3f2cdee Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Carlos=20Cort=C3=B3n=20Cobas?=
 <104267232+carloscortonc@users.noreply.github.com>
Date: Wed, 21 Jan 2026 20:49:25 +0100
Subject: [PATCH 24/52] chore: missing vite dep

---
 site/package.json | 1 +
 1 file changed, 1 insertion(+)

diff --git a/site/package.json b/site/package.json
index ac59def..058c710 100644
--- a/site/package.json
+++ b/site/package.json
@@ -20,5 +20,6 @@
     "esmify": "^2.1.1"
   },
   "devDependencies": {
+    "vite": "^7.3.1"
   }
 }

From 3a92385c97b86974d3a2a6ba91df49d6ce7120e6 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Carlos=20Cort=C3=B3n=20Cobas?=
 <104267232+carloscortonc@users.noreply.github.com>
Date: Wed, 21 Jan 2026 21:08:13 +0100
Subject: [PATCH 25/52] fix: type module

---
 site/index.html     | 2 +-
 site/vite.config.js | 2 +-
 2 files changed, 2 insertions(+), 2 deletions(-)

diff --git a/site/index.html b/site/index.html
index 9852863..9521a25 100644
--- a/site/index.html
+++ b/site/index.html
@@ -15,7 +15,7 @@
         spec && (window.CLI_COMMANDS[c] = spec);
       }
     
-    
+    
     Cli on the Web
   
   
diff --git a/site/vite.config.js b/site/vite.config.js
index e8a9385..d5b10fd 100644
--- a/site/vite.config.js
+++ b/site/vite.config.js
@@ -1,6 +1,6 @@
 export default {
   build: {
     minify: false,
-    polyfillModulePreload: false,
+    modulePreload: { polyfill: false },
   },
 };

From d6142f824582a1f7024fb2145b3bc9cb83a92ebb Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Carlos=20Cort=C3=B3n=20Cobas?=
 <104267232+carloscortonc@users.noreply.github.com>
Date: Wed, 21 Jan 2026 21:17:11 +0100
Subject: [PATCH 26/52] chore: fix bundle script

---
 site/package.json | 6 +++---
 1 file changed, 3 insertions(+), 3 deletions(-)

diff --git a/site/package.json b/site/package.json
index 058c710..73185cc 100644
--- a/site/package.json
+++ b/site/package.json
@@ -4,9 +4,9 @@
   "main": "cli.js",
   "type": "module",
   "scripts": {
-    "build": "npm --prefix .. ci && npm --prefix .. run build && node scripts/browserify.js > ${OUTPUT_DIR:-dist}/cli.web.js",
-    "bundle": "mkdir dist && npm run build && vite build --base=cli-er",
-    "start": "OUTPUT_DIR=public npm run build && vite",
+    "build": "npm --prefix .. ci && npm --prefix .. run build && node scripts/browserify.js > ./public/cli.web.js",
+    "bundle": "npm run build && vite build --base=/cli-er",
+    "start": "npm run build && vite",
     "test": "echo \"Error: no test specified\" && exit 1"
   },
   "keywords": [],

From b97e4c879b8730d3703a64320fd16f27ec85c3fd Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Carlos=20Cort=C3=B3n=20Cobas?=
 <104267232+carloscortonc@users.noreply.github.com>
Date: Wed, 21 Jan 2026 21:32:14 +0100
Subject: [PATCH 27/52] chore: move cli.web.js to /src folder

---
 site/index.html   | 2 +-
 site/package.json | 2 +-
 site/src/main.js  | 1 +
 3 files changed, 3 insertions(+), 2 deletions(-)

diff --git a/site/index.html b/site/index.html
index 9521a25..b0c09aa 100644
--- a/site/index.html
+++ b/site/index.html
@@ -5,6 +5,7 @@
     
     
     
-    
     Cli on the Web
   
   
diff --git a/site/package.json b/site/package.json
index 73185cc..80d74c1 100644
--- a/site/package.json
+++ b/site/package.json
@@ -4,7 +4,7 @@
   "main": "cli.js",
   "type": "module",
   "scripts": {
-    "build": "npm --prefix .. ci && npm --prefix .. run build && node scripts/browserify.js > ./public/cli.web.js",
+    "build": "npm --prefix .. ci && npm --prefix .. run build && node scripts/browserify.js > ./src/cli.web.js",
     "bundle": "npm run build && vite build --base=/cli-er",
     "start": "npm run build && vite",
     "test": "echo \"Error: no test specified\" && exit 1"
diff --git a/site/src/main.js b/site/src/main.js
index 8259846..549c55b 100644
--- a/site/src/main.js
+++ b/site/src/main.js
@@ -1,6 +1,7 @@
 import handleKey from "./key-handler.js";
 import * as renderer from "./renderer.js";
 import * as history from "./history.js";
+import "./cli.web.js";
 import "./index.css";
 
 const builtins = { history: history.spec, clear: renderer.clearSpec };

From f58cad125665fd2f83b6590e92591b64c2a77b0d Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Carlos=20Cort=C3=B3n=20Cobas?=
 <104267232+carloscortonc@users.noreply.github.com>
Date: Thu, 22 Jan 2026 20:55:43 +0100
Subject: [PATCH 28/52] feat: bash interpreter (base)

---
 site/index.html              |  3 +-
 site/package-lock.json       | 12 +++++++-
 site/src/bash/grammar.js     | 57 ++++++++++++++++++++++++++++++++++++
 site/src/bash/interpreter.js | 42 ++++++++++++++++++++++++++
 site/src/index.css           |  7 +++++
 site/src/main.js             | 21 ++++++++-----
 site/src/renderer.js         | 25 +++++++++++++---
 7 files changed, 154 insertions(+), 13 deletions(-)
 create mode 100644 site/src/bash/grammar.js
 create mode 100644 site/src/bash/interpreter.js

diff --git a/site/index.html b/site/index.html
index b0c09aa..7dd0a65 100644
--- a/site/index.html
+++ b/site/index.html
@@ -27,7 +27,8 @@
       
- $ + $ +

       
diff --git a/site/package-lock.json b/site/package-lock.json index a30c48f..6221d9a 100644 --- a/site/package-lock.json +++ b/site/package-lock.json @@ -12,7 +12,8 @@ "browserify": "^17.0.1", "browserify-fs": "^1.0.0", "cli-er": "^0.19.0", - "esmify": "^2.1.1" + "esmify": "^2.1.1", + "ohm-js": "^17.3.0" }, "devDependencies": { "vite": "^7.3.1" @@ -3360,6 +3361,15 @@ "integrity": "sha512-nnda7W8d+A3vEIY+UrDQzzboPf1vhs4JYVhff5CDkq9QNoZY7Xrxeo/htox37j9dZf7yNHevZzqtejWgy1vCqQ==", "license": "MIT" }, + "node_modules/ohm-js": { + "version": "17.3.0", + "resolved": "https://registry.npmjs.org/ohm-js/-/ohm-js-17.3.0.tgz", + "integrity": "sha512-LySMdjweN1hKBMMV8lM44+1wiewkndDNNJxtgVAscs7y683MXCdQZLsIaw64/p8NuqYbKOWZoHIOA5DU/xchoA==", + "license": "MIT", + "engines": { + "node": ">=0.12.1" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", diff --git a/site/src/bash/grammar.js b/site/src/bash/grammar.js new file mode 100644 index 0000000..1eeaa09 --- /dev/null +++ b/site/src/bash/grammar.js @@ -0,0 +1,57 @@ +import * as ohm from "ohm-js"; + +export const grammar = ohm.grammar(String.raw` + Shell { + Statement = OrStatement + OrStatement = OrStatement ("||" AndStatement)* -- or + | AndStatement + AndStatement = AndStatement ("&&" PipeStatement)* -- and + | PipeStatement + PipeStatement = PipeStatement ("|" Paren)* -- pipe + | Paren + Paren = "(" OrStatement ")" -- paren + | Expr + Expr = keyword (spaces Arg)+ -- args + | keyword -- alone + Arg = "-"* Value + Value = keyword | number + keyword = ~"-" ~number (letter | "-" | ".")+ + number = digit+ + } +`); + +export const semantics = grammar.createSemantics().addOperation("ast", { + OrStatement_or(a, _, b) { + return { type: "or", args: [a.ast(), ...b.ast()] }; + }, + AndStatement_and(a, _, b) { + return { type: "and", args: [a.ast(), ...b.ast()] }; + }, + PipeStatement_pipe(a, _, b) { + return { type: "pipe", args: [a.ast(), ...b.ast()] }; + }, + Paren_paren(_, e, __) { + return e.ast(); + }, + Expr_args(cmd, _, t) { + return { type: "cmd", cmd: cmd.ast(), args: t.ast() }; + }, + Expr_alone(cmd) { + return { type: "cmd", cmd: cmd.ast(), args: [] }; + }, + Arg(f, w) { + return "".concat(f.sourceString, w.sourceString); + }, + Value(v) { + return v.ast(); + }, + keyword(w) { + return w.sourceString; + }, + _iter(...children) { + return children.map((c) => c.ast()); + }, + _terminal() { + return ""; + }, +}); diff --git a/site/src/bash/interpreter.js b/site/src/bash/interpreter.js new file mode 100644 index 0000000..205ff91 --- /dev/null +++ b/site/src/bash/interpreter.js @@ -0,0 +1,42 @@ +import { grammar, semantics } from "./grammar.js"; + +async function executeAst(node) { + let r; + if (node.type === "cmd") { + const cliSpec = CLI_COMMANDS[node.cmd]; + console.log("[execute::cmd] args:", JSON.stringify(node.args)); + if (!cliSpec) { + process.stderr.write(`Command not found: "${node.cmd}"`); + return false; + } + window.CLI_ACTION_REF = cliSpec[2]; + return await new Cli(cliSpec[0], { ...cliSpec[1], cliName: node.cmd }).run(node.args); + } + if (node.type === "and") { + for (const child of node.args) { + if (!(r = await executeAst(child))) return false; + } + return r; + } + if (node.type === "or") { + for (const child of node.args) { + if ((r = await executeAst(child))) return r; + } + return false; + } + if (node.type === "pipe") { + const nextInput = await execute(node.args[0]); + process.env.PIPED = nextInput; + // set nextInput into process.env + return await executeAst(node.args[1]); + } +} + +export default async function execute(input) { + const r = grammar.match(input); + if (!r.succeeded()) { + process.stderr.write(r.message); + } + const ast = semantics(r).ast(); + return executeAst(ast); +} diff --git a/site/src/index.css b/site/src/index.css index 56860a3..392956a 100644 --- a/site/src/index.css +++ b/site/src/index.css @@ -68,6 +68,9 @@ body { width: 100%; gap: 8px; } +#sprompt.executing { + display: none; +} #input { width: 100%; border: none; @@ -79,6 +82,10 @@ body { pre { margin: 6px 0 10px; white-space: pre-wrap; + line-height: 20px; +} +pre[data-id] { + margin-bottom: 0; } #output > pre.error { color: #d96464; diff --git a/site/src/main.js b/site/src/main.js index 549c55b..12f4b3b 100644 --- a/site/src/main.js +++ b/site/src/main.js @@ -1,6 +1,7 @@ import handleKey from "./key-handler.js"; import * as renderer from "./renderer.js"; import * as history from "./history.js"; +import execute from "./bash/interpreter.js"; import "./cli.web.js"; import "./index.css"; @@ -20,16 +21,22 @@ document.addEventListener("click", () => i.focus()); o.addEventListener("click", (e) => e.stopPropagation()); // Capture cli output -process.stdout.write = (v) => renderer.renderOutput(v); -process.stderr.write = (v) => renderer.renderOutput(v, { error: true }); +const r = (...args) => renderer.renderOutput(...args); +process.stdout.write = (v) => r(v); +process.stderr.write = (v) => r(v, { error: true }); handleKey(i, { Enter: () => { - const [cmd, ...args] = i.value.split(" "); - if (!cmd) return; - renderer.renderInput(i.value); - history.add(i.value); + let inputValue = i.value; + if (!inputValue) return; + history.add(inputValue); + renderer.renderInput(inputValue); i.value = ""; + execute(inputValue).finally(() => { + renderer.flushOutput(); + }); + /* const [cmd, ...args] = i.value.split(" "); + if (!cmd) return; const cliSpec = CLI_COMMANDS[cmd]; if (!cliSpec) { @@ -37,7 +44,7 @@ handleKey(i, { } window.CLI_ACTION_REF = cliSpec[2]; - new Cli(cliSpec[0], { ...cliSpec[1], cliName: cmd }).run(args); + new Cli(cliSpec[0], { ...cliSpec[1], cliName: cmd }).run(args); */ }, Tab: (e) => { e.preventDefault(); diff --git a/site/src/renderer.js b/site/src/renderer.js index 7c44468..5f85c97 100644 --- a/site/src/renderer.js +++ b/site/src/renderer.js @@ -1,9 +1,14 @@ -let [s, i, o, oa] = ["shell", "input", "output", "output-after"].map((id) => document.getElementById(id)); +let [s, i, sp, o, oa] = ["shell", "input", "sprompt", "output", "output-after"].map((id) => + document.getElementById(id), +); + +const OUTPUT_ID = "exec"; export function renderInput(value) { const input = document.createElement("div"); input.className = "input-wrapper"; input.innerHTML = `$${value}`; + sp.classList.add("executing"); clearOutput(oa); o.appendChild(input); } @@ -14,14 +19,26 @@ export function updateInputValue(value) { } export function renderOutput(value, { error } = {}) { - const e = document.createElement("pre"); - e.innerText = value; + const r = document.querySelector(`#output>pre[data-id="${OUTPUT_ID}"]`) || undefined; + const e = r || document.createElement("pre"); + let c = e.innerText; + e.innerText = c.concat(c ? "\n" : "", value); error && (e.className = "error"); - o.appendChild(e); + if (!r) { + e.setAttribute("data-id", OUTPUT_ID); + o.appendChild(e); + } clearOutput(oa); s.scrollTop = s.scrollHeight; } +export function flushOutput() { + let e = document.querySelector(`#output>pre[data-id="${OUTPUT_ID}"]`); + if (!e) return; + e.removeAttribute("data-id"); + sp.classList.remove("executing"); +} + export function updateOutput(value) { oa.innerText = value; } From 51995c2f13583d23aba44f8b1e1cf4a1414382e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Cort=C3=B3n=20Cobas?= <104267232+carloscortonc@users.noreply.github.com> Date: Thu, 22 Jan 2026 20:58:42 +0100 Subject: [PATCH 29/52] chore: missing package-json changes --- site/package.json | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/site/package.json b/site/package.json index 80d74c1..129c0bf 100644 --- a/site/package.json +++ b/site/package.json @@ -1,5 +1,5 @@ { - "name": "web", + "name": "site", "version": "1.0.0", "main": "cli.js", "type": "module", @@ -17,7 +17,8 @@ "browserify": "^17.0.1", "browserify-fs": "^1.0.0", "cli-er": "^0.19.0", - "esmify": "^2.1.1" + "esmify": "^2.1.1", + "ohm-js": "^17.3.0" }, "devDependencies": { "vite": "^7.3.1" From b6c2f296c21e63642bbae0d06453b29478d358ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Cort=C3=B3n=20Cobas?= <104267232+carloscortonc@users.noreply.github.com> Date: Fri, 23 Jan 2026 19:30:52 +0100 Subject: [PATCH 30/52] feat: handle bash logic operations --- site/index.html | 10 +++++++--- site/public/assets/github.svg | 3 +++ site/public/commands/echo.js | 5 +++++ site/scripts/inject-base.cjs | 8 -------- site/shims/fs.js | 7 ++----- site/shims/shims.js | 5 +++-- site/src/bash/grammar.js | 21 ++++++++++----------- site/src/bash/interpreter.js | 33 ++++++++++++++++++++------------- site/src/index.css | 25 ++++++++++++++++++++----- site/src/main.js | 10 ---------- site/src/renderer.js | 5 ++--- 11 files changed, 72 insertions(+), 60 deletions(-) create mode 100755 site/public/assets/github.svg create mode 100644 site/public/commands/echo.js delete mode 100644 site/scripts/inject-base.cjs diff --git a/site/index.html b/site/index.html index 7dd0a65..588361a 100644 --- a/site/index.html +++ b/site/index.html @@ -7,8 +7,9 @@ diff --git a/site/public/commands/commands.js b/site/public/commands/commands.js index 29b6a32..8d48021 100644 --- a/site/public/commands/commands.js +++ b/site/public/commands/commands.js @@ -1,7 +1,5 @@ -export default [ - {}, - { cliDescription: "List available commands", help: { hidden: true } }, - () => { - Cli.logger.log("Available commands: ", Object.keys(CLI_COMMANDS).join(", ")); - }, -]; +export default { + definition: {}, + cliOptions: { cliDescription: "List available commands", help: { hidden: true } }, + action: () => Cli.logger.log("Available commands: ", Object.keys(CLI_COMMANDS).join(", "), "\n"), +}; diff --git a/site/public/commands/echo.js b/site/public/commands/echo.js index f5166f8..3e12600 100644 --- a/site/public/commands/echo.js +++ b/site/public/commands/echo.js @@ -1,5 +1,5 @@ -export default [ - { args: { type: "string", positional: true } }, - { help: { hidden: true } }, - ({ args }) => args && Cli.logger.log(args.join(" "), "\n"), -]; +export default { + definition: { args: { type: "string", positional: true } }, + cliOptions: { help: { hidden: true } }, + action: ({ args }) => args && Cli.logger.log(args.join(" "), "\n"), +}; diff --git a/site/public/commands/help.js b/site/public/commands/help.js index 4e243b1..dad768a 100644 --- a/site/public/commands/help.js +++ b/site/public/commands/help.js @@ -1,5 +1,5 @@ -export default [ - {}, - { cliDescription: "Display help", help: { hidden: true } }, - () => Cli.logger.log("This is a sandboxed environment to test cli-definitions"), -]; +export default { + definition: {}, + cliOptions: { cliDescription: "Display help", help: { hidden: true } }, + action: () => Cli.logger.log("This is a sandboxed environment to test cli-definitions"), +}; diff --git a/site/src/bash/interpreter.js b/site/src/bash/interpreter.js index 4e06631..338d2fd 100644 --- a/site/src/bash/interpreter.js +++ b/site/src/bash/interpreter.js @@ -8,12 +8,12 @@ async function executeAst(node) { process.stderr.write(`cliersh: command not found: "${node.cmd}"\n`); return process.exit(1); } - window.CLI_ACTION_REF = cliSpec[2]; + window.CLI_ACTION_REF = cliSpec.action; //TODO capture output into FD[1], capture error into FD[2] - const c = new Cli(cliSpec[0], { ...cliSpec[1], cliName: node.cmd }); + const c = new Cli(cliSpec.definition || {}, { ...cliSpec.cliOptions, cliName: node.cmd }); // Update default help template c.options.help.template = - cliSpec[1].help?.template || "{usage}\n{description}\n{namespaces}\n{commands}\n{options}"; + cliSpec.cliOptions.help?.template || "{usage}\n{description}\n{namespaces}\n{commands}\n{options}"; // Reset exitCode before executing command process.exitCode = 0; From 9d23b385aa29e6440f14d9f240ae6ba99768c0a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Cort=C3=B3n=20Cobas?= <104267232+carloscortonc@users.noreply.github.com> Date: Sat, 24 Jan 2026 20:34:00 +0100 Subject: [PATCH 33/52] feat: separate examples/docker def & allow remotes --- docs/api.md | 2 +- docs/cli-options.md | 4 +- examples/docker/definition.js | 136 +++++++++++++++++++++++++++++++ examples/docker/docker.js | 146 ++-------------------------------- site/index.html | 23 ++++-- site/package.json | 2 +- site/src/bash/interpreter.js | 3 +- site/src/history.js | 10 +-- site/src/renderer.js | 10 +-- 9 files changed, 176 insertions(+), 160 deletions(-) create mode 100644 examples/docker/definition.js diff --git a/docs/api.md b/docs/api.md index b4b3caf..d2e7ad2 100644 --- a/docs/api.md +++ b/docs/api.md @@ -73,7 +73,7 @@ This method has three main behaviours: print version, print help and execute a c - **print help**: if [autoincluded help](/docs/cli-options.md#helpautoinclude) is enabled and help option is provided, or a cli without `rootCommand` is invoked without location, or a namespace is invoked, help will be generated. If any errors configured in [`CliOptions.errors.onGenerateHelp`](/docs/cli-options.md#errorsongeneratehelp) are generated, they will be outputted before the help. - **execute command**: if any errors configured in [`CliOptions.errors.onExecuteCommand`](/docs/cli-options.md#errorsonexecutecommand) are generated, they will be printed and execution will end with status `1`. Otherwise, the script location will be calculated, and the corresponding script executed. -If a cli application does not have registered a root command (logic executed without any supplied namespace/command), it should be configured with [`CliOptions.rootCommand: false`](/docs/cli-options.md#rootcommand). By doing this, when the cli application is invoked with no arguments, full help will be shown (see this [docker example](/examples/docker/docker.js#L128)). +If a cli application does not have registered a root command (logic executed without any supplied namespace/command), it should be configured with [`CliOptions.rootCommand: false`](/docs/cli-options.md#rootcommand). By doing this, when the cli application is invoked with no arguments, full help will be shown (see this [docker example](/examples/docker/definition.js#L130)). You also use `CliOptions.rootCommand` to define a default command to execute, when no command/namespace is supplied (check this [webpack-cli example](/examples/webpack-cli)). diff --git a/docs/cli-options.md b/docs/cli-options.md index 59c6b89..5073154 100644 --- a/docs/cli-options.md +++ b/docs/cli-options.md @@ -44,7 +44,7 @@ Aliases to be used for help option
##### `help.description` Description for the option ##### `help.template` -Template to be used when generating help. There are five distinct sections: **usage**, **description**, **namespaces**, **commands** and **options**. This can be used to include a header/footer, change the order of the sections, or remove a section altogether. If a section has no content, it will be removed along with any line-breaks that follow. You can see a use-case for this in the [docker example](/examples/docker/docker.js#L130)
+Template to be used when generating help. There are five distinct sections: **usage**, **description**, **namespaces**, **commands** and **options**. This can be used to include a header/footer, change the order of the sections, or remove a section altogether. If a section has no content, it will be removed along with any line-breaks that follow. You can see a use-case for this in the [docker example](/examples/docker/definition.js#L133)
**Default**: `\n{usage}\n{description}\n{namespaces}\n{commands}\n{options}\n` #### `version` @@ -64,7 +64,7 @@ If a string is provided, it will be used as the default command to execute
**Default**: `true` #### `logger` -Logger to be used by the cli. It contains two methods, `log` and `error`, that can be used to add a prefix to the log (e.g. "error ") or change the output color, as demonstrated in this [docker example](/examples/docker/docker.js#L133).
+Logger to be used by the cli. It contains two methods, `log` and `error`, that can be used to add a prefix to the log (e.g. "error ") or change the output color, as demonstrated in this [docker example](/examples/docker/docker.js#L8).
**Default**: [/src/cli-logger.ts](/src/cli-logger.ts) #### `cliName` diff --git a/examples/docker/definition.js b/examples/docker/definition.js new file mode 100644 index 0000000..218e910 --- /dev/null +++ b/examples/docker/definition.js @@ -0,0 +1,136 @@ +export default { + definition: { + builder: { + description: "Manage builds", + options: { + build: { + description: "Build an image from a Dockerfile", + usage: Cli.formatMessage("generate-help.has-options").concat(" PATH | URL | -"), + options: { + source: { + positional: 0, + required: true, + description: "Path or Url to the Dockerfile", + }, + addHost: { + aliases: ["add-host"], + type: "list", + description: "Add a custom host-to-IP mapping (host:ip)", + }, + buildArg: { + aliases: ["build-arg"], + type: "list", + description: "Set build-time variables", + }, + cacheFrom: { + aliases: ["cache-from"], + type: "list", + description: "Images to consider as cache sources", + }, + disableContentTrust: { + aliases: ["disable-content-trust"], + type: "boolean", + default: true, + description: "Skip image verification", + }, + file: { + aliases: ["f", "file"], + description: "Name of the Dockerfile (Default is 'PATH/Dockerfile')", + }, + iidfile: { + description: "Write the image ID to the file", + }, + isolation: { + description: "Container isolation technology", + }, + label: { + type: "list", + description: "Set metadata for an image", + }, + network: { + default: "default", + description: "Set the networking mode for the RUN instructions during build", + }, + noCache: { + aliases: ["no-cache"], + type: "boolean", + description: "Do not use cache when building the image", + }, + output: { + aliases: ["o", "output"], + description: "Output destination (format: type=local,dest=path)", + }, + platform: { + description: "Set platform if server is multi-platform capable", + }, + progress: { + default: "auto", + description: "Set type of progress output (auto, plain, tty). Use plain to show container output", + }, + pull: { + type: "boolean", + description: "Always attempt to pull a newer version of the image", + }, + quiet: { + aliases: ["q", "quiet"], + type: "boolean", + description: "Suppress the build output and print image ID on success", + }, + secret: { + description: "Secret file to expose to the build (only if BuildKit enabled)", + }, + ssh: { + description: "SSH agent socket or keys to expose to the build (only if BuildKit enabled)", + }, + tag: { + aliases: ["t", "tag"], + type: "list", + description: "Name and optionally a tag in the 'name:tag' format", + }, + target: { + description: "Set the target build stage to build", + }, + }, + }, + prune: { + description: "Remove build cache", + options: { + all: { + aliases: ["a", "all"], + type: "boolean", + description: "Remove all unused build cache, not just dangling ones", + }, + filter: { + description: "Provide filter values (e.g. 'until=24h')", + }, + force: { + aliases: ["f", "force"], + type: "boolean", + description: "Do not prompt for confirmation", + }, + keepStorage: { + aliases: ["keep-storage"], + type: "number", + description: "Amount of disk space to keep for cache", + }, + }, + }, + }, + }, + debug: { + description: "Enable debug mode", + aliases: ["D", "debug"], + type: "boolean", + negatable: true, + default: false, + }, + }, + cliOptions: { + cliName: "docker", + rootCommand: false, + help: { + template: + "\n{usage}\n{description}\n{namespaces}\n{commands}\n{options}\nRun 'docker COMMAND --help' for more information on a command.\n", + }, + }, +}; diff --git a/examples/docker/docker.js b/examples/docker/docker.js index 3076414..2189433 100644 --- a/examples/docker/docker.js +++ b/examples/docker/docker.js @@ -1,140 +1,10 @@ -const Cli = require("cli-er"); +// Use "globaThis" so `/definition.js` is compatible with the web +globalThis.Cli = require("cli-er"); +const { default: docker } = require("./definition"); -new Cli( - { - builder: { - description: "Manage builds", - options: { - build: { - description: "Build an image from a Dockerfile", - usage: Cli.formatMessage("generate-help.has-options").concat(" PATH | URL | -"), - options: { - source: { - positional: 0, - required: true, - description: "Path or Url to the Dockerfile", - }, - addHost: { - aliases: ["add-host"], - type: "list", - description: "Add a custom host-to-IP mapping (host:ip)", - }, - buildArg: { - aliases: ["build-arg"], - type: "list", - description: "Set build-time variables", - }, - cacheFrom: { - aliases: ["cache-from"], - type: "list", - description: "Images to consider as cache sources", - }, - disableContentTrust: { - aliases: ["disable-content-trust"], - type: "boolean", - default: true, - description: "Skip image verification", - }, - file: { - aliases: ["f", "file"], - description: "Name of the Dockerfile (Default is 'PATH/Dockerfile')", - }, - iidfile: { - description: "Write the image ID to the file", - }, - isolation: { - description: "Container isolation technology", - }, - label: { - type: "list", - description: "Set metadata for an image", - }, - network: { - default: "default", - description: "Set the networking mode for the RUN instructions during build", - }, - noCache: { - aliases: ["no-cache"], - type: "boolean", - description: "Do not use cache when building the image", - }, - output: { - aliases: ["o", "output"], - description: "Output destination (format: type=local,dest=path)", - }, - platform: { - description: "Set platform if server is multi-platform capable", - }, - progress: { - default: "auto", - description: "Set type of progress output (auto, plain, tty). Use plain to show container output", - }, - pull: { - type: "boolean", - description: "Always attempt to pull a newer version of the image", - }, - quiet: { - aliases: ["q", "quiet"], - type: "boolean", - description: "Suppress the build output and print image ID on success", - }, - secret: { - description: "Secret file to expose to the build (only if BuildKit enabled)", - }, - ssh: { - description: "SSH agent socket or keys to expose to the build (only if BuildKit enabled)", - }, - tag: { - aliases: ["t", "tag"], - type: "list", - description: "Name and optionally a tag in the 'name:tag' format", - }, - target: { - description: "Set the target build stage to build", - }, - }, - }, - prune: { - description: "Remove build cache", - options: { - all: { - aliases: ["a", "all"], - type: "boolean", - description: "Remove all unused build cache, not just dangling ones", - }, - filter: { - description: "Provide filter values (e.g. 'until=24h')", - }, - force: { - aliases: ["f", "force"], - type: "boolean", - description: "Do not prompt for confirmation", - }, - keepStorage: { - aliases: ["keep-storage"], - type: "number", - description: "Amount of disk space to keep for cache", - }, - }, - }, - }, - }, - debug: { - description: "Enable debug mode", - aliases: ["D", "debug"], - type: "boolean", - negatable: true, - default: false, - }, +new Cli(docker.definition, { + ...docker.cliOptions, + logger: { + error: (...message) => process.stderr.write("\x1b[31mERROR ".concat(message.join(" "), "\x1b[0m")), }, - { - rootCommand: false, - help: { - template: - "\n{usage}\n{description}\n{namespaces}\n{commands}\n{options}\nRun 'docker COMMAND --help' for more information on a command.\n", - }, - logger: { - error: (...message) => process.stderr.write("\x1b[31mERROR ".concat(message.join(" "), "\x1b[0m")), - }, - }, -).run(); +}).run(); diff --git a/site/index.html b/site/index.html index f210735..db20d88 100644 --- a/site/index.html +++ b/site/index.html @@ -6,17 +6,27 @@ + + Cli on the Web @@ -39,6 +49,5 @@

       
     
-    
   
 
diff --git a/site/package.json b/site/package.json
index 129c0bf..e779a19 100644
--- a/site/package.json
+++ b/site/package.json
@@ -6,7 +6,7 @@
   "scripts": {
     "build": "npm --prefix .. ci && npm --prefix .. run build && node scripts/browserify.js > ./src/cli.web.js",
     "bundle": "npm run build && vite build --base=/cli-er",
-    "start": "npm run build && vite",
+    "start": "npm run build && (npx http-server .. --port=8000 --cors &) && vite",
     "test": "echo \"Error: no test specified\" && exit 1"
   },
   "keywords": [],
diff --git a/site/src/bash/interpreter.js b/site/src/bash/interpreter.js
index 338d2fd..d312a24 100644
--- a/site/src/bash/interpreter.js
+++ b/site/src/bash/interpreter.js
@@ -17,7 +17,8 @@ async function executeAst(node) {
 
     // Reset exitCode before executing command
     process.exitCode = 0;
-    await c.run(node.args).then(() => process.exit(process.exitCode));
+    await c.run(node.args);
+    process.exit(process.exitCode);
   }
   if (node.type === "and") {
     for (const child of node.args) {
diff --git a/site/src/history.js b/site/src/history.js
index dd453b2..cf9ae06 100644
--- a/site/src/history.js
+++ b/site/src/history.js
@@ -18,14 +18,14 @@ export function cmd(params) {
   Cli.logger.log(output);
 }
 
-export const spec = [
-  {
+export const spec = {
+  definition: {
     n: { type: "number", positional: 0, description: "Start at index n" },
     c: { type: "boolean", description: "Clear the history list" },
   },
-  { cliDescription: "Command line history" },
-  cmd,
-];
+  cliOptions: { cliDescription: "Command line history" },
+  action: cmd,
+};
 
 export function add(value) {
   CLI_HISTORY.push(value);
diff --git a/site/src/renderer.js b/site/src/renderer.js
index d6d7083..49f551e 100644
--- a/site/src/renderer.js
+++ b/site/src/renderer.js
@@ -54,8 +54,8 @@ export function clearOutput(e = o) {
   e.innerHTML = "";
 }
 
-export const clearSpec = [
-  {},
-  { cliDescription: "Clear the terminal screen", help: { hidden: true } },
-  () => clearOutput(),
-];
+export const clearSpec = {
+  definition: {},
+  cliOptions: { cliDescription: "Clear the terminal screen", help: { hidden: true } },
+  action: () => clearOutput(),
+};

From fcfc1a9d42666c0127bbd8e79c98bce9c9af6257 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Carlos=20Cort=C3=B3n=20Cobas?=
 <104267232+carloscortonc@users.noreply.github.com>
Date: Sat, 24 Jan 2026 21:13:20 +0100
Subject: [PATCH 34/52] feat: lightmode

---
 site/index.html              |  5 ++++-
 site/public/assets/theme.svg |  4 ++++
 site/src/index.css           | 24 +++++++++++++++++++-----
 site/src/main.js             |  5 ++++-
 4 files changed, 31 insertions(+), 7 deletions(-)
 create mode 100644 site/public/assets/theme.svg

diff --git a/site/index.html b/site/index.html
index db20d88..bf13a62 100644
--- a/site/index.html
+++ b/site/index.html
@@ -38,7 +38,10 @@
           

cli-er - Modular Command-Line Interface Builder

Type commands to get a list of available commands

-
+
+
+
+
diff --git a/site/public/assets/theme.svg b/site/public/assets/theme.svg new file mode 100644 index 0000000..5be19e0 --- /dev/null +++ b/site/public/assets/theme.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/site/src/index.css b/site/src/index.css index c8949fe..bb43329 100644 --- a/site/src/index.css +++ b/site/src/index.css @@ -1,5 +1,6 @@ :root { - --text-color: #c2c2c2; + --bg-color: #212221; + --fg-color: #c2c2c2; } * { font-family: monospace; @@ -13,12 +14,16 @@ body { overflow: hidden; } body { - background: #212221; - color: var(--text-color); + background: var(--bg-color); + color: var(--fg-color); margin: 20px 40px; width: calc(100% - 80px); height: calc(100% - 40px); } +body.light { + --bg-color: #eaeaea; + --fg-color: #474747; +} .page { display: flex; flex-direction: column; @@ -32,6 +37,10 @@ body { text-align: center; flex: auto; } +.actions { + display: flex; + gap: 12px; +} .icon { height: 30px; width: 30px; @@ -40,7 +49,12 @@ body { .icon.github { mask: url("/assets/github.svg") no-repeat center; mask-size: contain; - background: var(--text-color); + background: var(--fg-color); +} +.icon.theme { + mask: url("/assets/theme.svg") no-repeat center; + mask-size: contain; + background: var(--fg-color); } .title { font-size: 17px; @@ -92,7 +106,7 @@ body { background: transparent; outline: none; padding: 0; - color: var(--text-color); + color: var(--fg-color); } pre { margin: 2px 0 6px; diff --git a/site/src/main.js b/site/src/main.js index fbc220a..759f7a9 100644 --- a/site/src/main.js +++ b/site/src/main.js @@ -16,9 +16,12 @@ const blob = new Blob([blobSource], { type: "text/javascript" }); const url = URL.createObjectURL(blob); require("url").pathToFileURL = () => ({ href: url }); -let [i, o, oa] = ["input", "output", "output-after"].map((id) => document.getElementById(id)); +let [i, o, oa, lm] = ["input", "output", "output-after", "theme"].map((id) => document.getElementById(id)); document.addEventListener("click", () => i.focus()); o.addEventListener("click", (e) => e.stopPropagation()); +lm.addEventListener("click", () => + document.body.classList[document.body.classList.contains("light") ? "remove" : "add"]("light"), +); // Capture cli output const r = (...args) => renderer.renderOutput(...args); From e7df4e31897ce438223066898a5e795e7142ecc9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Cort=C3=B3n=20Cobas?= <104267232+carloscortonc@users.noreply.github.com> Date: Sat, 24 Jan 2026 21:16:30 +0100 Subject: [PATCH 35/52] fix: wrong command paths --- site/index.html | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/site/index.html b/site/index.html index bf13a62..c5b243b 100644 --- a/site/index.html +++ b/site/index.html @@ -19,7 +19,12 @@ const remote = (path) => remoteBase.concat("/", path); // List of available commands - const commands = ["/commands/commands", "/commands/help", "/commands/echo", remote("examples/docker/definition")]; + const commands = [ + "./commands/commands", + "./commands/help", + "./commands/echo", + remote("examples/docker/definition"), + ]; for (const c of commands) { const spec = await import(/* @vite-ignore */ `${c}.js`) From f539f1e84bd6913d9c559c8a89ed9d7e55984a53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Cort=C3=B3n=20Cobas?= <104267232+carloscortonc@users.noreply.github.com> Date: Mon, 26 Jan 2026 22:47:20 +0100 Subject: [PATCH 36/52] feat: move cli execution to web-worker --- site/cli.js | 2 +- site/index.html | 13 ++++------ site/shims/shims.js | 5 ++-- site/src/bash/cli-runner.js | 15 +++++++++++ site/src/bash/exec-worker.js | 19 ++++++++++++++ site/src/bash/interpreter.js | 25 +++++++++++++++++++ site/src/bash/serializer.js | 20 +++++++++++++++ .../commands => src/builtins}/commands.js | 2 +- site/src/builtins/index.js | 1 + site/src/main.js | 10 +++++--- 10 files changed, 96 insertions(+), 16 deletions(-) create mode 100644 site/src/bash/cli-runner.js create mode 100644 site/src/bash/exec-worker.js create mode 100644 site/src/bash/serializer.js rename site/{public/commands => src/builtins}/commands.js (88%) create mode 100644 site/src/builtins/index.js diff --git a/site/cli.js b/site/cli.js index 48360b3..17e72a2 100644 --- a/site/cli.js +++ b/site/cli.js @@ -1,4 +1,4 @@ import "./shims/shims.js"; import Cli from "../dist/index.js"; -window.Cli = Cli; +globalThis.Cli = Cli; diff --git a/site/index.html b/site/index.html index c5b243b..a848d2d 100644 --- a/site/index.html +++ b/site/index.html @@ -19,19 +19,16 @@ const remote = (path) => remoteBase.concat("/", path); // List of available commands - const commands = [ - "./commands/commands", - "./commands/help", - "./commands/echo", - remote("examples/docker/definition"), - ]; + const commands = ["./commands/help", "./commands/echo", remote("examples/docker/definition")]; for (const c of commands) { const spec = await import(/* @vite-ignore */ `${c}.js`) .then((r) => r.default) .catch((e) => (console.log(e), undefined)); - spec && - (window.CLI_COMMANDS[spec.cliOptions.cliName || c.split("/").find((_, i, l) => i == l.length - 1)] = spec); + if (spec) { + const specKey = spec.cliOptions.cliName || c.split("/").find((_, i, l) => i == l.length - 1); + window.CLI_COMMANDS[specKey] = { ...spec, builtin: false }; + } } Cli on the Web diff --git a/site/shims/shims.js b/site/shims/shims.js index 889a41f..1d310d1 100644 --- a/site/shims/shims.js +++ b/site/shims/shims.js @@ -1,6 +1,7 @@ -window.process = { +globalThis.require = undefined; +globalThis.process = { argv: [], - stdout: { columns: window.CLI_COLUMNS || 50, write: console.log }, + stdout: { columns: globalThis.CLI_COLUMNS || 50, write: console.log }, stdin: { isTTY: true }, stderr: { write: console.log }, cwd: () => "", diff --git a/site/src/bash/cli-runner.js b/site/src/bash/cli-runner.js new file mode 100644 index 0000000..c40cef9 --- /dev/null +++ b/site/src/bash/cli-runner.js @@ -0,0 +1,15 @@ +// Create a new Cli instance and run it with the provided arguments +export default async function run({ name, cliSpec, args }) { + const c = new Cli(cliSpec.definition || {}, { ...cliSpec.cliOptions, cliName: name }); + // Update default help template + c.options.help.template = + cliSpec.cliOptions.help?.template || "{usage}\n{description}\n{namespaces}\n{commands}\n{options}"; + + // Setup handler callback + globalThis.CLI_ACTION_REF = cliSpec.action || (() => process.stderr.write("Not implemented\n")); + + // Reset exitCode before executing command + process.exitCode = 0; + + return c.run(args); +} diff --git a/site/src/bash/exec-worker.js b/site/src/bash/exec-worker.js new file mode 100644 index 0000000..07ba4d4 --- /dev/null +++ b/site/src/bash/exec-worker.js @@ -0,0 +1,19 @@ +import "../cli.web.js"; +import { deserialize } from "./serializer.js"; +import run from "./cli-runner.js"; + +// Send output to main thread +process.stdout.write = (value) => postMessage({ type: "output", stream: "stdout", value }); +process.stderr.write = (value) => postMessage({ type: "output", stream: "stderr", value }); + +self.onmessage = async (e) => { + const { name, cliSpec, args, env, cliHandlerUrl } = deserialize(e.data); + // Setup handler url + require("url").pathToFileURL = () => ({ href: cliHandlerUrl }); + // Copy env values + process.env = env; + + await run({ name, cliSpec, args }); + + postMessage({ type: "exit", exitCode: process.exitCode }); +}; diff --git a/site/src/bash/interpreter.js b/site/src/bash/interpreter.js index d312a24..55392b8 100644 --- a/site/src/bash/interpreter.js +++ b/site/src/bash/interpreter.js @@ -1,13 +1,38 @@ import { grammar, semantics } from "./grammar.js"; +import ExecWorker from "./exec-worker?worker"; +import { serialize } from "./serializer.js"; +import run from "./cli-runner.js"; + +const execWorker = new ExecWorker(); async function executeAst(node) { if (node.type === "cmd") { const cliSpec = CLI_COMMANDS[node.cmd]; console.log(`[execute::cmd] ${node.cmd} args=${JSON.stringify(node.args)}`); + if (!cliSpec) { process.stderr.write(`cliersh: command not found: "${node.cmd}"\n`); return process.exit(1); } + + // Handle "builtins" that need access to internals (e.g. renderer), and would not otherwise work from web-worker + if (cliSpec.builtin) { + await run({ name: node.cmd, cliSpec, args: node.args }); + return process.exit(process.exitCode); + } + + return new Promise((resolve) => { + execWorker.postMessage(serialize({ name: node.cmd, cliSpec, args: node.args, env: process.env, cliHandlerUrl })); + + execWorker.onmessage = ({ data }) => { + if (data.type === "output") { + process[data.stream].write(data.value); + } else if (data.type === "exit") { + process.exit(data.exitCode); + resolve(); + } + }; + }); window.CLI_ACTION_REF = cliSpec.action; //TODO capture output into FD[1], capture error into FD[2] const c = new Cli(cliSpec.definition || {}, { ...cliSpec.cliOptions, cliName: node.cmd }); diff --git a/site/src/bash/serializer.js b/site/src/bash/serializer.js new file mode 100644 index 0000000..5560fff --- /dev/null +++ b/site/src/bash/serializer.js @@ -0,0 +1,20 @@ +// Inspired by jsonfn, while waiting for this to get merged: https://github.com/vkiryukhin/jsonfn/pull/25 +const functionPrefix = "__fn__ "; + +export function serialize(data) { + return JSON.stringify(data, (_, v) => { + if (typeof v === "function") { + return functionPrefix.concat(v.toString()); + } + return v; + }); +} + +export function deserialize(data) { + return JSON.parse(data, (_, v) => { + if (typeof v === "string" && v.startsWith(functionPrefix)) { + return eval(v.slice(functionPrefix.length)); + } + return v; + }); +} diff --git a/site/public/commands/commands.js b/site/src/builtins/commands.js similarity index 88% rename from site/public/commands/commands.js rename to site/src/builtins/commands.js index 8d48021..a771652 100644 --- a/site/public/commands/commands.js +++ b/site/src/builtins/commands.js @@ -1,4 +1,4 @@ -export default { +export const commands = { definition: {}, cliOptions: { cliDescription: "List available commands", help: { hidden: true } }, action: () => Cli.logger.log("Available commands: ", Object.keys(CLI_COMMANDS).join(", "), "\n"), diff --git a/site/src/builtins/index.js b/site/src/builtins/index.js new file mode 100644 index 0000000..62074d4 --- /dev/null +++ b/site/src/builtins/index.js @@ -0,0 +1 @@ +export * from "./commands"; diff --git a/site/src/main.js b/site/src/main.js index 759f7a9..2184f4e 100644 --- a/site/src/main.js +++ b/site/src/main.js @@ -2,19 +2,21 @@ import handleKey from "./key-handler.js"; import * as renderer from "./renderer.js"; import * as history from "./history.js"; import execute from "./bash/interpreter.js"; +import { commands } from "./builtins"; import "./cli.web.js"; import "./index.css"; -const builtins = { history: history.spec, clear: renderer.clearSpec }; +const builtins = { history: history.spec, clear: renderer.clearSpec, commands }; for (const c of Object.keys(builtins)) { + builtins[c].builtin = true; window.CLI_COMMANDS[c] = builtins[c]; } // Create handler for command actions -const blobSource = "export default (...args) => window.CLI_ACTION_REF(...args);"; +const blobSource = "export default (...args) => globalThis.CLI_ACTION_REF(...args);"; const blob = new Blob([blobSource], { type: "text/javascript" }); -const url = URL.createObjectURL(blob); -require("url").pathToFileURL = () => ({ href: url }); +window.cliHandlerUrl = URL.createObjectURL(blob); +require("url").pathToFileURL = () => ({ href: cliHandlerUrl }); let [i, o, oa, lm] = ["input", "output", "output-after", "theme"].map((id) => document.getElementById(id)); document.addEventListener("click", () => i.focus()); From 091d12744be68280ee548018a478fb205e06ef63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Cort=C3=B3n=20Cobas?= <104267232+carloscortonc@users.noreply.github.com> Date: Tue, 27 Jan 2026 16:59:49 +0100 Subject: [PATCH 37/52] feat(bash): quoted strings --- site/package.json | 2 +- site/src/bash/grammar.js | 21 ++++++++-- site/src/bash/interpreter.js | 11 ------ site/test/grammar.test.js | 75 ++++++++++++++++++++++++++++++++++++ 4 files changed, 93 insertions(+), 16 deletions(-) create mode 100644 site/test/grammar.test.js diff --git a/site/package.json b/site/package.json index e779a19..02c4db3 100644 --- a/site/package.json +++ b/site/package.json @@ -7,7 +7,7 @@ "build": "npm --prefix .. ci && npm --prefix .. run build && node scripts/browserify.js > ./src/cli.web.js", "bundle": "npm run build && vite build --base=/cli-er", "start": "npm run build && (npx http-server .. --port=8000 --cors &) && vite", - "test": "echo \"Error: no test specified\" && exit 1" + "test": "node --test ./test/**" }, "keywords": [], "author": "", diff --git a/site/src/bash/grammar.js b/site/src/bash/grammar.js index 34b6854..bab28a1 100644 --- a/site/src/bash/grammar.js +++ b/site/src/bash/grammar.js @@ -11,10 +11,14 @@ export const grammar = ohm.grammar(String.raw` | Paren Paren = "(" OrStatement ")" -- paren | Expr - Expr = Keyword (spaces arg)+ -- args + Expr = Keyword (spaces Quoted)+ -- args | Keyword -- alone Keyword = ~"-" ~digit word - arg = (letter | digit | "-" | "." )+ + Quoted = "\"" #(quotedChar)* "\"" -- quoted + | arg + quotedChar = scaped | ~"\"" ~"\\" any + arg = (letter | digit | "-" | ".")+ + scaped = "\\" ("n" | "\"" | "\\" ) word = (letter | "-" | ".")+ } `); @@ -41,8 +45,17 @@ export const semantics = grammar.createSemantics().addOperation("ast", { Keyword(v) { return v.ast(); }, - arg(w) { - return w.sourceString; + Quoted_quoted(_lq, s, _rq) { + return s.children.reduce((acc, e) => acc.concat(e.ast()), ""); + }, + quotedChar(q) { + return q.ctorName == "scaped" ? q.ast() : q.sourceString; + }, + arg(a) { + return a.sourceString; + }, + scaped(_s, l) { + return l.sourceString; }, word(w) { return w.sourceString; diff --git a/site/src/bash/interpreter.js b/site/src/bash/interpreter.js index 55392b8..d5cbaf0 100644 --- a/site/src/bash/interpreter.js +++ b/site/src/bash/interpreter.js @@ -33,17 +33,6 @@ async function executeAst(node) { } }; }); - window.CLI_ACTION_REF = cliSpec.action; - //TODO capture output into FD[1], capture error into FD[2] - const c = new Cli(cliSpec.definition || {}, { ...cliSpec.cliOptions, cliName: node.cmd }); - // Update default help template - c.options.help.template = - cliSpec.cliOptions.help?.template || "{usage}\n{description}\n{namespaces}\n{commands}\n{options}"; - - // Reset exitCode before executing command - process.exitCode = 0; - await c.run(node.args); - process.exit(process.exitCode); } if (node.type === "and") { for (const child of node.args) { diff --git a/site/test/grammar.test.js b/site/test/grammar.test.js new file mode 100644 index 0000000..2187b65 --- /dev/null +++ b/site/test/grammar.test.js @@ -0,0 +1,75 @@ +import assert from "node:assert"; +import { grammar, semantics } from "../src/bash/grammar.js"; + +function assertResult(input, expectedAst) { + const r = grammar.match(input); + // Use `undefined` to represent a failed parse result + let ast = undefined; + try { + ast = semantics(r).ast(); + } catch {} + assert.deepEqual(ast, expectedAst); +} + +// Command +assertResult("echo 1 2", { type: "cmd", cmd: "echo", args: ["1", "2"] }); +// Logic OR +assertResult("echo 1 || echo 2", { + type: "or", + args: [ + { type: "cmd", cmd: "echo", args: ["1"] }, + { type: "cmd", cmd: "echo", args: ["2"] }, + ], +}); +// Logic AND +assertResult("echo 1 && echo 2", { + type: "and", + args: [ + { type: "cmd", cmd: "echo", args: ["1"] }, + { type: "cmd", cmd: "echo", args: ["2"] }, + ], +}); +// Pipe +assertResult("echo 1 | echo 2", { + type: "pipe", + args: [ + { type: "cmd", cmd: "echo", args: ["1"] }, + { type: "cmd", cmd: "echo", args: ["2"] }, + ], +}); +// AND > OR +assertResult("echo 1 || echo 2 && echo 3", { + type: "or", + args: [ + { type: "cmd", cmd: "echo", args: ["1"] }, + { + type: "and", + args: [ + { type: "cmd", cmd: "echo", args: ["2"] }, + { type: "cmd", cmd: "echo", args: ["3"] }, + ], + }, + ], +}); +// Parenthesis +assertResult("(echo 1 || echo 2) && echo 3", { + type: "and", + args: [ + { + type: "or", + args: [ + { type: "cmd", cmd: "echo", args: ["1"] }, + { type: "cmd", cmd: "echo", args: ["2"] }, + ], + }, + { type: "cmd", cmd: "echo", args: ["3"] }, + ], +}); +// Quoted +assertResult('echo some value "some value"', { type: "cmd", cmd: "echo", args: ["some", "value", "some value"] }); +// Quoted with logic operators +assertResult('echo "some || value"', { type: "cmd", cmd: "echo", args: ["some || value"] }); +// Quoted with scape sequence +assertResult('echo "some \\" value"', { type: "cmd", cmd: "echo", args: ['some " value'] }); +// Quoted with unknown scape sequence (error) +assertResult('echo "some \\w value"', undefined); From c61928413dd1e3f6c3ee001d6f4689edb2a196d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Cort=C3=B3n=20Cobas?= <104267232+carloscortonc@users.noreply.github.com> Date: Wed, 28 Jan 2026 13:37:59 +0100 Subject: [PATCH 38/52] feat(bash): expressions inside quotes & sleep cmd --- site/index.html | 2 +- site/public/commands/echo.js | 2 +- site/src/bash/cli-runner.js | 4 ++++ site/src/bash/grammar.js | 16 ++++++++++------ site/src/bash/interpreter.js | 37 +++++++++++++++++++++++++++++++++--- site/src/builtins/index.js | 1 + site/src/builtins/sleep.js | 6 ++++++ site/src/main.js | 6 +++--- site/test/grammar.test.js | 36 ++++++++++++++++++++++++++++++++--- 9 files changed, 93 insertions(+), 17 deletions(-) create mode 100644 site/src/builtins/sleep.js diff --git a/site/index.html b/site/index.html index a848d2d..d3742bd 100644 --- a/site/index.html +++ b/site/index.html @@ -27,7 +27,7 @@ .catch((e) => (console.log(e), undefined)); if (spec) { const specKey = spec.cliOptions.cliName || c.split("/").find((_, i, l) => i == l.length - 1); - window.CLI_COMMANDS[specKey] = { ...spec, builtin: false }; + window.CLI_COMMANDS[specKey] = { ...spec, builtin: undefined }; } } diff --git a/site/public/commands/echo.js b/site/public/commands/echo.js index 3e12600..d14577c 100644 --- a/site/public/commands/echo.js +++ b/site/public/commands/echo.js @@ -1,5 +1,5 @@ export default { definition: { args: { type: "string", positional: true } }, cliOptions: { help: { hidden: true } }, - action: ({ args }) => args && Cli.logger.log(args.join(" "), "\n"), + action: ({ args }) => args && Cli.logger.log(args.join(" ")), }; diff --git a/site/src/bash/cli-runner.js b/site/src/bash/cli-runner.js index c40cef9..a3bde14 100644 --- a/site/src/bash/cli-runner.js +++ b/site/src/bash/cli-runner.js @@ -1,5 +1,9 @@ // Create a new Cli instance and run it with the provided arguments export default async function run({ name, cliSpec, args }) { + // For builtins, hide help option if not set otherwise + if (cliSpec.builtin !== undefined && cliSpec.cliOptions?.help?.hidden === undefined) { + cliSpec.cliOptions.help = { ...cliSpec.cliOptions.help, hidden: true }; + } const c = new Cli(cliSpec.definition || {}, { ...cliSpec.cliOptions, cliName: name }); // Update default help template c.options.help.template = diff --git a/site/src/bash/grammar.js b/site/src/bash/grammar.js index bab28a1..8b713af 100644 --- a/site/src/bash/grammar.js +++ b/site/src/bash/grammar.js @@ -14,9 +14,10 @@ export const grammar = ohm.grammar(String.raw` Expr = Keyword (spaces Quoted)+ -- args | Keyword -- alone Keyword = ~"-" ~digit word - Quoted = "\"" #(quotedChar)* "\"" -- quoted + Quoted = "\"" (InnerExpr | QuotedText)* "\"" -- quoted | arg - quotedChar = scaped | ~"\"" ~"\\" any + InnerExpr = "$(" OrStatement ")" + QuotedText = #( scaped | ~("\"" | "\\" | "$") any )+ arg = (letter | digit | "-" | ".")+ scaped = "\\" ("n" | "\"" | "\\" ) word = (letter | "-" | ".")+ @@ -37,7 +38,7 @@ export const semantics = grammar.createSemantics().addOperation("ast", { return e.ast(); }, Expr_args(cmd, _, t) { - return { type: "cmd", cmd: cmd.ast(), args: t.ast() }; + return { type: "cmd", cmd: cmd.ast(), args: t.ast().flat() }; }, Expr_alone(cmd) { return { type: "cmd", cmd: cmd.ast(), args: [] }; @@ -46,10 +47,13 @@ export const semantics = grammar.createSemantics().addOperation("ast", { return v.ast(); }, Quoted_quoted(_lq, s, _rq) { - return s.children.reduce((acc, e) => acc.concat(e.ast()), ""); + return { type: "quote", args: s.children.map((e) => e.ast()) }; }, - quotedChar(q) { - return q.ctorName == "scaped" ? q.ast() : q.sourceString; + QuotedText(q) { + return q.children.map((c) => (c.ctorName !== "any" ? c.ast() : c.sourceString)).join(""); + }, + InnerExpr(_s, expr, _e) { + return expr.ast(); }, arg(a) { return a.sourceString; diff --git a/site/src/bash/interpreter.js b/site/src/bash/interpreter.js index d5cbaf0..38c35a9 100644 --- a/site/src/bash/interpreter.js +++ b/site/src/bash/interpreter.js @@ -5,10 +5,41 @@ import run from "./cli-runner.js"; const execWorker = new ExecWorker(); +const capture = () => { + const ow = process.stdout.write; + let buffer = ""; + process.stdout.write = (v) => (buffer += v); + + return () => { + process.stdout.write = ow; + return buffer; + }; +}; + async function executeAst(node) { + if (typeof node === "string") { + return node; + } + if (node.type === "quote") { + const args = []; + for (const arg of node.args) { + if (typeof arg === "string") { + args.push(arg); + continue; + } + const free = capture(); + await executeAst(arg); + args.push(free()); + } + return args.join(""); + } if (node.type === "cmd") { const cliSpec = CLI_COMMANDS[node.cmd]; - console.log(`[execute::cmd] ${node.cmd} args=${JSON.stringify(node.args)}`); + const args = []; + for (const arg of node.args) { + args.push(await executeAst(arg)); + } + console.log(`[execute::cmd] ${node.cmd} args=${JSON.stringify(args)}`); if (!cliSpec) { process.stderr.write(`cliersh: command not found: "${node.cmd}"\n`); @@ -17,12 +48,12 @@ async function executeAst(node) { // Handle "builtins" that need access to internals (e.g. renderer), and would not otherwise work from web-worker if (cliSpec.builtin) { - await run({ name: node.cmd, cliSpec, args: node.args }); + await run({ name: node.cmd, cliSpec, args }); return process.exit(process.exitCode); } return new Promise((resolve) => { - execWorker.postMessage(serialize({ name: node.cmd, cliSpec, args: node.args, env: process.env, cliHandlerUrl })); + execWorker.postMessage(serialize({ name: node.cmd, cliSpec, args, env: process.env, cliHandlerUrl })); execWorker.onmessage = ({ data }) => { if (data.type === "output") { diff --git a/site/src/builtins/index.js b/site/src/builtins/index.js index 62074d4..b8f12af 100644 --- a/site/src/builtins/index.js +++ b/site/src/builtins/index.js @@ -1 +1,2 @@ export * from "./commands"; +export * from "./sleep"; diff --git a/site/src/builtins/sleep.js b/site/src/builtins/sleep.js new file mode 100644 index 0000000..8009719 --- /dev/null +++ b/site/src/builtins/sleep.js @@ -0,0 +1,6 @@ +export const sleep = { + definition: { seconds: { type: "number", positional: 0, required: true, description: "number of seconds to sleep" } }, + cliOptions: {}, + action: async ({ seconds }) => new Promise((resolve) => setTimeout(resolve, seconds * 1000)), + builtin: false, // Mark it as `false` so it will be executed inside web-worker (cancellable) +}; diff --git a/site/src/main.js b/site/src/main.js index 2184f4e..724912a 100644 --- a/site/src/main.js +++ b/site/src/main.js @@ -2,13 +2,13 @@ import handleKey from "./key-handler.js"; import * as renderer from "./renderer.js"; import * as history from "./history.js"; import execute from "./bash/interpreter.js"; -import { commands } from "./builtins"; +import * as builtincmds from "./builtins"; import "./cli.web.js"; import "./index.css"; -const builtins = { history: history.spec, clear: renderer.clearSpec, commands }; +const builtins = { history: history.spec, clear: renderer.clearSpec, ...builtincmds }; for (const c of Object.keys(builtins)) { - builtins[c].builtin = true; + builtins[c].builtin ??= true; window.CLI_COMMANDS[c] = builtins[c]; } diff --git a/site/test/grammar.test.js b/site/test/grammar.test.js index 2187b65..cdd90f1 100644 --- a/site/test/grammar.test.js +++ b/site/test/grammar.test.js @@ -66,10 +66,40 @@ assertResult("(echo 1 || echo 2) && echo 3", { ], }); // Quoted -assertResult('echo some value "some value"', { type: "cmd", cmd: "echo", args: ["some", "value", "some value"] }); +assertResult('echo some value "some value"', { + type: "cmd", + cmd: "echo", + args: ["some", "value", { type: "quote", args: ["some value"] }], +}); // Quoted with logic operators -assertResult('echo "some || value"', { type: "cmd", cmd: "echo", args: ["some || value"] }); +assertResult('echo "some || value"', { type: "cmd", cmd: "echo", args: [{ type: "quote", args: ["some || value"] }] }); // Quoted with scape sequence -assertResult('echo "some \\" value"', { type: "cmd", cmd: "echo", args: ['some " value'] }); +assertResult('echo "some \\" value"', { type: "cmd", cmd: "echo", args: [{ type: "quote", args: ['some " value'] }] }); // Quoted with unknown scape sequence (error) assertResult('echo "some \\w value"', undefined); +// Quoted expr +assertResult('echo "1$(echo 2)"', { + type: "cmd", + cmd: "echo", + args: [{ type: "quote", args: ["1", { type: "cmd", cmd: "echo", args: ["2"] }] }], +}); +// Quoted complex expr +assertResult('echo "1$(echo 2 && echo 2)"', { + type: "cmd", + cmd: "echo", + args: [ + { + type: "quote", + args: [ + "1", + { + type: "and", + args: [ + { type: "cmd", cmd: "echo", args: ["2"] }, + { type: "cmd", cmd: "echo", args: ["2"] }, + ], + }, + ], + }, + ], +}); From 58a6d8b8420e77ded1b91ab17f1c5e89eb82b8ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Cort=C3=B3n=20Cobas?= <104267232+carloscortonc@users.noreply.github.com> Date: Wed, 28 Jan 2026 13:46:20 +0100 Subject: [PATCH 39/52] chore: basic responsive for mobile --- site/src/index.css | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/site/src/index.css b/site/src/index.css index bb43329..1e4776c 100644 --- a/site/src/index.css +++ b/site/src/index.css @@ -1,6 +1,19 @@ :root { --bg-color: #212221; --fg-color: #c2c2c2; + --v-margin: 20px; + --h-margin: 40px; + --icon-size: 30px; +} +@media (max-width: 500px) { + :root { + --v-margin: 20px; + --h-margin: 15px; + --icon-size: 25px; + } + .header > .actions { + gap: 4px; + } } * { font-family: monospace; @@ -16,9 +29,9 @@ body { body { background: var(--bg-color); color: var(--fg-color); - margin: 20px 40px; - width: calc(100% - 80px); - height: calc(100% - 40px); + margin: var(--v-margin) var(--h-margin); + width: calc(100% - 2 * var(--h-margin)); + height: calc(100% - 2 * var(--v-margin)); } body.light { --bg-color: #eaeaea; @@ -42,8 +55,8 @@ body.light { gap: 12px; } .icon { - height: 30px; - width: 30px; + height: var(--icon-size); + width: var(--icon-size); cursor: pointer; } .icon.github { From c998de885004347263fe6a3fa2bed7ef2b4038f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Cort=C3=B3n=20Cobas?= <104267232+carloscortonc@users.noreply.github.com> Date: Wed, 28 Jan 2026 17:46:27 +0100 Subject: [PATCH 40/52] feat(bash): environment variables --- site/src/bash/grammar.js | 15 ++++---- site/src/bash/interpreter.js | 19 +++++++++- site/test/grammar.test.js | 71 +++++++++++++++++++++++++++--------- 3 files changed, 79 insertions(+), 26 deletions(-) diff --git a/site/src/bash/grammar.js b/site/src/bash/grammar.js index 8b713af..427f5a7 100644 --- a/site/src/bash/grammar.js +++ b/site/src/bash/grammar.js @@ -11,8 +11,9 @@ export const grammar = ohm.grammar(String.raw` | Paren Paren = "(" OrStatement ")" -- paren | Expr - Expr = Keyword (spaces Quoted)+ -- args - | Keyword -- alone + Expr = (Assignment spaces)* Keyword (spaces Quoted)* -- expr + | Assignment + Assignment = Keyword ~space "=" ~space (Quoted | InnerExpr) Keyword = ~"-" ~digit word Quoted = "\"" (InnerExpr | QuotedText)* "\"" -- quoted | arg @@ -20,7 +21,7 @@ export const grammar = ohm.grammar(String.raw` QuotedText = #( scaped | ~("\"" | "\\" | "$") any )+ arg = (letter | digit | "-" | ".")+ scaped = "\\" ("n" | "\"" | "\\" ) - word = (letter | "-" | ".")+ + word = (letter | "-" | "." | "_" )+ } `); @@ -37,11 +38,11 @@ export const semantics = grammar.createSemantics().addOperation("ast", { Paren_paren(_, e, __) { return e.ast(); }, - Expr_args(cmd, _, t) { - return { type: "cmd", cmd: cmd.ast(), args: t.ast().flat() }; + Expr_expr(a, _s, cmd, _s2, t) { + return { type: "cmd", cmd: cmd.ast(), args: t.ast().flat(), env: a.ast() }; }, - Expr_alone(cmd) { - return { type: "cmd", cmd: cmd.ast(), args: [] }; + Assignment(k, _, v) { + return { type: "env", args: [k.ast(), v.ast()] }; }, Keyword(v) { return v.ast(); diff --git a/site/src/bash/interpreter.js b/site/src/bash/interpreter.js index 38c35a9..20d4be2 100644 --- a/site/src/bash/interpreter.js +++ b/site/src/bash/interpreter.js @@ -33,13 +33,30 @@ async function executeAst(node) { } return args.join(""); } + if (node.type === "env") { + const arg1 = node.args[1]; + // Wrap value with "quoted" node-type to reuse logic for capturing output + const v = typeof arg1 !== "string" && arg1.type !== "quote" ? { type: "quote", args: [arg1] } : arg1; + const r = [node.args[0], await executeAst(v)]; + if (node.cmd === true) { + return r; + } + process.env[r[0]] = r[1]; + } if (node.type === "cmd") { const cliSpec = CLI_COMMANDS[node.cmd]; const args = []; + // Process arguments for (const arg of node.args) { args.push(await executeAst(arg)); } - console.log(`[execute::cmd] ${node.cmd} args=${JSON.stringify(args)}`); + const env = { ...process.env }; + // Process environment variables + for (const e of node.env) { + const [k, v] = await executeAst({ ...e, cmd: true }); + env[k] = v; + } + console.log(`[execute::cmd] ${node.cmd} args=${JSON.stringify(args)} env=${JSON.stringify(env)}`); if (!cliSpec) { process.stderr.write(`cliersh: command not found: "${node.cmd}"\n`); diff --git a/site/test/grammar.test.js b/site/test/grammar.test.js index cdd90f1..e844d04 100644 --- a/site/test/grammar.test.js +++ b/site/test/grammar.test.js @@ -12,41 +12,41 @@ function assertResult(input, expectedAst) { } // Command -assertResult("echo 1 2", { type: "cmd", cmd: "echo", args: ["1", "2"] }); +assertResult("echo 1 2", { type: "cmd", cmd: "echo", args: ["1", "2"], env: [] }); // Logic OR assertResult("echo 1 || echo 2", { type: "or", args: [ - { type: "cmd", cmd: "echo", args: ["1"] }, - { type: "cmd", cmd: "echo", args: ["2"] }, + { type: "cmd", cmd: "echo", args: ["1"], env: [] }, + { type: "cmd", cmd: "echo", args: ["2"], env: [] }, ], }); // Logic AND assertResult("echo 1 && echo 2", { type: "and", args: [ - { type: "cmd", cmd: "echo", args: ["1"] }, - { type: "cmd", cmd: "echo", args: ["2"] }, + { type: "cmd", cmd: "echo", args: ["1"], env: [] }, + { type: "cmd", cmd: "echo", args: ["2"], env: [] }, ], }); // Pipe assertResult("echo 1 | echo 2", { type: "pipe", args: [ - { type: "cmd", cmd: "echo", args: ["1"] }, - { type: "cmd", cmd: "echo", args: ["2"] }, + { type: "cmd", cmd: "echo", args: ["1"], env: [] }, + { type: "cmd", cmd: "echo", args: ["2"], env: [] }, ], }); // AND > OR assertResult("echo 1 || echo 2 && echo 3", { type: "or", args: [ - { type: "cmd", cmd: "echo", args: ["1"] }, + { type: "cmd", cmd: "echo", args: ["1"], env: [] }, { type: "and", args: [ - { type: "cmd", cmd: "echo", args: ["2"] }, - { type: "cmd", cmd: "echo", args: ["3"] }, + { type: "cmd", cmd: "echo", args: ["2"], env: [] }, + { type: "cmd", cmd: "echo", args: ["3"], env: [] }, ], }, ], @@ -58,11 +58,11 @@ assertResult("(echo 1 || echo 2) && echo 3", { { type: "or", args: [ - { type: "cmd", cmd: "echo", args: ["1"] }, - { type: "cmd", cmd: "echo", args: ["2"] }, + { type: "cmd", cmd: "echo", args: ["1"], env: [] }, + { type: "cmd", cmd: "echo", args: ["2"], env: [] }, ], }, - { type: "cmd", cmd: "echo", args: ["3"] }, + { type: "cmd", cmd: "echo", args: ["3"], env: [] }, ], }); // Quoted @@ -70,18 +70,30 @@ assertResult('echo some value "some value"', { type: "cmd", cmd: "echo", args: ["some", "value", { type: "quote", args: ["some value"] }], + env: [], }); // Quoted with logic operators -assertResult('echo "some || value"', { type: "cmd", cmd: "echo", args: [{ type: "quote", args: ["some || value"] }] }); +assertResult('echo "some || value"', { + type: "cmd", + cmd: "echo", + args: [{ type: "quote", args: ["some || value"] }], + env: [], +}); // Quoted with scape sequence -assertResult('echo "some \\" value"', { type: "cmd", cmd: "echo", args: [{ type: "quote", args: ['some " value'] }] }); +assertResult('echo "some \\" value"', { + type: "cmd", + cmd: "echo", + args: [{ type: "quote", args: ['some " value'] }], + env: [], +}); // Quoted with unknown scape sequence (error) assertResult('echo "some \\w value"', undefined); // Quoted expr assertResult('echo "1$(echo 2)"', { type: "cmd", cmd: "echo", - args: [{ type: "quote", args: ["1", { type: "cmd", cmd: "echo", args: ["2"] }] }], + args: [{ type: "quote", args: ["1", { type: "cmd", cmd: "echo", args: ["2"], env: [] }] }], + env: [], }); // Quoted complex expr assertResult('echo "1$(echo 2 && echo 2)"', { @@ -95,11 +107,34 @@ assertResult('echo "1$(echo 2 && echo 2)"', { { type: "and", args: [ - { type: "cmd", cmd: "echo", args: ["2"] }, - { type: "cmd", cmd: "echo", args: ["2"] }, + { type: "cmd", cmd: "echo", args: ["2"], env: [] }, + { type: "cmd", cmd: "echo", args: ["2"], env: [] }, ], }, ], }, ], + env: [], +}); +// Assignment +assertResult("test=1", { + type: "env", + args: ["test", "1"], +}); +// Assigment with complex expr +assertResult("test=$(echo 1)", { + type: "env", + args: ["test", { type: "cmd", cmd: "echo", args: ["1"], env: [] }], +}); +// Assigment with quoted expr +assertResult('test="1 $(echo 1)"', { + type: "env", + args: ["test", { type: "quote", args: ["1 ", { type: "cmd", cmd: "echo", args: ["1"], env: [] }] }], +}); +// Assignment within command +assertResult("test=1 echo 1", { + type: "cmd", + cmd: "echo", + args: ["1"], + env: [{ type: "env", args: ["test", "1"] }], }); From 884f81dddaff6b2d045c12a1b91eb6c15c07656d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Cort=C3=B3n=20Cobas?= <104267232+carloscortonc@users.noreply.github.com> Date: Wed, 28 Jan 2026 18:06:25 +0100 Subject: [PATCH 41/52] build: update actions/cache version --- .github/workflows/build.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index bc7a39f..3f807ca 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -17,7 +17,7 @@ jobs: node-version: 20 - name: Setup cache for dist - uses: actions/cache@v3 + uses: actions/cache@v5 with: path: dist key: dist-${{ github.run_id }} @@ -41,7 +41,7 @@ jobs: fetch-depth: 0 - name: Restore dist cache - uses: actions/cache@v3 + uses: actions/cache@v5 with: path: dist key: dist-${{ github.run_id }} From bbeb02f91120e6fe2413b2a49f5993b73ed65391 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Cort=C3=B3n=20Cobas?= <104267232+carloscortonc@users.noreply.github.com> Date: Wed, 28 Jan 2026 19:16:10 +0100 Subject: [PATCH 42/52] fix: avoid `process.env` being processed by vite --- site/src/bash/interpreter.js | 2 +- site/vite.config.js | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/site/src/bash/interpreter.js b/site/src/bash/interpreter.js index 20d4be2..5aacef7 100644 --- a/site/src/bash/interpreter.js +++ b/site/src/bash/interpreter.js @@ -70,7 +70,7 @@ async function executeAst(node) { } return new Promise((resolve) => { - execWorker.postMessage(serialize({ name: node.cmd, cliSpec, args, env: process.env, cliHandlerUrl })); + execWorker.postMessage(serialize({ name: node.cmd, cliSpec, args, env, cliHandlerUrl })); execWorker.onmessage = ({ data }) => { if (data.type === "output") { diff --git a/site/vite.config.js b/site/vite.config.js index d5b10fd..919d7e7 100644 --- a/site/vite.config.js +++ b/site/vite.config.js @@ -3,4 +3,8 @@ export default { minify: false, modulePreload: { polyfill: false }, }, + define: { + // Avoid replacing "process.env" references + "process.env": "process.env", + }, }; From ea6b4ce695ec7f7138219500023aea81dfac3aae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Cort=C3=B3n=20Cobas?= <104267232+carloscortonc@users.noreply.github.com> Date: Wed, 28 Jan 2026 19:43:53 +0100 Subject: [PATCH 43/52] feat(bash): allow inner-expr without quotes --- site/src/bash/grammar.js | 3 ++- site/test/grammar.test.js | 7 +++++++ site/vite.config.js | 4 +++- 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/site/src/bash/grammar.js b/site/src/bash/grammar.js index 427f5a7..537dbcb 100644 --- a/site/src/bash/grammar.js +++ b/site/src/bash/grammar.js @@ -13,9 +13,10 @@ export const grammar = ohm.grammar(String.raw` | Expr Expr = (Assignment spaces)* Keyword (spaces Quoted)* -- expr | Assignment - Assignment = Keyword ~space "=" ~space (Quoted | InnerExpr) + Assignment = Keyword ~space "=" ~space Quoted Keyword = ~"-" ~digit word Quoted = "\"" (InnerExpr | QuotedText)* "\"" -- quoted + | InnerExpr | arg InnerExpr = "$(" OrStatement ")" QuotedText = #( scaped | ~("\"" | "\\" | "$") any )+ diff --git a/site/test/grammar.test.js b/site/test/grammar.test.js index e844d04..a68c9a2 100644 --- a/site/test/grammar.test.js +++ b/site/test/grammar.test.js @@ -116,6 +116,13 @@ assertResult('echo "1$(echo 2 && echo 2)"', { ], env: [], }); +// Quoted without quotes +assertResult("echo $(echo 1)", { + type: "cmd", + cmd: "echo", + args: [{ type: "cmd", cmd: "echo", args: ["1"], env: [] }], + env: [], +}); // Assignment assertResult("test=1", { type: "env", diff --git a/site/vite.config.js b/site/vite.config.js index 919d7e7..d47df5e 100644 --- a/site/vite.config.js +++ b/site/vite.config.js @@ -1,9 +1,11 @@ +import { defineConfig } from "vite"; + export default { build: { minify: false, modulePreload: { polyfill: false }, }, - define: { + define: process.env.NODE_ENV === "production" && { // Avoid replacing "process.env" references "process.env": "process.env", }, From 16aa69c33873b96227dda1152020ceaf53d51a72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Cort=C3=B3n=20Cobas?= <104267232+carloscortonc@users.noreply.github.com> Date: Wed, 28 Jan 2026 20:25:23 +0100 Subject: [PATCH 44/52] feat(bash): basic expansion --- site/src/bash/grammar.js | 14 ++++++++++---- site/src/bash/interpreter.js | 8 +++++++- site/test/grammar.test.js | 14 ++++++++++++++ 3 files changed, 31 insertions(+), 5 deletions(-) diff --git a/site/src/bash/grammar.js b/site/src/bash/grammar.js index 537dbcb..d50bef0 100644 --- a/site/src/bash/grammar.js +++ b/site/src/bash/grammar.js @@ -14,15 +14,18 @@ export const grammar = ohm.grammar(String.raw` Expr = (Assignment spaces)* Keyword (spaces Quoted)* -- expr | Assignment Assignment = Keyword ~space "=" ~space Quoted - Keyword = ~"-" ~digit word - Quoted = "\"" (InnerExpr | QuotedText)* "\"" -- quoted + Quoted = "\"" (InnerExpr | Expansion | QuotedText)* "\"" -- quoted | InnerExpr + | Expansion | arg InnerExpr = "$(" OrStatement ")" + Expansion = "$" word QuotedText = #( scaped | ~("\"" | "\\" | "$") any )+ - arg = (letter | digit | "-" | ".")+ + Keyword = ~"-" ~digit word + arg = word_char+ scaped = "\\" ("n" | "\"" | "\\" ) - word = (letter | "-" | "." | "_" )+ + word = word_char+ + word_char = letter | digit | "-" | "." | "_" | ":" | "$" } `); @@ -45,6 +48,9 @@ export const semantics = grammar.createSemantics().addOperation("ast", { Assignment(k, _, v) { return { type: "env", args: [k.ast(), v.ast()] }; }, + Expansion(_, k) { + return { type: "expansion", args: [k.ast()] }; + }, Keyword(v) { return v.ast(); }, diff --git a/site/src/bash/interpreter.js b/site/src/bash/interpreter.js index 5aacef7..2c4428c 100644 --- a/site/src/bash/interpreter.js +++ b/site/src/bash/interpreter.js @@ -43,12 +43,18 @@ async function executeAst(node) { } process.env[r[0]] = r[1]; } + if (node.type === "expansion") { + const v = node.args[0]; + // https://www.gnu.org/software/bash/manual/bash.html#Special-Parameters-1 + return process.env[v]; + } if (node.type === "cmd") { const cliSpec = CLI_COMMANDS[node.cmd]; const args = []; // Process arguments for (const arg of node.args) { - args.push(await executeAst(arg)); + let av = await executeAst(arg); + av !== undefined && args.push(av); } const env = { ...process.env }; // Process environment variables diff --git a/site/test/grammar.test.js b/site/test/grammar.test.js index a68c9a2..7e95f93 100644 --- a/site/test/grammar.test.js +++ b/site/test/grammar.test.js @@ -145,3 +145,17 @@ assertResult("test=1 echo 1", { args: ["1"], env: [{ type: "env", args: ["test", "1"] }], }); +// Expansion +assertResult("echo $e", { + type: "cmd", + cmd: "echo", + args: [{ type: "expansion", args: ["e"] }], + env: [], +}); +// Expansion inside quotes +assertResult('echo "one and $e"', { + type: "cmd", + cmd: "echo", + args: [{ type: "quote", args: ["one and ", { type: "expansion", args: ["e"] }] }], + env: [], +}); From 04e373c39874910f82b247340adab80f5fe52a8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Cort=C3=B3n=20Cobas?= <104267232+carloscortonc@users.noreply.github.com> Date: Thu, 29 Jan 2026 09:41:45 +0100 Subject: [PATCH 45/52] feat(bash): special param expansion --- site/src/bash/grammar.js | 6 +++--- site/src/bash/interpreter.js | 8 ++++++-- site/src/main.js | 5 +++++ site/test/grammar.test.js | 7 +++++++ 4 files changed, 21 insertions(+), 5 deletions(-) diff --git a/site/src/bash/grammar.js b/site/src/bash/grammar.js index d50bef0..ca5a599 100644 --- a/site/src/bash/grammar.js +++ b/site/src/bash/grammar.js @@ -19,11 +19,11 @@ export const grammar = ohm.grammar(String.raw` | Expansion | arg InnerExpr = "$(" OrStatement ")" - Expansion = "$" word + Expansion = "$" (word | "?")? QuotedText = #( scaped | ~("\"" | "\\" | "$") any )+ Keyword = ~"-" ~digit word arg = word_char+ - scaped = "\\" ("n" | "\"" | "\\" ) + scaped = "\\" ("n" | "\"" | "\\" | "$") word = word_char+ word_char = letter | digit | "-" | "." | "_" | ":" | "$" } @@ -49,7 +49,7 @@ export const semantics = grammar.createSemantics().addOperation("ast", { return { type: "env", args: [k.ast(), v.ast()] }; }, Expansion(_, k) { - return { type: "expansion", args: [k.ast()] }; + return { type: "expansion", args: [k.sourceString] }; }, Keyword(v) { return v.ast(); diff --git a/site/src/bash/interpreter.js b/site/src/bash/interpreter.js index 2c4428c..3a6d9a1 100644 --- a/site/src/bash/interpreter.js +++ b/site/src/bash/interpreter.js @@ -23,8 +23,8 @@ async function executeAst(node) { if (node.type === "quote") { const args = []; for (const arg of node.args) { - if (typeof arg === "string") { - args.push(arg); + if (typeof arg === "string" || arg.type === "expansion") { + args.push(await executeAst(arg)); continue; } const free = capture(); @@ -46,6 +46,10 @@ async function executeAst(node) { if (node.type === "expansion") { const v = node.args[0]; // https://www.gnu.org/software/bash/manual/bash.html#Special-Parameters-1 + if (!v) return "$"; + if (v === "0") return process.env.SHELL; + if (v == "?") return process.lastExitCode.toString(); + if (v == "$") return "0001"; return process.env[v]; } if (node.type === "cmd") { diff --git a/site/src/main.js b/site/src/main.js index 724912a..97ada2c 100644 --- a/site/src/main.js +++ b/site/src/main.js @@ -30,6 +30,11 @@ const r = (...args) => renderer.renderOutput(...args); process.stdout.write = (v) => r(v); process.stderr.write = (v) => r(v, { error: true }); +// Setup initial env values +Object.assign(process.env, { + SHELL: "cliersh", +}); + handleKey(i, { Enter: () => { let inputValue = i.value; diff --git a/site/test/grammar.test.js b/site/test/grammar.test.js index 7e95f93..be4cbfb 100644 --- a/site/test/grammar.test.js +++ b/site/test/grammar.test.js @@ -152,6 +152,13 @@ assertResult("echo $e", { args: [{ type: "expansion", args: ["e"] }], env: [], }); +// Expansion - empty +assertResult("echo $", { + type: "cmd", + cmd: "echo", + args: [{ type: "expansion", args: [""] }], + env: [], +}); // Expansion inside quotes assertResult('echo "one and $e"', { type: "cmd", From b484d87b61f32fb6c760599e659909ae089868f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Cort=C3=B3n=20Cobas?= <104267232+carloscortonc@users.noreply.github.com> Date: Thu, 29 Jan 2026 09:51:37 +0100 Subject: [PATCH 46/52] chore: move single "$" to quoted-text --- site/src/bash/grammar.js | 4 ++-- site/src/bash/interpreter.js | 1 - site/test/grammar.test.js | 15 +++++++++++---- 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/site/src/bash/grammar.js b/site/src/bash/grammar.js index ca5a599..3b9b836 100644 --- a/site/src/bash/grammar.js +++ b/site/src/bash/grammar.js @@ -19,8 +19,8 @@ export const grammar = ohm.grammar(String.raw` | Expansion | arg InnerExpr = "$(" OrStatement ")" - Expansion = "$" (word | "?")? - QuotedText = #( scaped | ~("\"" | "\\" | "$") any )+ + Expansion = "$" (word | "?") + QuotedText = #( scaped | ~("\"" | "\\") ~("$" word) ~("$(") any )+ Keyword = ~"-" ~digit word arg = word_char+ scaped = "\\" ("n" | "\"" | "\\" | "$") diff --git a/site/src/bash/interpreter.js b/site/src/bash/interpreter.js index 3a6d9a1..063b608 100644 --- a/site/src/bash/interpreter.js +++ b/site/src/bash/interpreter.js @@ -46,7 +46,6 @@ async function executeAst(node) { if (node.type === "expansion") { const v = node.args[0]; // https://www.gnu.org/software/bash/manual/bash.html#Special-Parameters-1 - if (!v) return "$"; if (v === "0") return process.env.SHELL; if (v == "?") return process.lastExitCode.toString(); if (v == "$") return "0001"; diff --git a/site/test/grammar.test.js b/site/test/grammar.test.js index be4cbfb..b976b70 100644 --- a/site/test/grammar.test.js +++ b/site/test/grammar.test.js @@ -80,10 +80,10 @@ assertResult('echo "some || value"', { env: [], }); // Quoted with scape sequence -assertResult('echo "some \\" value"', { +assertResult('echo "some \\" \\$exp value"', { type: "cmd", cmd: "echo", - args: [{ type: "quote", args: ['some " value'] }], + args: [{ type: "quote", args: ['some " $exp value'] }], env: [], }); // Quoted with unknown scape sequence (error) @@ -152,11 +152,18 @@ assertResult("echo $e", { args: [{ type: "expansion", args: ["e"] }], env: [], }); -// Expansion - empty +// Expansion empty = arg assertResult("echo $", { type: "cmd", cmd: "echo", - args: [{ type: "expansion", args: [""] }], + args: ["$"], + env: [], +}); +// Expansion quoted - empty = quote +assertResult('echo "$"', { + type: "cmd", + cmd: "echo", + args: [{ type: "quote", args: ["$"] }], env: [], }); // Expansion inside quotes From 33c4037d15c375113fc6427f0c0e82e56d546835 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Cort=C3=B3n=20Cobas?= <104267232+carloscortonc@users.noreply.github.com> Date: Thu, 29 Jan 2026 10:03:55 +0100 Subject: [PATCH 47/52] feat: printenv cmd --- site/src/builtins/index.js | 1 + site/src/builtins/printenv.js | 13 +++++++++++++ 2 files changed, 14 insertions(+) create mode 100644 site/src/builtins/printenv.js diff --git a/site/src/builtins/index.js b/site/src/builtins/index.js index b8f12af..7640493 100644 --- a/site/src/builtins/index.js +++ b/site/src/builtins/index.js @@ -1,2 +1,3 @@ export * from "./commands"; export * from "./sleep"; +export * from "./printenv"; diff --git a/site/src/builtins/printenv.js b/site/src/builtins/printenv.js new file mode 100644 index 0000000..b67e252 --- /dev/null +++ b/site/src/builtins/printenv.js @@ -0,0 +1,13 @@ +export const printenv = { + definition: { name: { type: "string", positional: 0 } }, + cliOptions: { help: { template: "Usage: printenv [name]" } }, + action: ({ name }) => { + if (name) { + return process.env[name] ? process.stdout.write(process.env[name]) : undefined; + } + const env = Object.entries(process.env) + .map(([k, v]) => `${k}=${v}`) + .join("\n"); + process.stdout.write(env); + }, +}; From 1ed583c97d95e1d8c7f8b6017639eecf5e3464b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Cort=C3=B3n=20Cobas?= <104267232+carloscortonc@users.noreply.github.com> Date: Thu, 29 Jan 2026 11:23:01 +0100 Subject: [PATCH 48/52] feat: basic fs module --- site/src/builtins/index.js | 2 ++ site/src/builtins/ls.js | 11 ++++++++ site/src/builtins/pwd.js | 7 +++++ site/src/fs.js | 57 ++++++++++++++++++++++++++++++++++++++ site/src/main.js | 5 ++++ 5 files changed, 82 insertions(+) create mode 100644 site/src/builtins/ls.js create mode 100644 site/src/builtins/pwd.js create mode 100644 site/src/fs.js diff --git a/site/src/builtins/index.js b/site/src/builtins/index.js index 7640493..7e987de 100644 --- a/site/src/builtins/index.js +++ b/site/src/builtins/index.js @@ -1,3 +1,5 @@ export * from "./commands"; export * from "./sleep"; export * from "./printenv"; +export * from "./pwd"; +export * from "./ls"; diff --git a/site/src/builtins/ls.js b/site/src/builtins/ls.js new file mode 100644 index 0000000..8003b0d --- /dev/null +++ b/site/src/builtins/ls.js @@ -0,0 +1,11 @@ +import * as fs from "../fs.js"; + +export const ls = { + definition: {}, + cliOptions: {}, + action: async () => { + const cwd = fs.getCwd(); + const contents = await fs.readDir(cwd); + process.stdout.write(contents.map((c) => c.name).join(" ")); + }, +}; diff --git a/site/src/builtins/pwd.js b/site/src/builtins/pwd.js new file mode 100644 index 0000000..0a889fd --- /dev/null +++ b/site/src/builtins/pwd.js @@ -0,0 +1,7 @@ +import * as fs from "../fs"; + +export const pwd = { + definition: {}, + cliOptions: {}, + action: () => process.stdout.write(fs.getCwd()), +}; diff --git a/site/src/fs.js b/site/src/fs.js new file mode 100644 index 0000000..d3b9d3f --- /dev/null +++ b/site/src/fs.js @@ -0,0 +1,57 @@ +//TODO change to a class +const root = await navigator.storage.getDirectory(); +const config = { + CWD: "/", +}; + +export function getCwd() { + return config.CWD; +} + +export function setCwd(cwd) { + config.CWD = cwd; +} + +async function getDirHandle(pathOrParts, create) { + const parts = Array.isArray(pathOrParts) ? pathOrParts : pathOrParts.split("/").filter(Boolean); + let h = root; + for (const part of parts) { + h = await h.getDirectoryHandle(part, { create }); + } + return h; +} + +async function getFileHandle(path, create) { + const parts = path.split("/").filter(Boolean); + const dirh = await getDirHandle(parts.slice(0, parts.length - 1), create); + return dirh.getFileHandle(parts[parts.length - 1], { create }); +} + +export async function writeFile(path, content) { + const handle = await getFileHandle(path, true); + const writable = await handle.createWritable(); + await writable.write(content); + await writable.close(); +} + +export async function readFile(path) { + const handle = await getFileHandle(path); + return handle.getFile().then((f) => f.text()); +} + +export async function readDir(path) { + const handle = await getDirHandle(path); + const entries = []; + for await (const [name, value] of handle.entries()) { + entries.push({ name, type: value.kind }); + } + return entries; +} + +// Create initial FS structure +export async function init(fileMap) { + await root.remove(); + for (const f in fileMap) { + await writeFile(f, fileMap[f]); + } +} diff --git a/site/src/main.js b/site/src/main.js index 97ada2c..7bf4572 100644 --- a/site/src/main.js +++ b/site/src/main.js @@ -1,6 +1,7 @@ import handleKey from "./key-handler.js"; import * as renderer from "./renderer.js"; import * as history from "./history.js"; +import * as fs from "./fs.js"; import execute from "./bash/interpreter.js"; import * as builtincmds from "./builtins"; import "./cli.web.js"; @@ -33,8 +34,12 @@ process.stderr.write = (v) => r(v, { error: true }); // Setup initial env values Object.assign(process.env, { SHELL: "cliersh", + USER: "guest", }); +// Initialize FS +fs.init({ "/README.md": "hello", "/users/guest/info.txt": "" }); + handleKey(i, { Enter: () => { let inputValue = i.value; From 39d212545c6e6f93940f060998748c0f4d45a276 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Cort=C3=B3n=20Cobas?= <104267232+carloscortonc@users.noreply.github.com> Date: Thu, 29 Jan 2026 11:31:32 +0100 Subject: [PATCH 49/52] chore: refactor fs module into a class --- site/src/builtins/ls.js | 2 +- site/src/builtins/pwd.js | 2 +- site/src/fs.js | 92 +++++++++++++++++++++------------------- site/src/main.js | 2 +- 4 files changed, 51 insertions(+), 47 deletions(-) diff --git a/site/src/builtins/ls.js b/site/src/builtins/ls.js index 8003b0d..145074f 100644 --- a/site/src/builtins/ls.js +++ b/site/src/builtins/ls.js @@ -1,4 +1,4 @@ -import * as fs from "../fs.js"; +import fs from "../fs.js"; export const ls = { definition: {}, diff --git a/site/src/builtins/pwd.js b/site/src/builtins/pwd.js index 0a889fd..e595ccb 100644 --- a/site/src/builtins/pwd.js +++ b/site/src/builtins/pwd.js @@ -1,4 +1,4 @@ -import * as fs from "../fs"; +import fs from "../fs"; export const pwd = { definition: {}, diff --git a/site/src/fs.js b/site/src/fs.js index d3b9d3f..e9c45f2 100644 --- a/site/src/fs.js +++ b/site/src/fs.js @@ -1,57 +1,61 @@ -//TODO change to a class const root = await navigator.storage.getDirectory(); -const config = { - CWD: "/", -}; -export function getCwd() { - return config.CWD; -} +class FileSystem { + cwd = "/"; -export function setCwd(cwd) { - config.CWD = cwd; -} + getCwd() { + return this.cwd; + } -async function getDirHandle(pathOrParts, create) { - const parts = Array.isArray(pathOrParts) ? pathOrParts : pathOrParts.split("/").filter(Boolean); - let h = root; - for (const part of parts) { - h = await h.getDirectoryHandle(part, { create }); + setCwd(cwd) { + this.cwd = cwd; } - return h; -} -async function getFileHandle(path, create) { - const parts = path.split("/").filter(Boolean); - const dirh = await getDirHandle(parts.slice(0, parts.length - 1), create); - return dirh.getFileHandle(parts[parts.length - 1], { create }); -} + async #getDirHandle(pathOrParts, create) { + const parts = Array.isArray(pathOrParts) ? pathOrParts : pathOrParts.split("/").filter(Boolean); + let h = root; + for (const part of parts) { + h = await h.getDirectoryHandle(part, { create }); + } + return h; + } -export async function writeFile(path, content) { - const handle = await getFileHandle(path, true); - const writable = await handle.createWritable(); - await writable.write(content); - await writable.close(); -} + async #getFileHandle(path, create) { + const parts = path.split("/").filter(Boolean); + const dirh = await this.#getDirHandle(parts.slice(0, parts.length - 1), create); + return dirh.getFileHandle(parts[parts.length - 1], { create }); + } -export async function readFile(path) { - const handle = await getFileHandle(path); - return handle.getFile().then((f) => f.text()); -} + async writeFile(path, content) { + const handle = await this.#getFileHandle(path, true); + const writable = await handle.createWritable(); + await writable.write(content); + await writable.close(); + } -export async function readDir(path) { - const handle = await getDirHandle(path); - const entries = []; - for await (const [name, value] of handle.entries()) { - entries.push({ name, type: value.kind }); + async readFile(path) { + const handle = await this.#getFileHandle(path); + return handle.getFile().then((f) => f.text()); } - return entries; -} -// Create initial FS structure -export async function init(fileMap) { - await root.remove(); - for (const f in fileMap) { - await writeFile(f, fileMap[f]); + async readDir(path) { + const handle = await this.#getDirHandle(path); + const entries = []; + for await (const [name, value] of handle.entries()) { + entries.push({ name, type: value.kind }); + } + return entries; + } + + /** Create initial FS structure */ + async init(fileMap) { + await root.remove(); + for (const f in fileMap) { + await this.writeFile(f, fileMap[f]); + } } } + +const fs = new FileSystem(); + +export default fs; diff --git a/site/src/main.js b/site/src/main.js index 7bf4572..f67640f 100644 --- a/site/src/main.js +++ b/site/src/main.js @@ -1,7 +1,7 @@ import handleKey from "./key-handler.js"; import * as renderer from "./renderer.js"; import * as history from "./history.js"; -import * as fs from "./fs.js"; +import fs from "./fs.js"; import execute from "./bash/interpreter.js"; import * as builtincmds from "./builtins"; import "./cli.web.js"; From c6bca44250ac28d89b37e41a60cfa162a9f6db85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Cort=C3=B3n=20Cobas?= <104267232+carloscortonc@users.noreply.github.com> Date: Thu, 29 Jan 2026 13:37:45 +0100 Subject: [PATCH 50/52] feat: enable stdin reading from worker --- site/public/commands/echo.js | 2 +- site/shims/fs.js | 2 +- site/src/bash/exec-worker.js | 25 ++++++++++++++++++++++--- site/src/bash/interpreter.js | 13 ++++++++++--- site/src/fs.js | 34 ++++++++++++++++++++++++++++++++++ site/src/kernel.js | 11 +++++++++++ site/src/main.js | 3 ++- 7 files changed, 81 insertions(+), 9 deletions(-) create mode 100644 site/src/kernel.js diff --git a/site/public/commands/echo.js b/site/public/commands/echo.js index d14577c..1c93eed 100644 --- a/site/public/commands/echo.js +++ b/site/public/commands/echo.js @@ -1,5 +1,5 @@ export default { - definition: { args: { type: "string", positional: true } }, + definition: { args: { type: "string", positional: true, stdin: true } }, cliOptions: { help: { hidden: true } }, action: ({ args }) => args && Cli.logger.log(args.join(" ")), }; diff --git a/site/shims/fs.js b/site/shims/fs.js index ea20429..eb1dd56 100644 --- a/site/shims/fs.js +++ b/site/shims/fs.js @@ -1,5 +1,5 @@ module.exports = { - readFileSync: (fd) => FS_FILES?.[fd], + readFileSync: () => "", existsSync: () => true, realpathSync: () => "", }; diff --git a/site/src/bash/exec-worker.js b/site/src/bash/exec-worker.js index 07ba4d4..a5406a7 100644 --- a/site/src/bash/exec-worker.js +++ b/site/src/bash/exec-worker.js @@ -1,5 +1,6 @@ import "../cli.web.js"; import { deserialize } from "./serializer.js"; +import fs from "../fs.js"; import run from "./cli-runner.js"; // Send output to main thread @@ -7,13 +8,31 @@ process.stdout.write = (value) => postMessage({ type: "output", stream: "stdout" process.stderr.write = (value) => postMessage({ type: "output", stream: "stderr", value }); self.onmessage = async (e) => { - const { name, cliSpec, args, env, cliHandlerUrl } = deserialize(e.data); + const { name, cliSpec, args, process: p, cliHandlerUrl } = deserialize(e.data); + + // Merge incoming process-data into process object + merge(process, p); + // Setup handler url require("url").pathToFileURL = () => ({ href: cliHandlerUrl }); - // Copy env values - process.env = env; + // Setup fs bridge + require("fs").readFileSync = fs.readFileSync.bind(fs); + + const cleanup = await fs.wwPrepareStdinHandle(); await run({ name, cliSpec, args }); + cleanup(); + postMessage({ type: "exit", exitCode: process.exitCode }); }; + +function merge(target, source) { + for (const k in source) { + if (typeof source[k] === "object") { + merge(target[k], source[k]); + } else { + target[k] = source[k]; + } + } +} diff --git a/site/src/bash/interpreter.js b/site/src/bash/interpreter.js index 063b608..d12531b 100644 --- a/site/src/bash/interpreter.js +++ b/site/src/bash/interpreter.js @@ -1,6 +1,8 @@ import { grammar, semantics } from "./grammar.js"; import ExecWorker from "./exec-worker?worker"; import { serialize } from "./serializer.js"; +import kernel from "../kernel.js"; +import fs from "../fs.js"; import run from "./cli-runner.js"; const execWorker = new ExecWorker(); @@ -48,7 +50,7 @@ async function executeAst(node) { // https://www.gnu.org/software/bash/manual/bash.html#Special-Parameters-1 if (v === "0") return process.env.SHELL; if (v == "?") return process.lastExitCode.toString(); - if (v == "$") return "0001"; + if (v == "$") return kernel.getpid(); return process.env[v]; } if (node.type === "cmd") { @@ -78,8 +80,11 @@ async function executeAst(node) { return process.exit(process.exitCode); } + // Create and object containing required process properties for the worker + const p = { env, stdout: { columns: process.stdout.columns }, stdin: { isTTY: process.stdin.isTTY } }; + return new Promise((resolve) => { - execWorker.postMessage(serialize({ name: node.cmd, cliSpec, args, env, cliHandlerUrl })); + execWorker.postMessage(serialize({ name: node.cmd, cliSpec, args, process: p, cliHandlerUrl })); execWorker.onmessage = ({ data }) => { if (data.type === "output") { @@ -104,8 +109,10 @@ async function executeAst(node) { } } if (node.type === "pipe") { + const free = capture(); await executeAst(node.args[0]); - //TODO store otuput (FD[1]) in FD[0] + // Write captured value into cpid's fd=0 (stdin) + await fs.writeFile(fs.getProcessFdPath(0), free()); process.stdin.isTTY = false; await executeAst(node.args[1]); process.stdin.isTTY = true; diff --git a/site/src/fs.js b/site/src/fs.js index e9c45f2..3d6d1c9 100644 --- a/site/src/fs.js +++ b/site/src/fs.js @@ -1,7 +1,11 @@ +import kernel from "./kernel.js"; + const root = await navigator.storage.getDirectory(); class FileSystem { cwd = "/"; + // Store a dedicated handle for stdin fd (`/proc/{PID}/fd/0`) to be used in `readFileSync` + stdinHandle = null; getCwd() { return this.cwd; @@ -26,6 +30,10 @@ class FileSystem { return dirh.getFileHandle(parts[parts.length - 1], { create }); } + getProcessFdPath(fd) { + return `/proc/${kernel.getpid()}/fd/${fd}`; + } + async writeFile(path, content) { const handle = await this.#getFileHandle(path, true); const writable = await handle.createWritable(); @@ -38,6 +46,23 @@ class FileSystem { return handle.getFile().then((f) => f.text()); } + /** Compatibility method with original `fs.readFileSync` + * If a number is provided, treat it as file-descriptor, and read from `/proc/{PID}/fd/{fd}` + * We will assume the only fd requested will be 0 (used by cli-er) + */ + readFileSync(pathOrFd) { + if (typeof pathOrFd === "string") { + return ""; + } + if (pathOrFd !== 0) { + return ""; + } + const fileSize = this.stdinHandle.getSize(); + const buffer = new DataView(new ArrayBuffer(fileSize)); + this.stdinHandle.read(buffer, { at: 0 }); + return new TextDecoder("utf-8").decode(new Uint8Array(buffer.buffer)); + } + async readDir(path) { const handle = await this.#getDirHandle(path); const entries = []; @@ -53,6 +78,15 @@ class FileSystem { for (const f in fileMap) { await this.writeFile(f, fileMap[f]); } + // Create empty fd=0 file + await this.writeFile(this.getProcessFdPath(0), ""); + } + + // Create a stdin handle for web-worker + async wwPrepareStdinHandle() { + this.stdinHandle = await this.#getFileHandle(this.getProcessFdPath(0)).then((h) => h.createSyncAccessHandle()); + // Return cleanup function + return () => this.stdinHandle.close(); } } diff --git a/site/src/kernel.js b/site/src/kernel.js new file mode 100644 index 0000000..fb13611 --- /dev/null +++ b/site/src/kernel.js @@ -0,0 +1,11 @@ +class Kernel { + cpid = 1; + + getpid() { + return this.cpid; + } +} + +const kernel = new Kernel(); + +export default kernel; diff --git a/site/src/main.js b/site/src/main.js index f67640f..85931b6 100644 --- a/site/src/main.js +++ b/site/src/main.js @@ -38,7 +38,8 @@ Object.assign(process.env, { }); // Initialize FS -fs.init({ "/README.md": "hello", "/users/guest/info.txt": "" }); +await fs.init({ "/README.md": "hello" }); +require("fs").readFileSync = fs.readFileSync.bind(fs); handleKey(i, { Enter: () => { From c038a889fcfcadfd0d3dea2b7073b42fe9292ca6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Cort=C3=B3n=20Cobas?= <104267232+carloscortonc@users.noreply.github.com> Date: Thu, 29 Jan 2026 13:45:14 +0100 Subject: [PATCH 51/52] fix: configure worker as esmodule --- site/vite.config.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/site/vite.config.js b/site/vite.config.js index d47df5e..3076350 100644 --- a/site/vite.config.js +++ b/site/vite.config.js @@ -9,4 +9,7 @@ export default { // Avoid replacing "process.env" references "process.env": "process.env", }, + worker: { + format: "es", + }, }; From 1bcbb9f4937221164b677d42d9716f0dc4629621 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Cort=C3=B3n=20Cobas?= <104267232+carloscortonc@users.noreply.github.com> Date: Thu, 29 Jan 2026 17:27:14 +0100 Subject: [PATCH 52/52] chore: update releaserc to include docs/site --- .github/workflows/docs.yaml | 3 +-- .releaserc.json | 32 ++++++++++++++++++++++++++++++-- 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/.github/workflows/docs.yaml b/.github/workflows/docs.yaml index 655a137..3502cb4 100644 --- a/.github/workflows/docs.yaml +++ b/.github/workflows/docs.yaml @@ -2,8 +2,7 @@ name: Build and Deploy Docs on: push: - branches: [main] - pull_request: + branches: [develop] workflow_dispatch: permissions: diff --git a/.releaserc.json b/.releaserc.json index d51edff..dead85c 100644 --- a/.releaserc.json +++ b/.releaserc.json @@ -1,8 +1,36 @@ { "branches": ["main"], "plugins": [ - "@semantic-release/commit-analyzer", - ["@semantic-release/release-notes-generator", { "writerOpts": { "commitGroupsSort": ["feat", "fix"] } }], + [ + "@semantic-release/commit-analyzer", + { + "releaseRules": [ + { "breaking": true, "release": "major" }, + { "type": "feat", "release": "minor" }, + { "type": "fix", "release": "patch" }, + { "type": "docs", "release": false }, + { "type": "site", "release": false }, + { "type": "perf", "release": "patch" }, + { "type": "refactor", "release": "patch" } + ] + } + ], + [ + "@semantic-release/release-notes-generator", + { + "writerOpts": { + "types": [ + { "type": "feat", "section": "Features" }, + { "type": "fix", "section": "Bug Fixes" }, + { "type": "docs", "section": "Documentation" }, + { "type": "site", "section": "Site Changes" }, + { "type": "perf", "section": "Performance Improvements" }, + { "type": "refactor", "section": "Code Refactoring" } + ], + "commitGroupsSort": ["feat", "fix", "perf", "docs", "site"] + } + } + ], "@semantic-release/changelog", "@semantic-release/npm", ["@semantic-release/git", { "message": "chore(release): ${nextRelease.version}" }],