@nemus-cli/nemus 0.14.0 → 0.15.1

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/CHANGELOG.md CHANGED
@@ -7,6 +7,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.15.1] - 2026-09-04
11
+
12
+ ### Fixed
13
+
14
+ - **`npx @nemus-cli/nemus …` no longer hangs.** The postinstall first-run setup
15
+ (interactive `nemus configure`, shell-integration install) now runs **only on
16
+ a global install** (`npm i -g`). A transient `npx` run or a local dependency
17
+ install is not global — the `nemus`/`nem` bins aren't persisted on PATH, so
18
+ shell integration is pointless, and the interactive `configure` reached the
19
+ controlling terminal via `/dev/tty` and hung a non-interactive
20
+ `npx … --help`/`--version`. Global installs are unchanged.
21
+
22
+ ### Added
23
+
24
+ - **Zero-install usage** documented: `npx @nemus-cli/nemus <command>` runs any
25
+ command without installing or touching your shell.
26
+
27
+ ## [0.15.0] - 2026-09-02
28
+
29
+ ### Added
30
+
31
+ - **XDG base-directory support for config/state (Linux).** A fresh install now
32
+ stores its config/state under `$XDG_CONFIG_HOME/nemus` (default
33
+ `~/.config/nemus`) on Linux, making Nemus a better Linux citizen. Precedence
34
+ (first match wins): `NEMUS_CACHE_DIR`/`WORKSPACE_MANAGER_CACHE_DIR` override →
35
+ an existing `~/.nemus` with state (kept on every platform, never moved) →
36
+ an absolute `XDG_CONFIG_HOME` → Linux default `~/.config/nemus` → `~/.nemus`
37
+ (macOS/Windows). Existing `~/.nemus` installs are untouched. (#77)
38
+
10
39
  ## [0.14.0] - 2026-09-02
11
40
 
12
41
  ### Changed
package/README.md CHANGED
@@ -140,7 +140,20 @@ npm install -g @nemus-cli/nemus
140
140
  The postinstall step sets up optional shell integration (auto-cd into new
141
141
  workspaces + a quick-navigate helper). It keeps the generated functions in
142
142
  `~/.nemus/shell-integration.sh` and adds only a guarded source line to your
143
- shell RC file, so upgrades don't churn your `.zshrc`/`.bashrc`.
143
+ shell RC file, so upgrades don't churn your `.zshrc`/`.bashrc`. First-time setup
144
+ (`nemus configure`) runs automatically **only on a global install** — a
145
+ throwaway `npx` run or a local dependency install leaves your shell untouched.
146
+
147
+ ### Try it without installing
148
+
149
+ ```bash
150
+ npx @nemus-cli/nemus list # run any command via npx
151
+ npx @nemus-cli/nemus -- "…" # or the AI prompt
152
+ ```
153
+
154
+ Zero install, nothing added to your PATH or shell RC. (Shell integration and
155
+ auto-cd only make sense once the `nemus`/`nem` bins are on your PATH, so install
156
+ globally when you want those.)
144
157
 
145
158
  ### From source
146
159
 
@@ -284,7 +297,8 @@ Everything Nemus reads from the environment (all optional):
284
297
  | Variable | Effect |
285
298
  | --- | --- |
286
299
  | `NEMUS_DIR` | Override where workspaces are created (also `WORKSPACE_MANAGER_DIR`). |
287
- | `NEMUS_CACHE_DIR` | Override the cache/config/state dir, default `~/.nemus` (also `WORKSPACE_MANAGER_CACHE_DIR`). |
300
+ | `NEMUS_CACHE_DIR` | Override the config/state dir. Default: `~/.nemus`, or `$XDG_CONFIG_HOME/nemus` ( `~/.config/nemus`) on Linux. Also `WORKSPACE_MANAGER_CACHE_DIR`. |
301
+ | `XDG_CONFIG_HOME` | On Linux (or when set explicitly), a fresh install stores config/state under `$XDG_CONFIG_HOME/nemus`. An existing `~/.nemus` is always kept as-is. |
288
302
  | `NEMUS_JUDGE_MODEL` | Model for the `reflect` judge (overrides `--model`'s default). |
289
303
  | `NEMUS_JUDGE_THINKING` | Thinking level for the `reflect` judge on pi (`off`…`max`). |
290
304
  | `NEMUS_JUDGE_TIMEOUT_MS` | Timeout for the `reflect` judge call. |
@@ -426,7 +440,19 @@ export NEMUS_CACHE_DIR="$HOME/.cache/nemus" # default: ~/.nemus
426
440
 
427
441
  (The legacy `WORKSPACE_MANAGER_DIR` / `WORKSPACE_MANAGER_CACHE_DIR` names still
428
442
  work as fallbacks. On first run, state from the old `~/.workspace-manager-cache`
429
- location is migrated to `~/.nemus` automatically.)
443
+ location is migrated to the resolved config dir automatically.)
444
+
445
+ **Config/state location (XDG-aware).** Resolution order, first match wins —
446
+ designed so existing installs are never silently moved:
447
+
448
+ 1. `NEMUS_CACHE_DIR` / `WORKSPACE_MANAGER_CACHE_DIR` (explicit override).
449
+ 2. An existing `~/.nemus` that holds durable state (anything beyond regenerable
450
+ caches like `repos-cache.json` / `last-version-check.json` and the
451
+ `shell-integration.sh` file — e.g. `config.json`, `suites.json`, the
452
+ `reflect/` reports) — kept on every platform, never moved.
453
+ 3. `XDG_CONFIG_HOME/nemus`, when `XDG_CONFIG_HOME` is set to an absolute path.
454
+ 4. Linux with no prior install → `~/.config/nemus` (the XDG default).
455
+ 5. Otherwise (macOS/Windows, fresh install) → `~/.nemus`.
430
456
 
431
457
  Key settings: `githubOrg` (which org's repos to list — leave empty to list your
432
458
  own), `cloneProtocol` (`ssh`|`https`), `aiAgent`, `primaryAgent`, `installMcp`.
@@ -34,6 +34,7 @@ var __importStar = (this && this.__importStar) || (function () {
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.config = exports.CLONE_MAX_BUFFER = exports.CLONE_TIMEOUT_MS = exports.CONFIG_PATH = exports.META_FILENAME = exports.SUITES_FILE = exports.HISTORY_FILE = exports.CACHE_DIR = exports.WORKSPACES_DIR = exports.CONFIG_DEFAULTS = void 0;
37
+ exports.resolveNemusHome = resolveNemusHome;
37
38
  exports.getUserConfig = getUserConfig;
38
39
  exports.getPackageVersion = getPackageVersion;
39
40
  exports.getCloneUrl = getCloneUrl;
@@ -42,11 +43,62 @@ const os = __importStar(require("os"));
42
43
  const path = __importStar(require("path"));
43
44
  const fs = __importStar(require("fs"));
44
45
  const HOME_DIR = os.homedir();
45
- // Cache/config/state directory. Prefer the branded NEMUS_* env vars; fall back
46
- // to the legacy WORKSPACE_MANAGER_* names for backward compatibility; else the
47
- // default ~/.nemus home.
48
- const DEFAULT_CACHE_DIR = path.join(HOME_DIR, '.nemus');
49
46
  const LEGACY_CACHE_DIR = path.join(HOME_DIR, '.workspace-manager-cache');
47
+ // Entries in ~/.nemus that do NOT make it a "real install": regenerable caches
48
+ // and the shell-integration artifact. Anything else (config.json, suites.json,
49
+ // history.jsonl, the reflect/ report dir, or any future state) marks the dir as
50
+ // an existing install to keep in place. Denylisting the throwaways — a small,
51
+ // stable set — is exhaustive for durable state by construction, which is safer
52
+ // than allowlisting state files (that could miss e.g. reflect/ and wrongly
53
+ // relocate a real install to XDG on upgrade).
54
+ const REGENERABLE_ENTRIES = new Set([
55
+ 'last-version-check.json', // update-check cache
56
+ 'repos-cache.json', // GitHub repo-list cache
57
+ 'last-error.json', // last error, for report-bug
58
+ 'shell-integration.sh', // shell installer artifact — not Nemus state
59
+ '.DS_Store',
60
+ ]);
61
+ /** True if ~/.nemus holds anything beyond regenerable caches / the shell artifact. */
62
+ function hasDurableState(dir, readDir) {
63
+ let entries;
64
+ try {
65
+ entries = readDir(dir);
66
+ }
67
+ catch {
68
+ return false; // dir doesn't exist
69
+ }
70
+ return entries.some((e) => !REGENERABLE_ENTRIES.has(e));
71
+ }
72
+ /**
73
+ * Resolve the directory that holds Nemus config + state. Precedence (first
74
+ * match wins), designed so existing users are never silently moved:
75
+ *
76
+ * 1. Explicit override — NEMUS_CACHE_DIR (or legacy WORKSPACE_MANAGER_CACHE_DIR).
77
+ * 2. An existing ~/.nemus that holds durable state (anything beyond
78
+ * regenerable caches / the shell-integration file) — kept on every platform.
79
+ * 3. XDG_CONFIG_HOME, when set to an absolute path — $XDG_CONFIG_HOME/nemus.
80
+ * 4. Linux with no prior install — the XDG default ~/.config/nemus.
81
+ * 5. Otherwise (macOS/Windows, fresh install) — ~/.nemus.
82
+ *
83
+ * Config + state are kept together in one dir (a later change may split cache
84
+ * out under XDG_CACHE_HOME). The dir is chosen under XDG_CONFIG_HOME, not
85
+ * XDG_CACHE_HOME, because it holds real config (config.json) — not disposable
86
+ * cache a cleaner may wipe. A relative XDG_CONFIG_HOME is ignored per the spec.
87
+ */
88
+ function resolveNemusHome({ env, home, platform, readDir }) {
89
+ const explicit = env.NEMUS_CACHE_DIR || env.WORKSPACE_MANAGER_CACHE_DIR;
90
+ if (explicit)
91
+ return explicit;
92
+ const branded = path.join(home, '.nemus');
93
+ if (hasDurableState(branded, readDir))
94
+ return branded;
95
+ const xdg = env.XDG_CONFIG_HOME;
96
+ if (xdg && path.isAbsolute(xdg))
97
+ return path.join(xdg, 'nemus');
98
+ if (platform === 'linux')
99
+ return path.join(home, '.config', 'nemus');
100
+ return branded;
101
+ }
50
102
  /**
51
103
  * One-time, best-effort migration of state from the pre-0.2.2 cache location
52
104
  * (~/.workspace-manager-cache) to ~/.nemus. Runs only when using the default
@@ -58,11 +110,15 @@ const LEGACY_CACHE_DIR = path.join(HOME_DIR, '.workspace-manager-cache');
58
110
  * shared by another tool whose "latest version" is unrelated to nemus, and
59
111
  * copying it would make the update check report a wrong version until the entry
60
112
  * expires. Skipping it just forces one fresh lookup.
113
+ *
114
+ * Skipped when an explicit env override is in effect (don't copy into a path the
115
+ * user deliberately chose). Runs into whatever default location was resolved,
116
+ * including a new XDG dir on Linux.
61
117
  */
62
118
  const MIGRATION_SKIP = new Set(['last-version-check.json']);
63
- function migrateLegacyCacheDir(targetDir) {
119
+ function migrateLegacyCacheDir(targetDir, isExplicitOverride) {
64
120
  try {
65
- if (targetDir === DEFAULT_CACHE_DIR && !fs.existsSync(targetDir) && fs.existsSync(LEGACY_CACHE_DIR)) {
121
+ if (!isExplicitOverride && !fs.existsSync(targetDir) && fs.existsSync(LEGACY_CACHE_DIR)) {
66
122
  fs.cpSync(LEGACY_CACHE_DIR, targetDir, {
67
123
  recursive: true,
68
124
  filter: (src) => !MIGRATION_SKIP.has(path.basename(src)),
@@ -73,8 +129,14 @@ function migrateLegacyCacheDir(targetDir) {
73
129
  // best-effort — ignore and let the dir be created lazily
74
130
  }
75
131
  }
76
- const CACHE_DIR_RESOLVED = process.env.NEMUS_CACHE_DIR || process.env.WORKSPACE_MANAGER_CACHE_DIR || DEFAULT_CACHE_DIR;
77
- migrateLegacyCacheDir(CACHE_DIR_RESOLVED);
132
+ const EXPLICIT_OVERRIDE = !!(process.env.NEMUS_CACHE_DIR || process.env.WORKSPACE_MANAGER_CACHE_DIR);
133
+ const CACHE_DIR_RESOLVED = resolveNemusHome({
134
+ env: process.env,
135
+ home: HOME_DIR,
136
+ platform: process.platform,
137
+ readDir: (p) => fs.readdirSync(p),
138
+ });
139
+ migrateLegacyCacheDir(CACHE_DIR_RESOLVED, EXPLICIT_OVERRIDE);
78
140
  // Config file lives inside the cache dir.
79
141
  const CONFIG_FILE = path.join(CACHE_DIR_RESOLVED, 'config.json');
80
142
  exports.CONFIG_DEFAULTS = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nemus-cli/nemus",
3
- "version": "0.14.0",
3
+ "version": "0.15.1",
4
4
  "workspaces": [
5
5
  "packages/*"
6
6
  ],
@@ -20,6 +20,16 @@ const path = require('path');
20
20
  // Skip in CI environments
21
21
  if (process.env.CI) process.exit(0);
22
22
 
23
+ // Only run first-run setup for a real GLOBAL install (`npm i -g`). A transient
24
+ // `npx @nemus-cli/nemus …` (npm exec) or a local dependency install is not
25
+ // global: the `nemus`/`nem` bins aren't persisted on PATH, so shell integration
26
+ // is pointless — and worse, the interactive `configure` below reaches the
27
+ // controlling terminal via /dev/tty and would HANG a non-interactive
28
+ // `npx … --help`/`--version` invocation waiting for input. npm sets
29
+ // npm_config_global="true" only for `-g`/`--global`; npx and local installs
30
+ // leave it false/unset.
31
+ if (String(process.env.npm_config_global).toLowerCase() !== 'true') process.exit(0);
32
+
23
33
  const PKG_ROOT = path.join(__dirname, '..');
24
34
  const SHELL_SCRIPT = path.join(PKG_ROOT, 'install-shell-integration.sh');
25
35
  const CLI_BIN = path.join(PKG_ROOT, 'bin', 'workspace.js');
@@ -0,0 +1,52 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { execFileSync } from 'node:child_process';
3
+ import { mkdtempSync, writeFileSync, readFileSync, rmSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+
7
+ const SCRIPT = join(__dirname, '..', 'scripts', 'postinstall.js');
8
+
9
+ describe('postinstall.js', () => {
10
+ // Regression: a transient `npx @nemus-cli/nemus …` (npm exec) or a local
11
+ // dependency install is NOT global. Such installs must not launch the
12
+ // interactive `configure` (which reaches /dev/tty and would HANG a
13
+ // non-interactive `npx … --help`) nor mutate the user's shell RC — the bins
14
+ // aren't persisted on PATH for them anyway.
15
+ it('is a fast no-op for a non-global install: exits 0 and never touches the shell RC', () => {
16
+ const home = mkdtempSync(join(tmpdir(), 'nemus-postinstall-'));
17
+ const rc = join(home, '.zshrc');
18
+ writeFileSync(rc, '# user rc\n');
19
+ const env = { ...process.env, HOME: home, SHELL: '/bin/zsh', NEMUS_CACHE_DIR: join(home, '.nemus') };
20
+ // Delete CI so the global gate — not the CI early-exit — is what protects npx.
21
+ delete env.CI;
22
+ delete env.npm_config_global; // npx / local install leaves this unset
23
+ try {
24
+ // Throws on non-zero exit; throws on the 15s timeout if it ever hangs.
25
+ execFileSync(process.execPath, [SCRIPT], { env, stdio: 'ignore', timeout: 15_000 });
26
+ // RC untouched — no guarded `source` line appended.
27
+ expect(readFileSync(rc, 'utf8')).toBe('# user rc\n');
28
+ } finally {
29
+ rmSync(home, { recursive: true, force: true });
30
+ }
31
+ });
32
+
33
+ it('treats npm_config_global="false" the same as unset (still a no-op)', () => {
34
+ const home = mkdtempSync(join(tmpdir(), 'nemus-postinstall-'));
35
+ const rc = join(home, '.bashrc');
36
+ writeFileSync(rc, '# user rc\n');
37
+ const env = {
38
+ ...process.env,
39
+ HOME: home,
40
+ SHELL: '/bin/bash',
41
+ NEMUS_CACHE_DIR: join(home, '.nemus'),
42
+ npm_config_global: 'false',
43
+ };
44
+ delete env.CI;
45
+ try {
46
+ execFileSync(process.execPath, [SCRIPT], { env, stdio: 'ignore', timeout: 15_000 });
47
+ expect(readFileSync(rc, 'utf8')).toBe('# user rc\n');
48
+ } finally {
49
+ rmSync(home, { recursive: true, force: true });
50
+ }
51
+ });
52
+ });
@@ -202,12 +202,21 @@ describe('config', () => {
202
202
  });
203
203
 
204
204
  describe('cache dir location + legacy migration', () => {
205
- it('defaults CACHE_DIR to ~/.nemus when no env override is set', async () => {
205
+ // With XDG support a fresh install resolves to ~/.config/nemus on Linux and
206
+ // ~/.nemus elsewhere. Delete XDG_CONFIG_HOME so resolution is homedir-based
207
+ // (and stays inside the tempDir sandbox on any CI host).
208
+ const defaultHome = () =>
209
+ process.platform === 'linux'
210
+ ? path.join(tempDir, '.config', 'nemus')
211
+ : path.join(tempDir, '.nemus');
212
+
213
+ it('defaults CACHE_DIR to the platform default when no env override is set', async () => {
206
214
  delete process.env.NEMUS_CACHE_DIR;
207
215
  delete process.env.WORKSPACE_MANAGER_CACHE_DIR;
216
+ delete process.env.XDG_CONFIG_HOME;
208
217
  vi.resetModules();
209
218
  const { CACHE_DIR } = await loadModule();
210
- expect(CACHE_DIR).toBe(path.join(tempDir, '.nemus'));
219
+ expect(CACHE_DIR).toBe(defaultHome());
211
220
  });
212
221
 
213
222
  it('prefers NEMUS_CACHE_DIR, then legacy WORKSPACE_MANAGER_CACHE_DIR', async () => {
@@ -222,41 +231,122 @@ describe('config', () => {
222
231
  expect((await loadModule()).CACHE_DIR).toBe(path.join(tempDir, '.workspace-manager-cache'));
223
232
  });
224
233
 
225
- it('migrates state from ~/.workspace-manager-cache to ~/.nemus on first run', async () => {
234
+ it('migrates state from ~/.workspace-manager-cache to the default dir on first run', async () => {
226
235
  delete process.env.NEMUS_CACHE_DIR;
227
236
  delete process.env.WORKSPACE_MANAGER_CACHE_DIR;
237
+ delete process.env.XDG_CONFIG_HOME;
228
238
  // legacy dir (created by beforeEach) holds prior state; new dir absent
229
239
  fs.writeFileSync(path.join(tempDir, '.workspace-manager-cache', 'suites.json'), '{"x":1}');
230
- expect(fs.existsSync(path.join(tempDir, '.nemus'))).toBe(false);
240
+ expect(fs.existsSync(defaultHome())).toBe(false);
231
241
  vi.resetModules();
232
242
  const { CACHE_DIR } = await loadModule();
233
- expect(CACHE_DIR).toBe(path.join(tempDir, '.nemus'));
243
+ expect(CACHE_DIR).toBe(defaultHome());
234
244
  // copied, not moved: file exists in the new dir AND the legacy dir survives
235
- expect(fs.readFileSync(path.join(tempDir, '.nemus', 'suites.json'), 'utf-8')).toBe('{"x":1}');
245
+ expect(fs.readFileSync(path.join(defaultHome(), 'suites.json'), 'utf-8')).toBe('{"x":1}');
236
246
  expect(fs.existsSync(path.join(tempDir, '.workspace-manager-cache', 'suites.json'))).toBe(true);
237
247
  });
238
248
 
239
249
  it('does not migrate last-version-check.json (avoids inheriting a foreign latest)', async () => {
240
250
  delete process.env.NEMUS_CACHE_DIR;
241
251
  delete process.env.WORKSPACE_MANAGER_CACHE_DIR;
252
+ delete process.env.XDG_CONFIG_HOME;
242
253
  const legacy = path.join(tempDir, '.workspace-manager-cache');
243
254
  fs.writeFileSync(path.join(legacy, 'suites.json'), '{"x":1}');
244
255
  fs.writeFileSync(path.join(legacy, 'last-version-check.json'), '{"latestVersion":"99.0.0"}');
245
256
  vi.resetModules();
246
257
  await loadModule();
247
258
  // other state migrates, but the version-check cache is left behind
248
- expect(fs.existsSync(path.join(tempDir, '.nemus', 'suites.json'))).toBe(true);
249
- expect(fs.existsSync(path.join(tempDir, '.nemus', 'last-version-check.json'))).toBe(false);
259
+ expect(fs.existsSync(path.join(defaultHome(), 'suites.json'))).toBe(true);
260
+ expect(fs.existsSync(path.join(defaultHome(), 'last-version-check.json'))).toBe(false);
250
261
  });
251
262
 
252
263
  it('does not migrate when the new dir already exists', async () => {
253
264
  delete process.env.NEMUS_CACHE_DIR;
254
265
  delete process.env.WORKSPACE_MANAGER_CACHE_DIR;
255
- fs.mkdirSync(path.join(tempDir, '.nemus'), { recursive: true });
266
+ delete process.env.XDG_CONFIG_HOME;
267
+ fs.mkdirSync(defaultHome(), { recursive: true });
256
268
  fs.writeFileSync(path.join(tempDir, '.workspace-manager-cache', 'suites.json'), '{"x":1}');
257
269
  vi.resetModules();
258
270
  await loadModule();
259
- expect(fs.existsSync(path.join(tempDir, '.nemus', 'suites.json'))).toBe(false);
271
+ expect(fs.existsSync(path.join(defaultHome(), 'suites.json'))).toBe(false);
272
+ });
273
+
274
+ it('keeps an existing ~/.nemus install even on Linux (no surprise XDG move)', async () => {
275
+ delete process.env.NEMUS_CACHE_DIR;
276
+ delete process.env.WORKSPACE_MANAGER_CACHE_DIR;
277
+ delete process.env.XDG_CONFIG_HOME;
278
+ const branded = path.join(tempDir, '.nemus');
279
+ fs.mkdirSync(branded, { recursive: true });
280
+ fs.writeFileSync(path.join(branded, 'config.json'), '{}'); // real prior state
281
+ vi.resetModules();
282
+ const { CACHE_DIR } = await loadModule();
283
+ expect(CACHE_DIR).toBe(branded);
284
+ });
285
+ });
286
+
287
+ describe('resolveNemusHome precedence', () => {
288
+ const home = '/home/u';
289
+ const branded = path.join(home, '.nemus');
290
+ // A readDir that reports the given entries for ~/.nemus and "absent" elsewhere.
291
+ const brandedHas = (...entries: string[]) => (p: string): string[] => {
292
+ if (p === branded) return entries;
293
+ throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
294
+ };
295
+ const empty = (): string[] => {
296
+ throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
297
+ };
298
+
299
+ it('explicit NEMUS_CACHE_DIR / legacy override wins over everything', async () => {
300
+ const { resolveNemusHome } = await loadModule();
301
+ expect(
302
+ resolveNemusHome({ env: { NEMUS_CACHE_DIR: '/x', XDG_CONFIG_HOME: '/c' }, home, platform: 'linux', readDir: brandedHas('config.json') }),
303
+ ).toBe('/x');
304
+ expect(
305
+ resolveNemusHome({ env: { WORKSPACE_MANAGER_CACHE_DIR: '/y' }, home, platform: 'linux', readDir: brandedHas('config.json') }),
306
+ ).toBe('/y');
307
+ });
308
+
309
+ it('an existing ~/.nemus with durable state wins over XDG, on any platform', async () => {
310
+ const { resolveNemusHome } = await loadModule();
311
+ expect(resolveNemusHome({ env: { XDG_CONFIG_HOME: '/c' }, home, platform: 'linux', readDir: brandedHas('config.json') })).toBe(branded);
312
+ });
313
+
314
+ it('counts reflect/ (and any non-cache entry) as durable state — not just the classic 3', async () => {
315
+ const { resolveNemusHome } = await loadModule();
316
+ // a reflect-only user has no config.json/suites.json/history.jsonl
317
+ expect(resolveNemusHome({ env: {}, home, platform: 'linux', readDir: brandedHas('reflect') })).toBe(branded);
318
+ });
319
+
320
+ it('a ~/.nemus holding only regenerable caches / the shell artifact does NOT count', async () => {
321
+ const { resolveNemusHome } = await loadModule();
322
+ expect(
323
+ resolveNemusHome({
324
+ env: {},
325
+ home,
326
+ platform: 'linux',
327
+ readDir: brandedHas('shell-integration.sh', 'last-version-check.json', 'repos-cache.json', 'last-error.json', '.DS_Store'),
328
+ }),
329
+ ).toBe(path.join(home, '.config', 'nemus'));
330
+ });
331
+
332
+ it('honors an absolute XDG_CONFIG_HOME on a fresh install (any platform)', async () => {
333
+ const { resolveNemusHome } = await loadModule();
334
+ expect(resolveNemusHome({ env: { XDG_CONFIG_HOME: '/cfg' }, home, platform: 'linux', readDir: empty })).toBe('/cfg/nemus');
335
+ expect(resolveNemusHome({ env: { XDG_CONFIG_HOME: '/cfg' }, home, platform: 'darwin', readDir: empty })).toBe('/cfg/nemus');
336
+ });
337
+
338
+ it('ignores a relative XDG_CONFIG_HOME (per spec)', async () => {
339
+ const { resolveNemusHome } = await loadModule();
340
+ expect(resolveNemusHome({ env: { XDG_CONFIG_HOME: 'rel/path' }, home, platform: 'linux', readDir: empty })).toBe(
341
+ path.join(home, '.config', 'nemus'),
342
+ );
343
+ });
344
+
345
+ it('fresh-install default: Linux -> ~/.config/nemus, macOS/Windows -> ~/.nemus', async () => {
346
+ const { resolveNemusHome } = await loadModule();
347
+ expect(resolveNemusHome({ env: {}, home, platform: 'linux', readDir: empty })).toBe(path.join(home, '.config', 'nemus'));
348
+ expect(resolveNemusHome({ env: {}, home, platform: 'darwin', readDir: empty })).toBe(branded);
349
+ expect(resolveNemusHome({ env: {}, home, platform: 'win32', readDir: empty })).toBe(branded);
260
350
  });
261
351
  });
262
352
  });
@@ -4,12 +4,73 @@ import * as fs from 'fs';
4
4
 
5
5
  const HOME_DIR = os.homedir();
6
6
 
7
- // Cache/config/state directory. Prefer the branded NEMUS_* env vars; fall back
8
- // to the legacy WORKSPACE_MANAGER_* names for backward compatibility; else the
9
- // default ~/.nemus home.
10
- const DEFAULT_CACHE_DIR = path.join(HOME_DIR, '.nemus');
11
7
  const LEGACY_CACHE_DIR = path.join(HOME_DIR, '.workspace-manager-cache');
12
8
 
9
+ // Entries in ~/.nemus that do NOT make it a "real install": regenerable caches
10
+ // and the shell-integration artifact. Anything else (config.json, suites.json,
11
+ // history.jsonl, the reflect/ report dir, or any future state) marks the dir as
12
+ // an existing install to keep in place. Denylisting the throwaways — a small,
13
+ // stable set — is exhaustive for durable state by construction, which is safer
14
+ // than allowlisting state files (that could miss e.g. reflect/ and wrongly
15
+ // relocate a real install to XDG on upgrade).
16
+ const REGENERABLE_ENTRIES = new Set([
17
+ 'last-version-check.json', // update-check cache
18
+ 'repos-cache.json', // GitHub repo-list cache
19
+ 'last-error.json', // last error, for report-bug
20
+ 'shell-integration.sh', // shell installer artifact — not Nemus state
21
+ '.DS_Store',
22
+ ]);
23
+
24
+ export interface NemusHomeEnv {
25
+ env: NodeJS.ProcessEnv;
26
+ home: string;
27
+ platform: NodeJS.Platform;
28
+ /** List a directory's entries; throw (or return []) when it doesn't exist. */
29
+ readDir: (p: string) => string[];
30
+ }
31
+
32
+ /** True if ~/.nemus holds anything beyond regenerable caches / the shell artifact. */
33
+ function hasDurableState(dir: string, readDir: (p: string) => string[]): boolean {
34
+ let entries: string[];
35
+ try {
36
+ entries = readDir(dir);
37
+ } catch {
38
+ return false; // dir doesn't exist
39
+ }
40
+ return entries.some((e) => !REGENERABLE_ENTRIES.has(e));
41
+ }
42
+
43
+ /**
44
+ * Resolve the directory that holds Nemus config + state. Precedence (first
45
+ * match wins), designed so existing users are never silently moved:
46
+ *
47
+ * 1. Explicit override — NEMUS_CACHE_DIR (or legacy WORKSPACE_MANAGER_CACHE_DIR).
48
+ * 2. An existing ~/.nemus that holds durable state (anything beyond
49
+ * regenerable caches / the shell-integration file) — kept on every platform.
50
+ * 3. XDG_CONFIG_HOME, when set to an absolute path — $XDG_CONFIG_HOME/nemus.
51
+ * 4. Linux with no prior install — the XDG default ~/.config/nemus.
52
+ * 5. Otherwise (macOS/Windows, fresh install) — ~/.nemus.
53
+ *
54
+ * Config + state are kept together in one dir (a later change may split cache
55
+ * out under XDG_CACHE_HOME). The dir is chosen under XDG_CONFIG_HOME, not
56
+ * XDG_CACHE_HOME, because it holds real config (config.json) — not disposable
57
+ * cache a cleaner may wipe. A relative XDG_CONFIG_HOME is ignored per the spec.
58
+ */
59
+ export function resolveNemusHome({ env, home, platform, readDir }: NemusHomeEnv): string {
60
+ const explicit = env.NEMUS_CACHE_DIR || env.WORKSPACE_MANAGER_CACHE_DIR;
61
+ if (explicit) return explicit;
62
+
63
+ const branded = path.join(home, '.nemus');
64
+ if (hasDurableState(branded, readDir)) return branded;
65
+
66
+ const xdg = env.XDG_CONFIG_HOME;
67
+ if (xdg && path.isAbsolute(xdg)) return path.join(xdg, 'nemus');
68
+
69
+ if (platform === 'linux') return path.join(home, '.config', 'nemus');
70
+
71
+ return branded;
72
+ }
73
+
13
74
  /**
14
75
  * One-time, best-effort migration of state from the pre-0.2.2 cache location
15
76
  * (~/.workspace-manager-cache) to ~/.nemus. Runs only when using the default
@@ -21,11 +82,15 @@ const LEGACY_CACHE_DIR = path.join(HOME_DIR, '.workspace-manager-cache');
21
82
  * shared by another tool whose "latest version" is unrelated to nemus, and
22
83
  * copying it would make the update check report a wrong version until the entry
23
84
  * expires. Skipping it just forces one fresh lookup.
85
+ *
86
+ * Skipped when an explicit env override is in effect (don't copy into a path the
87
+ * user deliberately chose). Runs into whatever default location was resolved,
88
+ * including a new XDG dir on Linux.
24
89
  */
25
90
  const MIGRATION_SKIP = new Set(['last-version-check.json']);
26
- function migrateLegacyCacheDir(targetDir: string): void {
91
+ function migrateLegacyCacheDir(targetDir: string, isExplicitOverride: boolean): void {
27
92
  try {
28
- if (targetDir === DEFAULT_CACHE_DIR && !fs.existsSync(targetDir) && fs.existsSync(LEGACY_CACHE_DIR)) {
93
+ if (!isExplicitOverride && !fs.existsSync(targetDir) && fs.existsSync(LEGACY_CACHE_DIR)) {
29
94
  fs.cpSync(LEGACY_CACHE_DIR, targetDir, {
30
95
  recursive: true,
31
96
  filter: (src) => !MIGRATION_SKIP.has(path.basename(src)),
@@ -36,9 +101,14 @@ function migrateLegacyCacheDir(targetDir: string): void {
36
101
  }
37
102
  }
38
103
 
39
- const CACHE_DIR_RESOLVED =
40
- process.env.NEMUS_CACHE_DIR || process.env.WORKSPACE_MANAGER_CACHE_DIR || DEFAULT_CACHE_DIR;
41
- migrateLegacyCacheDir(CACHE_DIR_RESOLVED);
104
+ const EXPLICIT_OVERRIDE = !!(process.env.NEMUS_CACHE_DIR || process.env.WORKSPACE_MANAGER_CACHE_DIR);
105
+ const CACHE_DIR_RESOLVED = resolveNemusHome({
106
+ env: process.env,
107
+ home: HOME_DIR,
108
+ platform: process.platform,
109
+ readDir: (p) => fs.readdirSync(p),
110
+ });
111
+ migrateLegacyCacheDir(CACHE_DIR_RESOLVED, EXPLICIT_OVERRIDE);
42
112
 
43
113
  // Config file lives inside the cache dir.
44
114
  const CONFIG_FILE = path.join(CACHE_DIR_RESOLVED, 'config.json');