@supanadit/morse-web 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +95 -0
- package/dist/cli.mjs +539 -0
- package/dist/cli.mjs.map +7 -0
- package/dist/server.mjs +3910 -0
- package/dist/server.mjs.map +7 -0
- package/dist/ui/3rdpartylicenses.txt +591 -0
- package/dist/ui/favicon.ico +0 -0
- package/dist/ui/favicon.svg +9 -0
- package/dist/ui/index.html +44 -0
- package/dist/ui/main-6YN6VQNO.js +88 -0
- package/dist/ui/styles-WKFZG3MN.css +1 -0
- package/dist/ui/webview.manifest.json +7 -0
- package/package.json +56 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 supanadit
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# @supanadit/morse-web
|
|
2
|
+
|
|
3
|
+
**Morse in your browser, as one npm package.** It serves the same Angular frontend and drives the same
|
|
4
|
+
[Pi](https://github.com/earendil-works/pi) coding agent as the VS Code extension — no VS Code required.
|
|
5
|
+
|
|
6
|
+
```bash
|
|
7
|
+
npm install -g @supanadit/morse-web
|
|
8
|
+
morse start
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
▲ Morse started
|
|
13
|
+
│
|
|
14
|
+
◆ port 4399 (PID: 20880)
|
|
15
|
+
│
|
|
16
|
+
● visit: http://127.0.0.1:4399/
|
|
17
|
+
● logs: morse logs
|
|
18
|
+
│
|
|
19
|
+
└ daemon running — the terminal can be closed
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Then open <http://127.0.0.1:4399/>. The server keeps running in the background; stop it with `morse stop`.
|
|
23
|
+
|
|
24
|
+
## Requirements
|
|
25
|
+
|
|
26
|
+
- Node.js 20+ (developed on 24).
|
|
27
|
+
- The `pi` CLI on your `PATH` and already authenticated (`pi --version`), or a path via `MORSE_PI_PATH`.
|
|
28
|
+
Morse reuses your existing pi configuration: models, credentials, tools and sessions under `~/.pi`.
|
|
29
|
+
|
|
30
|
+
## Commands
|
|
31
|
+
|
|
32
|
+
| Command | What it does |
|
|
33
|
+
|---|---|
|
|
34
|
+
| `morse` / `morse start` | Start the server as a background daemon and print the URL |
|
|
35
|
+
| `morse start -f` | Run in the foreground (systemd `Type=simple`, Docker) |
|
|
36
|
+
| `morse stop` | Stop the running server |
|
|
37
|
+
| `morse restart` | Stop, then start |
|
|
38
|
+
| `morse status` | Show PID, URL, workspace and uptime (`--json` for scripts) |
|
|
39
|
+
| `morse logs` | Print the server log (`--follow` to tail) |
|
|
40
|
+
| `morse help`, `morse version` | Help / version |
|
|
41
|
+
|
|
42
|
+
### Options
|
|
43
|
+
|
|
44
|
+
```
|
|
45
|
+
-p, --port <n> Web server port (default: 4399, next free if taken)
|
|
46
|
+
--host <addr> Bind address (default: 127.0.0.1)
|
|
47
|
+
--lan Bind 0.0.0.0 so the LAN can reach it
|
|
48
|
+
-f, --foreground Run in the foreground
|
|
49
|
+
--workspace <dir> Directory the agent works in (default: the current one)
|
|
50
|
+
--projects <list> Comma/colon separated roots the agent may open
|
|
51
|
+
--open Open the browser once healthy
|
|
52
|
+
--force Replace a server this CLI already started
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
### Environment
|
|
56
|
+
|
|
57
|
+
```
|
|
58
|
+
MORSE_HOME Data directory (default: ~/.morse)
|
|
59
|
+
MORSE_PORT Default port
|
|
60
|
+
MORSE_HOST Default bind address
|
|
61
|
+
MORSE_WORKSPACE Workspace the agent runs in
|
|
62
|
+
MORSE_PROJECTS Roots the agent may open
|
|
63
|
+
MORSE_HOT_SESSIONS How many pi processes stay alive (default: 4)
|
|
64
|
+
MORSE_PI_PATH Path to the pi executable (default: pi on PATH)
|
|
65
|
+
MORSE_PI_ENTRY Run a pi RPC entry with node instead of the binary
|
|
66
|
+
MORSE_UI_DIR Override the served frontend directory
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Security
|
|
70
|
+
|
|
71
|
+
`morse start` binds `127.0.0.1` by default. `--lan` exposes an **unauthenticated** chat UI to your network:
|
|
72
|
+
only do it on a trusted network, or put a TLS reverse proxy with auth in front of it.
|
|
73
|
+
|
|
74
|
+
## How the single package is built
|
|
75
|
+
|
|
76
|
+
Everything lives in one tarball:
|
|
77
|
+
|
|
78
|
+
```
|
|
79
|
+
dist/server.mjs NestJS host, with every @morse/* workspace package inlined
|
|
80
|
+
dist/cli.mjs the `morse` lifecycle CLI
|
|
81
|
+
dist/ui/ the built Angular frontend, served as static assets
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
The `@morse/*` packages are private workspace libraries, so they cannot be installed from npm — bundling
|
|
85
|
+
them into `server.mjs` is what makes a single publishable artifact possible. The NestJS runtime
|
|
86
|
+
(`@nestjs/*`, `rxjs`, `ws`, `reflect-metadata`) stays external and is installed normally from
|
|
87
|
+
`dependencies`.
|
|
88
|
+
|
|
89
|
+
## Uninstall
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
morse stop
|
|
93
|
+
npm uninstall -g @supanadit/morse-web
|
|
94
|
+
rm -rf ~/.morse # state and logs
|
|
95
|
+
```
|
package/dist/cli.mjs
ADDED
|
@@ -0,0 +1,539 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { spawn } from "node:child_process";
|
|
5
|
+
import { createServer } from "node:net";
|
|
6
|
+
import {
|
|
7
|
+
existsSync,
|
|
8
|
+
mkdirSync,
|
|
9
|
+
openSync,
|
|
10
|
+
readFileSync,
|
|
11
|
+
rmSync,
|
|
12
|
+
writeFileSync
|
|
13
|
+
} from "node:fs";
|
|
14
|
+
import { homedir } from "node:os";
|
|
15
|
+
import { dirname, join, resolve } from "node:path";
|
|
16
|
+
import { fileURLToPath } from "node:url";
|
|
17
|
+
var CLI_DIR = dirname(fileURLToPath(import.meta.url));
|
|
18
|
+
var PACKAGE_ROOT = join(CLI_DIR, "..");
|
|
19
|
+
var SERVER_ENTRY = join(CLI_DIR, "server.mjs");
|
|
20
|
+
var UI_DIR = join(CLI_DIR, "ui");
|
|
21
|
+
var PACKAGE_JSON = join(PACKAGE_ROOT, "package.json");
|
|
22
|
+
var MORSE_HOME = process.env.MORSE_HOME?.trim() || join(homedir(), ".morse");
|
|
23
|
+
var STATE_FILE = join(MORSE_HOME, "server.json");
|
|
24
|
+
var LOG_DIR = join(MORSE_HOME, "logs");
|
|
25
|
+
var LOG_FILE = join(LOG_DIR, "server.log");
|
|
26
|
+
var DEFAULT_PORT = 4399;
|
|
27
|
+
var DEFAULT_HOST = "127.0.0.1";
|
|
28
|
+
var HEALTH_PATH = "/api/health";
|
|
29
|
+
var colorEnabled = process.stdout.isTTY === true && process.env.NO_COLOR === void 0;
|
|
30
|
+
var paint = (code) => (text) => colorEnabled ? `\x1B[${code}m${text}\x1B[0m` : text;
|
|
31
|
+
var dim = paint(2);
|
|
32
|
+
var bold = paint(1);
|
|
33
|
+
var cyan = paint(36);
|
|
34
|
+
var green = paint(32);
|
|
35
|
+
var yellow = paint(33);
|
|
36
|
+
var red = paint(31);
|
|
37
|
+
var COMMANDS = /* @__PURE__ */ new Set([
|
|
38
|
+
"start",
|
|
39
|
+
"serve",
|
|
40
|
+
"stop",
|
|
41
|
+
"restart",
|
|
42
|
+
"status",
|
|
43
|
+
"logs",
|
|
44
|
+
"help",
|
|
45
|
+
"version"
|
|
46
|
+
]);
|
|
47
|
+
function parseArgs(argv) {
|
|
48
|
+
const options = {
|
|
49
|
+
command: "start",
|
|
50
|
+
lan: false,
|
|
51
|
+
foreground: false,
|
|
52
|
+
open: false,
|
|
53
|
+
follow: false,
|
|
54
|
+
force: false,
|
|
55
|
+
json: false,
|
|
56
|
+
help: false,
|
|
57
|
+
version: false
|
|
58
|
+
};
|
|
59
|
+
let commandSeen = false;
|
|
60
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
61
|
+
const arg = argv[index];
|
|
62
|
+
const [flag, inline] = splitFlag(arg);
|
|
63
|
+
const takeValue = () => {
|
|
64
|
+
if (inline !== void 0) return inline;
|
|
65
|
+
const next = argv[index + 1];
|
|
66
|
+
if (next === void 0 || next.startsWith("-")) {
|
|
67
|
+
throw new Error(`${flag} needs a value`);
|
|
68
|
+
}
|
|
69
|
+
index += 1;
|
|
70
|
+
return next;
|
|
71
|
+
};
|
|
72
|
+
switch (flag) {
|
|
73
|
+
case "start":
|
|
74
|
+
case "serve":
|
|
75
|
+
case "stop":
|
|
76
|
+
case "restart":
|
|
77
|
+
case "status":
|
|
78
|
+
case "logs":
|
|
79
|
+
case "help":
|
|
80
|
+
case "version":
|
|
81
|
+
options.command = flag;
|
|
82
|
+
commandSeen = true;
|
|
83
|
+
break;
|
|
84
|
+
case "-p":
|
|
85
|
+
case "--port":
|
|
86
|
+
options.port = parsePort(takeValue(), flag);
|
|
87
|
+
break;
|
|
88
|
+
case "--host":
|
|
89
|
+
case "--hostname":
|
|
90
|
+
options.host = takeValue();
|
|
91
|
+
break;
|
|
92
|
+
case "--workspace":
|
|
93
|
+
case "--cwd":
|
|
94
|
+
options.workspace = takeValue();
|
|
95
|
+
break;
|
|
96
|
+
case "--projects":
|
|
97
|
+
options.projects = takeValue();
|
|
98
|
+
break;
|
|
99
|
+
case "--lan":
|
|
100
|
+
options.lan = true;
|
|
101
|
+
break;
|
|
102
|
+
case "-f":
|
|
103
|
+
case "--foreground":
|
|
104
|
+
case "--no-daemon":
|
|
105
|
+
options.foreground = true;
|
|
106
|
+
break;
|
|
107
|
+
case "--open":
|
|
108
|
+
options.open = true;
|
|
109
|
+
break;
|
|
110
|
+
case "--follow":
|
|
111
|
+
options.follow = true;
|
|
112
|
+
break;
|
|
113
|
+
case "--force":
|
|
114
|
+
options.force = true;
|
|
115
|
+
break;
|
|
116
|
+
case "--json":
|
|
117
|
+
options.json = true;
|
|
118
|
+
break;
|
|
119
|
+
case "-h":
|
|
120
|
+
case "--help":
|
|
121
|
+
options.help = true;
|
|
122
|
+
break;
|
|
123
|
+
case "-v":
|
|
124
|
+
case "--version":
|
|
125
|
+
options.version = true;
|
|
126
|
+
break;
|
|
127
|
+
default:
|
|
128
|
+
if (!arg.startsWith("-") && !commandSeen) {
|
|
129
|
+
options.command = arg;
|
|
130
|
+
commandSeen = true;
|
|
131
|
+
break;
|
|
132
|
+
}
|
|
133
|
+
throw new Error(`Unknown option "${arg}". Run \`morse help\`.`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
if (!COMMANDS.has(options.command)) {
|
|
137
|
+
throw new Error(`Unknown command "${options.command}". Run \`morse help\`.`);
|
|
138
|
+
}
|
|
139
|
+
return options;
|
|
140
|
+
}
|
|
141
|
+
function splitFlag(arg) {
|
|
142
|
+
if (!arg.startsWith("--")) return [arg, void 0];
|
|
143
|
+
const equals = arg.indexOf("=");
|
|
144
|
+
return equals === -1 ? [arg, void 0] : [arg.slice(0, equals), arg.slice(equals + 1)];
|
|
145
|
+
}
|
|
146
|
+
function parsePort(value, flag) {
|
|
147
|
+
const port = Number.parseInt(value, 10);
|
|
148
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
149
|
+
throw new Error(`${flag} expects a port between 1 and 65535`);
|
|
150
|
+
}
|
|
151
|
+
return port;
|
|
152
|
+
}
|
|
153
|
+
function readState() {
|
|
154
|
+
try {
|
|
155
|
+
const parsed = JSON.parse(readFileSync(STATE_FILE, "utf8"));
|
|
156
|
+
if (typeof parsed.pid !== "number" || typeof parsed.port !== "number") return void 0;
|
|
157
|
+
return {
|
|
158
|
+
pid: parsed.pid,
|
|
159
|
+
port: parsed.port,
|
|
160
|
+
host: parsed.host ?? DEFAULT_HOST,
|
|
161
|
+
url: parsed.url ?? `http://${DEFAULT_HOST}:${parsed.port}/`,
|
|
162
|
+
workspace: parsed.workspace ?? process.cwd(),
|
|
163
|
+
startedAt: parsed.startedAt ?? (/* @__PURE__ */ new Date()).toISOString(),
|
|
164
|
+
logFile: parsed.logFile ?? LOG_FILE
|
|
165
|
+
};
|
|
166
|
+
} catch {
|
|
167
|
+
return void 0;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
function writeState(state) {
|
|
171
|
+
mkdirSync(MORSE_HOME, { recursive: true });
|
|
172
|
+
writeFileSync(STATE_FILE, `${JSON.stringify(state, null, 2)}
|
|
173
|
+
`, "utf8");
|
|
174
|
+
}
|
|
175
|
+
function clearState() {
|
|
176
|
+
rmSync(STATE_FILE, { force: true });
|
|
177
|
+
}
|
|
178
|
+
function isAlive(pid) {
|
|
179
|
+
try {
|
|
180
|
+
process.kill(pid, 0);
|
|
181
|
+
return true;
|
|
182
|
+
} catch (error) {
|
|
183
|
+
return error.code === "EPERM";
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
function displayHost(host) {
|
|
187
|
+
return host === "0.0.0.0" || host === "::" ? DEFAULT_HOST : host;
|
|
188
|
+
}
|
|
189
|
+
function healthUrl(state) {
|
|
190
|
+
return `http://${displayHost(state.host)}:${state.port}${HEALTH_PATH}`;
|
|
191
|
+
}
|
|
192
|
+
async function probe(url) {
|
|
193
|
+
try {
|
|
194
|
+
const response = await fetch(url, { signal: AbortSignal.timeout(1500) });
|
|
195
|
+
return response.ok;
|
|
196
|
+
} catch {
|
|
197
|
+
return false;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
async function waitForHealth(url, timeoutMs) {
|
|
201
|
+
const deadline = Date.now() + timeoutMs;
|
|
202
|
+
for (; ; ) {
|
|
203
|
+
if (await probe(url)) return true;
|
|
204
|
+
if (Date.now() >= deadline) return false;
|
|
205
|
+
await sleep(250);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
async function isPortFree(port) {
|
|
209
|
+
return new Promise((resolvePort) => {
|
|
210
|
+
const server = createServer();
|
|
211
|
+
server.once("error", () => resolvePort(false));
|
|
212
|
+
server.once("listening", () => server.close(() => resolvePort(true)));
|
|
213
|
+
server.listen(port, DEFAULT_HOST);
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
async function findFreePort(start2) {
|
|
217
|
+
for (let port = start2; port < start2 + 100; port += 1) {
|
|
218
|
+
if (await isPortFree(port)) return port;
|
|
219
|
+
}
|
|
220
|
+
throw new Error(`No free port found in ${start2}..${start2 + 99}`);
|
|
221
|
+
}
|
|
222
|
+
function sleep(ms) {
|
|
223
|
+
return new Promise((resolveSleep) => setTimeout(resolveSleep, ms));
|
|
224
|
+
}
|
|
225
|
+
async function start(options) {
|
|
226
|
+
const existing = readState();
|
|
227
|
+
if (existing && isAlive(existing.pid)) {
|
|
228
|
+
if (!options.force) {
|
|
229
|
+
console.log(`${yellow("Morse is already running")} (PID ${existing.pid})`);
|
|
230
|
+
console.log(` visit: ${cyan(existing.url)}`);
|
|
231
|
+
console.log(` stop: ${dim(`morse stop`)}`);
|
|
232
|
+
return 0;
|
|
233
|
+
}
|
|
234
|
+
await stop({ quiet: true });
|
|
235
|
+
}
|
|
236
|
+
clearState();
|
|
237
|
+
const workspace = options.workspace ? resolve(options.workspace) : process.cwd();
|
|
238
|
+
if (!existsSync(workspace)) {
|
|
239
|
+
throw new Error(`Workspace does not exist: ${workspace}`);
|
|
240
|
+
}
|
|
241
|
+
const envPort = process.env.MORSE_PORT ? Number.parseInt(process.env.MORSE_PORT, 10) : NaN;
|
|
242
|
+
let port = options.port ?? (Number.isInteger(envPort) ? envPort : void 0) ?? DEFAULT_PORT;
|
|
243
|
+
if (options.port === void 0 && !Number.isInteger(envPort)) {
|
|
244
|
+
port = await findFreePort(DEFAULT_PORT);
|
|
245
|
+
} else if (!await isPortFree(port)) {
|
|
246
|
+
throw new Error(`Port ${port} is already in use. Pass --port to pick another one.`);
|
|
247
|
+
}
|
|
248
|
+
const host = options.lan ? "0.0.0.0" : options.host ?? process.env.MORSE_HOST ?? DEFAULT_HOST;
|
|
249
|
+
const env = buildEnv(options, port, host, workspace);
|
|
250
|
+
if (options.foreground) {
|
|
251
|
+
return startForeground(env, port, host, workspace);
|
|
252
|
+
}
|
|
253
|
+
return startDaemon(env, port, host, workspace, options);
|
|
254
|
+
}
|
|
255
|
+
function buildEnv(options, port, host, workspace) {
|
|
256
|
+
const env = {
|
|
257
|
+
...process.env,
|
|
258
|
+
MORSE_HOST: host,
|
|
259
|
+
MORSE_PORT: String(port),
|
|
260
|
+
MORSE_UI_DIR: UI_DIR,
|
|
261
|
+
MORSE_WORKSPACE: workspace
|
|
262
|
+
};
|
|
263
|
+
if (options.projects) env.MORSE_PROJECTS = options.projects;
|
|
264
|
+
return env;
|
|
265
|
+
}
|
|
266
|
+
async function startDaemon(env, port, host, workspace, options) {
|
|
267
|
+
mkdirSync(LOG_DIR, { recursive: true });
|
|
268
|
+
writeFileSync(
|
|
269
|
+
LOG_FILE,
|
|
270
|
+
`
|
|
271
|
+
=== morse start ${(/* @__PURE__ */ new Date()).toISOString()} (port ${port}) ===
|
|
272
|
+
`,
|
|
273
|
+
{ flag: "a" }
|
|
274
|
+
);
|
|
275
|
+
const logFd = openSync(LOG_FILE, "a");
|
|
276
|
+
const child = spawn(process.execPath, [SERVER_ENTRY], {
|
|
277
|
+
cwd: workspace,
|
|
278
|
+
env,
|
|
279
|
+
detached: true,
|
|
280
|
+
// The daemon owns no terminal: closing the shell must not send it SIGHUP.
|
|
281
|
+
stdio: ["ignore", logFd, logFd]
|
|
282
|
+
});
|
|
283
|
+
child.unref();
|
|
284
|
+
const state = {
|
|
285
|
+
pid: child.pid ?? -1,
|
|
286
|
+
port,
|
|
287
|
+
host,
|
|
288
|
+
url: `http://${displayHost(host)}:${port}/`,
|
|
289
|
+
workspace,
|
|
290
|
+
startedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
291
|
+
logFile: LOG_FILE
|
|
292
|
+
};
|
|
293
|
+
writeState(state);
|
|
294
|
+
const healthy = await waitForHealth(healthUrl(state), 25e3);
|
|
295
|
+
if (!healthy) {
|
|
296
|
+
console.error(red("Morse failed to start."));
|
|
297
|
+
if (!isAlive(state.pid)) {
|
|
298
|
+
clearState();
|
|
299
|
+
console.error(dim(`Last lines of ${LOG_FILE}:`));
|
|
300
|
+
console.error(tailLog(40));
|
|
301
|
+
return 1;
|
|
302
|
+
}
|
|
303
|
+
console.error(dim("The process is alive but did not answer the health probe."));
|
|
304
|
+
console.error(dim(`Logs: morse logs`));
|
|
305
|
+
return 1;
|
|
306
|
+
}
|
|
307
|
+
console.log();
|
|
308
|
+
console.log(` ${green("\u25B2")} ${bold("Morse started")}`);
|
|
309
|
+
console.log(` \u2502`);
|
|
310
|
+
console.log(` \u25C6 port ${port} (PID: ${state.pid})`);
|
|
311
|
+
console.log(` \u2502`);
|
|
312
|
+
console.log(` \u25CF visit: ${cyan(state.url)}`);
|
|
313
|
+
console.log(` \u25CF logs: ${dim("morse logs")}`);
|
|
314
|
+
console.log(` \u2502`);
|
|
315
|
+
console.log(` \u2514 daemon running \u2014 the terminal can be closed`);
|
|
316
|
+
console.log();
|
|
317
|
+
if (options.open) openBrowser(state.url);
|
|
318
|
+
return 0;
|
|
319
|
+
}
|
|
320
|
+
async function startForeground(env, port, host, workspace) {
|
|
321
|
+
const child = spawn(process.execPath, [SERVER_ENTRY], { cwd: workspace, env, stdio: "inherit" });
|
|
322
|
+
writeState({
|
|
323
|
+
pid: child.pid ?? -1,
|
|
324
|
+
port,
|
|
325
|
+
host,
|
|
326
|
+
url: `http://${displayHost(host)}:${port}/`,
|
|
327
|
+
workspace,
|
|
328
|
+
startedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
329
|
+
logFile: LOG_FILE
|
|
330
|
+
});
|
|
331
|
+
const forward = (signal) => {
|
|
332
|
+
if (child.pid !== void 0) child.kill(signal);
|
|
333
|
+
};
|
|
334
|
+
process.on("SIGINT", () => forward("SIGINT"));
|
|
335
|
+
process.on("SIGTERM", () => forward("SIGTERM"));
|
|
336
|
+
const code = await new Promise((resolveExit) => {
|
|
337
|
+
child.on("exit", (exitCode) => resolveExit(exitCode ?? 0));
|
|
338
|
+
});
|
|
339
|
+
clearState();
|
|
340
|
+
return code;
|
|
341
|
+
}
|
|
342
|
+
async function stop(options = {}) {
|
|
343
|
+
const state = readState();
|
|
344
|
+
if (!state || !isAlive(state.pid)) {
|
|
345
|
+
clearState();
|
|
346
|
+
if (!options.quiet) console.log(dim("Morse is not running."));
|
|
347
|
+
return 0;
|
|
348
|
+
}
|
|
349
|
+
const { pid } = state;
|
|
350
|
+
process.kill(pid, "SIGTERM");
|
|
351
|
+
if (!await waitForExit(pid, 5e3)) {
|
|
352
|
+
process.kill(pid, "SIGKILL");
|
|
353
|
+
await waitForExit(pid, 2e3);
|
|
354
|
+
}
|
|
355
|
+
clearState();
|
|
356
|
+
if (!options.quiet) {
|
|
357
|
+
console.log(`${green("\u25CF")} Morse stopped (PID ${pid}).`);
|
|
358
|
+
}
|
|
359
|
+
return 0;
|
|
360
|
+
}
|
|
361
|
+
async function waitForExit(pid, timeoutMs) {
|
|
362
|
+
const deadline = Date.now() + timeoutMs;
|
|
363
|
+
while (Date.now() < deadline) {
|
|
364
|
+
if (!isAlive(pid)) return true;
|
|
365
|
+
await sleep(100);
|
|
366
|
+
}
|
|
367
|
+
return !isAlive(pid);
|
|
368
|
+
}
|
|
369
|
+
function status(options) {
|
|
370
|
+
const state = readState();
|
|
371
|
+
if (!state || !isAlive(state.pid)) {
|
|
372
|
+
clearState();
|
|
373
|
+
if (options.json) {
|
|
374
|
+
console.log(JSON.stringify({ running: false }));
|
|
375
|
+
} else {
|
|
376
|
+
console.log(dim("Morse is not running."));
|
|
377
|
+
}
|
|
378
|
+
return 1;
|
|
379
|
+
}
|
|
380
|
+
const uptimeMs = Date.now() - new Date(state.startedAt).getTime();
|
|
381
|
+
if (options.json) {
|
|
382
|
+
console.log(JSON.stringify({ running: true, ...state, uptimeMs }));
|
|
383
|
+
return 0;
|
|
384
|
+
}
|
|
385
|
+
console.log(`${green("\u25CF")} Morse is running (PID ${state.pid})`);
|
|
386
|
+
console.log(` visit: ${cyan(state.url)}`);
|
|
387
|
+
console.log(` workspace: ${state.workspace}`);
|
|
388
|
+
console.log(` uptime: ${formatUptime(uptimeMs)}`);
|
|
389
|
+
console.log(` logs: ${dim(state.logFile)}`);
|
|
390
|
+
return 0;
|
|
391
|
+
}
|
|
392
|
+
function formatUptime(uptimeMs) {
|
|
393
|
+
const seconds = Math.max(0, Math.floor(uptimeMs / 1e3));
|
|
394
|
+
const hours = Math.floor(seconds / 3600);
|
|
395
|
+
const minutes = Math.floor(seconds % 3600 / 60);
|
|
396
|
+
if (hours > 0) return `${hours}h ${minutes}m`;
|
|
397
|
+
if (minutes > 0) return `${minutes}m ${seconds % 60}s`;
|
|
398
|
+
return `${seconds}s`;
|
|
399
|
+
}
|
|
400
|
+
function tailLog(linesToShow) {
|
|
401
|
+
if (!existsSync(LOG_FILE)) return "(no log yet)";
|
|
402
|
+
const lines = readFileSync(LOG_FILE, "utf8").split("\n");
|
|
403
|
+
return lines.slice(-linesToShow).join("\n");
|
|
404
|
+
}
|
|
405
|
+
async function logs(options) {
|
|
406
|
+
if (!existsSync(LOG_FILE)) {
|
|
407
|
+
console.log(dim(`No log file yet (${LOG_FILE}).`));
|
|
408
|
+
return 0;
|
|
409
|
+
}
|
|
410
|
+
process.stdout.write(tailLog(200));
|
|
411
|
+
if (!options.follow) return 0;
|
|
412
|
+
let seen = readFileSync(LOG_FILE, "utf8").length;
|
|
413
|
+
await new Promise((resolveFollow) => {
|
|
414
|
+
const timer = setInterval(() => {
|
|
415
|
+
try {
|
|
416
|
+
const current = readFileSync(LOG_FILE, "utf8");
|
|
417
|
+
if (current.length > seen) {
|
|
418
|
+
process.stdout.write(current.slice(seen));
|
|
419
|
+
seen = current.length;
|
|
420
|
+
} else if (current.length < seen) {
|
|
421
|
+
seen = current.length;
|
|
422
|
+
}
|
|
423
|
+
} catch {
|
|
424
|
+
}
|
|
425
|
+
}, 400);
|
|
426
|
+
const stopFollowing = () => {
|
|
427
|
+
clearInterval(timer);
|
|
428
|
+
resolveFollow();
|
|
429
|
+
};
|
|
430
|
+
process.on("SIGINT", stopFollowing);
|
|
431
|
+
process.on("SIGTERM", stopFollowing);
|
|
432
|
+
});
|
|
433
|
+
return 0;
|
|
434
|
+
}
|
|
435
|
+
function openBrowser(url) {
|
|
436
|
+
const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
|
|
437
|
+
const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
|
|
438
|
+
try {
|
|
439
|
+
const child = spawn(command, args, { stdio: "ignore", detached: true });
|
|
440
|
+
child.unref();
|
|
441
|
+
} catch {
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
function readVersion() {
|
|
445
|
+
try {
|
|
446
|
+
return JSON.parse(readFileSync(PACKAGE_JSON, "utf8")).version ?? "0.0.0";
|
|
447
|
+
} catch {
|
|
448
|
+
return "0.0.0";
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
function printHelp() {
|
|
452
|
+
const name = `@supanadit/morse-web`;
|
|
453
|
+
console.log(`
|
|
454
|
+
${bold("Morse")} \u2014 the Comfortable Pi Interface, in your browser (${name})
|
|
455
|
+
|
|
456
|
+
${bold("USAGE")}
|
|
457
|
+
morse [COMMAND] [OPTIONS]
|
|
458
|
+
|
|
459
|
+
${bold("COMMANDS")}
|
|
460
|
+
start Start the web server in the background (default)
|
|
461
|
+
stop Stop the running server
|
|
462
|
+
restart Stop, then start the server
|
|
463
|
+
status Show whether the server is running
|
|
464
|
+
logs Print the server log (--follow to tail)
|
|
465
|
+
help Show this help
|
|
466
|
+
version Print the version
|
|
467
|
+
|
|
468
|
+
${bold("OPTIONS")}
|
|
469
|
+
-p, --port <n> Web server port (default: ${DEFAULT_PORT}, next free if taken)
|
|
470
|
+
--host <addr> Bind address (default: ${DEFAULT_HOST})
|
|
471
|
+
--lan Bind 0.0.0.0 so the LAN can reach it
|
|
472
|
+
-f, --foreground Run in the foreground (systemd Type=simple)
|
|
473
|
+
--workspace <dir> Directory the agent works in (default: the current one)
|
|
474
|
+
--projects <list> Comma/colon separated list of roots the agent may open
|
|
475
|
+
--open Open the browser once the server is healthy
|
|
476
|
+
--force Replace a server this CLI already started
|
|
477
|
+
--json Machine-readable output (status)
|
|
478
|
+
--follow Follow the log file (logs)
|
|
479
|
+
-h, --help Show this help
|
|
480
|
+
-v, --version Print the version
|
|
481
|
+
|
|
482
|
+
${bold("ENVIRONMENT")}
|
|
483
|
+
MORSE_HOME Data directory (default: ~/.morse)
|
|
484
|
+
MORSE_PORT Default port
|
|
485
|
+
MORSE_HOST Default bind address
|
|
486
|
+
MORSE_WORKSPACE Workspace the agent runs in
|
|
487
|
+
MORSE_PROJECTS Roots the agent may open
|
|
488
|
+
MORSE_HOT_SESSIONS How many pi processes stay alive (default: 4)
|
|
489
|
+
MORSE_PI_PATH Path to the pi executable (default: pi on PATH)
|
|
490
|
+
MORSE_PI_ENTRY Run a pi RPC entry with node instead of the binary
|
|
491
|
+
|
|
492
|
+
${bold("EXAMPLES")}
|
|
493
|
+
morse # start the daemon on ${DEFAULT_PORT}
|
|
494
|
+
morse start --port 8080 # a specific port
|
|
495
|
+
morse --lan --port 3002 # reachable on the LAN
|
|
496
|
+
morse start --open # start and open the browser
|
|
497
|
+
morse logs --follow # tail the server log
|
|
498
|
+
morse stop # stop the daemon
|
|
499
|
+
`);
|
|
500
|
+
}
|
|
501
|
+
async function run(argv) {
|
|
502
|
+
const options = parseArgs(argv);
|
|
503
|
+
if (options.version || options.command === "version") {
|
|
504
|
+
console.log(readVersion());
|
|
505
|
+
return 0;
|
|
506
|
+
}
|
|
507
|
+
if (options.help || options.command === "help") {
|
|
508
|
+
printHelp();
|
|
509
|
+
return 0;
|
|
510
|
+
}
|
|
511
|
+
switch (options.command) {
|
|
512
|
+
case "start":
|
|
513
|
+
case "serve":
|
|
514
|
+
return start(options);
|
|
515
|
+
case "stop":
|
|
516
|
+
return stop();
|
|
517
|
+
case "restart":
|
|
518
|
+
await stop({ quiet: true });
|
|
519
|
+
return start(options);
|
|
520
|
+
case "status":
|
|
521
|
+
return status(options);
|
|
522
|
+
case "logs":
|
|
523
|
+
return logs(options);
|
|
524
|
+
default:
|
|
525
|
+
printHelp();
|
|
526
|
+
return 0;
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
run(process.argv.slice(2)).then((code) => {
|
|
530
|
+
process.exitCode = code;
|
|
531
|
+
}).catch((error) => {
|
|
532
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
533
|
+
console.error(`${red("morse:")} ${message}`);
|
|
534
|
+
process.exitCode = 1;
|
|
535
|
+
});
|
|
536
|
+
export {
|
|
537
|
+
run
|
|
538
|
+
};
|
|
539
|
+
//# sourceMappingURL=cli.mjs.map
|