mulmoterminal 0.1.2 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -2
- package/bin/mulmoterminal.js +51 -7
- package/bin/update-check.js +37 -0
- package/bin/update-check.spec.ts +46 -0
- package/dist/assets/index-BCNbHKHg.js +225 -0
- package/dist/assets/{marp-ByJn8N3A.js → marp-BhLpsKq8.js} +1 -1
- package/dist/index.html +1 -1
- package/package.json +5 -2
- package/plugins/plugins.json +2 -1
- package/server/backends/artifacts.ts +67 -0
- package/server/index.ts +5 -0
- package/server/plugins-registry.ts +73 -3
- package/dist/assets/index-Bqu764bM.js +0 -186
package/README.md
CHANGED
|
@@ -24,7 +24,17 @@ npm install -g mulmoterminal
|
|
|
24
24
|
mulmoterminal
|
|
25
25
|
```
|
|
26
26
|
|
|
27
|
-
|
|
27
|
+
A global install isn't auto-updated, so on startup MulmoTerminal checks npm and
|
|
28
|
+
prints a one-line notice when a newer version is available (`npm i -g mulmoterminal`
|
|
29
|
+
to update). Disable with `MULMOTERMINAL_NO_UPDATE_CHECK=1` (or `NO_UPDATE_NOTIFIER=1`).
|
|
30
|
+
|
|
31
|
+
Options: `--cwd <dir>` (working directory — relative paths allowed; defaults to the
|
|
32
|
+
directory you run the command from), `--port <n>` (default 3456), `--no-open`,
|
|
33
|
+
`--version`, `--help`.
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
npx mulmoterminal --cwd ./my-project # work in a specific directory
|
|
37
|
+
```
|
|
28
38
|
|
|
29
39
|
The published package ships the server (run via `tsx`) plus the pre-built web UI;
|
|
30
40
|
`npx mulmoterminal` checks for the `claude` CLI, picks a free port, starts the
|
|
@@ -118,7 +128,7 @@ the server runs without one.
|
|
|
118
128
|
| ------------ | -------------- | ----------- |
|
|
119
129
|
| `PORT` | `3456` | HTTP/WebSocket port. |
|
|
120
130
|
| `CLAUDE_BIN` | `claude` | The Claude Code binary to spawn. |
|
|
121
|
-
| `CLAUDE_CWD` |
|
|
131
|
+
| `CLAUDE_CWD` | current dir | Working directory each `claude` PTY runs in; determines which project's sessions the sidebar lists. Via `npx mulmoterminal` it defaults to the directory you ran the command from (override with `--cwd <dir>`, relative allowed); when the server is run directly it falls back to `~/mulmoclaude`. A value read from `.env` must be an absolute path (`~` is not expanded). |
|
|
122
132
|
|
|
123
133
|
Example `.env` (gitignored):
|
|
124
134
|
|
package/bin/mulmoterminal.js
CHANGED
|
@@ -6,12 +6,13 @@
|
|
|
6
6
|
// runs the server via tsx. Mirrors the mulmoclaude launcher.
|
|
7
7
|
|
|
8
8
|
import { execSync, spawn } from "node:child_process";
|
|
9
|
-
import { existsSync } from "node:fs";
|
|
9
|
+
import { existsSync, statSync } from "node:fs";
|
|
10
10
|
import { get as httpGet } from "node:http";
|
|
11
11
|
import { createRequire } from "node:module";
|
|
12
12
|
import { createServer } from "node:net";
|
|
13
|
-
import { dirname, join } from "node:path";
|
|
13
|
+
import { dirname, join, resolve } from "node:path";
|
|
14
14
|
import { fileURLToPath } from "node:url";
|
|
15
|
+
import { fetchLatestVersion, isNewerVersion } from "./update-check.js";
|
|
15
16
|
|
|
16
17
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
17
18
|
const PKG_DIR = join(__dirname, "..");
|
|
@@ -30,6 +31,21 @@ const { version: VERSION } = createRequire(import.meta.url)("../package.json");
|
|
|
30
31
|
const log = (msg) => console.log(`\x1b[36m[mulmoterminal]\x1b[0m ${msg}`);
|
|
31
32
|
const error = (msg) => console.error(`\x1b[31m[mulmoterminal]\x1b[0m ${msg}`);
|
|
32
33
|
|
|
34
|
+
// Non-blocking notice when a newer version is published — `npm i -g` never
|
|
35
|
+
// auto-updates. Opt out via MULMOTERMINAL_NO_UPDATE_CHECK / NO_UPDATE_NOTIFIER.
|
|
36
|
+
function checkForUpdate() {
|
|
37
|
+
if (process.env.MULMOTERMINAL_NO_UPDATE_CHECK || process.env.NO_UPDATE_NOTIFIER) return;
|
|
38
|
+
fetchLatestVersion()
|
|
39
|
+
.then((latest) => {
|
|
40
|
+
if (latest && isNewerVersion(latest, VERSION)) {
|
|
41
|
+
log(`\x1b[33mUpdate available: ${VERSION} → ${latest} · run: npm i -g mulmoterminal\x1b[0m`);
|
|
42
|
+
}
|
|
43
|
+
})
|
|
44
|
+
.catch(() => {
|
|
45
|
+
// best-effort; never disrupt startup
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
33
49
|
function claudeInstalled() {
|
|
34
50
|
try {
|
|
35
51
|
// Intentionally resolves `claude` from the user's PATH — detecting their
|
|
@@ -129,6 +145,29 @@ function parsePortArg(args) {
|
|
|
129
145
|
return { requestedPort: parsed, portExplicit: true };
|
|
130
146
|
}
|
|
131
147
|
|
|
148
|
+
// Resolve the workspace directory claude runs in (and whose sessions the sidebar
|
|
149
|
+
// lists). Precedence: --cwd (relative paths allowed) > CLAUDE_CWD env > the
|
|
150
|
+
// directory npx was run from. Always returned absolute. An explicit --cwd that
|
|
151
|
+
// isn't an existing directory is a hard error (catches typos before launch).
|
|
152
|
+
function resolveCwd(args) {
|
|
153
|
+
const idx = args.indexOf("--cwd");
|
|
154
|
+
let flagValue;
|
|
155
|
+
if (idx !== -1) {
|
|
156
|
+
flagValue = args[idx + 1];
|
|
157
|
+
if (flagValue === undefined || flagValue.startsWith("-")) {
|
|
158
|
+
error("--cwd requires a directory path");
|
|
159
|
+
process.exit(1);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
const chosen = flagValue ?? process.env.CLAUDE_CWD ?? ".";
|
|
163
|
+
const abs = resolve(process.cwd(), chosen);
|
|
164
|
+
if (idx !== -1 && (!existsSync(abs) || !statSync(abs).isDirectory())) {
|
|
165
|
+
error(`--cwd is not a directory: ${abs}`);
|
|
166
|
+
process.exit(1);
|
|
167
|
+
}
|
|
168
|
+
return abs;
|
|
169
|
+
}
|
|
170
|
+
|
|
132
171
|
async function choosePort(requested, explicit) {
|
|
133
172
|
if (await isPortFree(requested)) return requested;
|
|
134
173
|
if (explicit) {
|
|
@@ -149,12 +188,12 @@ async function choosePort(requested, explicit) {
|
|
|
149
188
|
// the port was taken at bind time before it became ready — the caller then
|
|
150
189
|
// retries on a fresh port. In every other case (clean shutdown, fatal error,
|
|
151
190
|
// or the server simply running) the process exits with the server's code.
|
|
152
|
-
function runServer(port, noOpen, onChild) {
|
|
153
|
-
return new Promise((
|
|
191
|
+
function runServer(port, noOpen, cwd, onChild) {
|
|
192
|
+
return new Promise((resolveExit) => {
|
|
154
193
|
log(`Starting MulmoTerminal on port ${port}...`);
|
|
155
194
|
const server = spawn(process.execPath, ["--import", "tsx", SERVER_ENTRY], {
|
|
156
195
|
cwd: PKG_DIR,
|
|
157
|
-
env: { ...process.env, NODE_ENV: "production", PORT: String(port) },
|
|
196
|
+
env: { ...process.env, NODE_ENV: "production", PORT: String(port), CLAUDE_CWD: cwd },
|
|
158
197
|
stdio: "inherit",
|
|
159
198
|
});
|
|
160
199
|
onChild(server);
|
|
@@ -178,7 +217,7 @@ function runServer(port, noOpen, onChild) {
|
|
|
178
217
|
// served — always retriable, regardless of what a probe to the port saw
|
|
179
218
|
// (another process could have answered it). Other exits are terminal.
|
|
180
219
|
if (code === PORT_IN_USE_EXIT_CODE) {
|
|
181
|
-
|
|
220
|
+
resolveExit();
|
|
182
221
|
return;
|
|
183
222
|
}
|
|
184
223
|
process.exit(code ?? 1);
|
|
@@ -194,6 +233,7 @@ async function main() {
|
|
|
194
233
|
Usage: npx mulmoterminal [options]
|
|
195
234
|
|
|
196
235
|
Options:
|
|
236
|
+
--cwd <dir> Working directory claude runs in (default: current directory; relative paths allowed)
|
|
197
237
|
--port <number> Server port (default: ${DEFAULT_PORT}; a free port is chosen if it's busy)
|
|
198
238
|
--no-open Don't open the browser automatically
|
|
199
239
|
--version Show version
|
|
@@ -206,6 +246,8 @@ Options:
|
|
|
206
246
|
return;
|
|
207
247
|
}
|
|
208
248
|
|
|
249
|
+
checkForUpdate();
|
|
250
|
+
|
|
209
251
|
if (!claudeInstalled()) {
|
|
210
252
|
error("Claude Code CLI not found.");
|
|
211
253
|
error("Install it first: npm install -g @anthropic-ai/claude-code && claude auth login");
|
|
@@ -220,6 +262,8 @@ Options:
|
|
|
220
262
|
|
|
221
263
|
const { requestedPort, portExplicit } = parsePortArg(args);
|
|
222
264
|
const noOpen = args.includes("--no-open");
|
|
265
|
+
const cwd = resolveCwd(args);
|
|
266
|
+
log(`Workspace: ${cwd}`);
|
|
223
267
|
|
|
224
268
|
// Registered once; always targets the live child across bind-retries.
|
|
225
269
|
let child = null;
|
|
@@ -235,7 +279,7 @@ Options:
|
|
|
235
279
|
// second-guessed. `runServer` only returns when the port was raced.
|
|
236
280
|
let port = await choosePort(requestedPort, portExplicit);
|
|
237
281
|
for (let attempt = 0; attempt <= MAX_BIND_RETRIES; attempt++) {
|
|
238
|
-
await runServer(port, noOpen, (c) => {
|
|
282
|
+
await runServer(port, noOpen, cwd, (c) => {
|
|
239
283
|
child = c;
|
|
240
284
|
});
|
|
241
285
|
if (portExplicit) {
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// Update-check helpers for the launcher, split out so the version comparison is
|
|
2
|
+
// unit-testable. Network calls are best-effort and never throw.
|
|
3
|
+
|
|
4
|
+
const REGISTRY = (process.env.npm_config_registry || "https://registry.npmjs.org").replace(/\/$/, "");
|
|
5
|
+
|
|
6
|
+
// Best-effort latest-version lookup. Resolves null on any failure (offline,
|
|
7
|
+
// timeout, non-OK, bad payload) so callers never block or break startup.
|
|
8
|
+
export async function fetchLatestVersion(pkg = "mulmoterminal") {
|
|
9
|
+
try {
|
|
10
|
+
const res = await fetch(`${REGISTRY}/${pkg}/latest`, {
|
|
11
|
+
signal: AbortSignal.timeout(1500),
|
|
12
|
+
headers: { accept: "application/json" },
|
|
13
|
+
});
|
|
14
|
+
if (!res.ok) return null;
|
|
15
|
+
const body = await res.json();
|
|
16
|
+
return typeof body.version === "string" ? body.version : null;
|
|
17
|
+
} catch {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// True if `latest` is a strictly newer major.minor.patch than `current`.
|
|
23
|
+
// Pre-release suffixes are ignored and parts are compared numerically (so
|
|
24
|
+
// 0.1.10 > 0.1.9, which a lexical compare would get wrong).
|
|
25
|
+
export function isNewerVersion(latest, current) {
|
|
26
|
+
const parts = (v) =>
|
|
27
|
+
String(v)
|
|
28
|
+
.split("-")[0]
|
|
29
|
+
.split(".")
|
|
30
|
+
.map((n) => Number.parseInt(n, 10) || 0);
|
|
31
|
+
const a = parts(latest);
|
|
32
|
+
const b = parts(current);
|
|
33
|
+
for (let i = 0; i < 3; i++) {
|
|
34
|
+
if ((a[i] ?? 0) !== (b[i] ?? 0)) return (a[i] ?? 0) > (b[i] ?? 0);
|
|
35
|
+
}
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { describe, it, expect, vi, afterEach } from "vitest";
|
|
2
|
+
import { isNewerVersion, fetchLatestVersion } from "./update-check.js";
|
|
3
|
+
|
|
4
|
+
describe("isNewerVersion", () => {
|
|
5
|
+
const cases: [string, string, boolean][] = [
|
|
6
|
+
["0.1.3", "0.1.0", true],
|
|
7
|
+
["0.2.0", "0.1.9", true],
|
|
8
|
+
["1.0.0", "0.9.9", true],
|
|
9
|
+
["0.1.10", "0.1.9", true], // numeric, not lexical (the bug a string compare hits)
|
|
10
|
+
["0.1.3", "0.1.3", false],
|
|
11
|
+
["0.1.0", "0.1.3", false],
|
|
12
|
+
["0.9.9", "1.0.0", false],
|
|
13
|
+
["0.1.4-beta.1", "0.1.3", true], // pre-release suffix ignored on the core
|
|
14
|
+
["0.1.3", "0.1.3-beta.1", false], // equal core → not newer
|
|
15
|
+
];
|
|
16
|
+
it.each(cases)("isNewerVersion(%s, %s) === %s", (latest, current, expected) => {
|
|
17
|
+
expect(isNewerVersion(latest, current)).toBe(expected);
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
describe("fetchLatestVersion", () => {
|
|
22
|
+
const stubFetch = (impl: () => Promise<unknown>) => vi.stubGlobal("fetch", vi.fn(impl));
|
|
23
|
+
afterEach(() => vi.unstubAllGlobals());
|
|
24
|
+
|
|
25
|
+
it("returns the version from a 200 response", async () => {
|
|
26
|
+
stubFetch(async () => ({ ok: true, json: async () => ({ version: "1.2.3" }) }));
|
|
27
|
+
expect(await fetchLatestVersion()).toBe("1.2.3");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("returns null on a non-OK response", async () => {
|
|
31
|
+
stubFetch(async () => ({ ok: false, json: async () => ({}) }));
|
|
32
|
+
expect(await fetchLatestVersion()).toBeNull();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("returns null when fetch rejects (offline / timeout)", async () => {
|
|
36
|
+
stubFetch(async () => {
|
|
37
|
+
throw new Error("offline");
|
|
38
|
+
});
|
|
39
|
+
expect(await fetchLatestVersion()).toBeNull();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("returns null when the payload has no version string", async () => {
|
|
43
|
+
stubFetch(async () => ({ ok: true, json: async () => ({ name: "mulmoterminal" }) }));
|
|
44
|
+
expect(await fetchLatestVersion()).toBeNull();
|
|
45
|
+
});
|
|
46
|
+
});
|