@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 +29 -0
- package/README.md +29 -3
- package/dist/utils/config.js +70 -8
- package/package.json +1 -1
- package/scripts/postinstall.js +10 -0
- package/src/postinstall.test.ts +52 -0
- package/src/utils/config.test.ts +100 -10
- package/src/utils/config.ts +79 -9
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
|
|
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
|
|
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`.
|
package/dist/utils/config.js
CHANGED
|
@@ -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 (
|
|
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
|
|
77
|
-
|
|
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
package/scripts/postinstall.js
CHANGED
|
@@ -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
|
+
});
|
package/src/utils/config.test.ts
CHANGED
|
@@ -202,12 +202,21 @@ describe('config', () => {
|
|
|
202
202
|
});
|
|
203
203
|
|
|
204
204
|
describe('cache dir location + legacy migration', () => {
|
|
205
|
-
|
|
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(
|
|
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
|
|
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(
|
|
240
|
+
expect(fs.existsSync(defaultHome())).toBe(false);
|
|
231
241
|
vi.resetModules();
|
|
232
242
|
const { CACHE_DIR } = await loadModule();
|
|
233
|
-
expect(CACHE_DIR).toBe(
|
|
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(
|
|
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(
|
|
249
|
-
expect(fs.existsSync(path.join(
|
|
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
|
-
|
|
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(
|
|
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
|
});
|
package/src/utils/config.ts
CHANGED
|
@@ -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 (
|
|
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
|
|
40
|
-
|
|
41
|
-
|
|
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');
|