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 CHANGED
@@ -24,7 +24,17 @@ npm install -g mulmoterminal
24
24
  mulmoterminal
25
25
  ```
26
26
 
27
- Options: `--port <n>` (default 3456), `--no-open`, `--version`, `--help`.
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` | `$HOME` | Working directory each `claude` PTY runs in. Determines which project's sessions the sidebar lists. **Must be an absolute path** `~` is not expanded for values read from `.env`. |
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
 
@@ -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((resolve) => {
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
- resolve();
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
+ });