@nemus-cli/nemus 0.13.0 → 0.15.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/CHANGELOG.md +24 -0
- package/README.md +18 -3
- package/dist/utils/config.js +70 -8
- package/install-shell-integration.sh +21 -9
- package/package.json +1 -1
- package/remove-shell-block.awk +8 -0
- package/src/utils/config.test.ts +100 -10
- package/src/utils/config.ts +79 -9
- package/uninstall.sh +16 -5
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.15.0] - 2026-09-02
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **XDG base-directory support for config/state (Linux).** A fresh install now
|
|
15
|
+
stores its config/state under `$XDG_CONFIG_HOME/nemus` (default
|
|
16
|
+
`~/.config/nemus`) on Linux, making Nemus a better Linux citizen. Precedence
|
|
17
|
+
(first match wins): `NEMUS_CACHE_DIR`/`WORKSPACE_MANAGER_CACHE_DIR` override →
|
|
18
|
+
an existing `~/.nemus` with state (kept on every platform, never moved) →
|
|
19
|
+
an absolute `XDG_CONFIG_HOME` → Linux default `~/.config/nemus` → `~/.nemus`
|
|
20
|
+
(macOS/Windows). Existing `~/.nemus` installs are untouched. (#77)
|
|
21
|
+
|
|
22
|
+
## [0.14.0] - 2026-09-02
|
|
23
|
+
|
|
24
|
+
### Changed
|
|
25
|
+
|
|
26
|
+
- **Shell integration now keeps its generated functions out of your shell RC
|
|
27
|
+
files.** Nemus writes them to `~/.nemus/shell-integration.sh` and adds only a
|
|
28
|
+
single guarded `source` line to `.zshrc`/`.bashrc`, so upgrades no longer
|
|
29
|
+
churn hundreds of lines in your personal shell config. Existing inline
|
|
30
|
+
installations migrate automatically without disturbing surrounding user
|
|
31
|
+
content, and reinstalls stay idempotent. Uninstall removes the generated file
|
|
32
|
+
too. Thanks to @uzikilon for the idea and original implementation (#50).
|
|
33
|
+
|
|
10
34
|
## [0.13.0] - 2026-09-02
|
|
11
35
|
|
|
12
36
|
### Added
|
package/README.md
CHANGED
|
@@ -138,7 +138,9 @@ npm install -g @nemus-cli/nemus
|
|
|
138
138
|
```
|
|
139
139
|
|
|
140
140
|
The postinstall step sets up optional shell integration (auto-cd into new
|
|
141
|
-
workspaces + a quick-navigate helper).
|
|
141
|
+
workspaces + a quick-navigate helper). It keeps the generated functions in
|
|
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`.
|
|
142
144
|
|
|
143
145
|
### From source
|
|
144
146
|
|
|
@@ -282,7 +284,8 @@ Everything Nemus reads from the environment (all optional):
|
|
|
282
284
|
| Variable | Effect |
|
|
283
285
|
| --- | --- |
|
|
284
286
|
| `NEMUS_DIR` | Override where workspaces are created (also `WORKSPACE_MANAGER_DIR`). |
|
|
285
|
-
| `NEMUS_CACHE_DIR` | Override the
|
|
287
|
+
| `NEMUS_CACHE_DIR` | Override the config/state dir. Default: `~/.nemus`, or `$XDG_CONFIG_HOME/nemus` (→ `~/.config/nemus`) on Linux. Also `WORKSPACE_MANAGER_CACHE_DIR`. |
|
|
288
|
+
| `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. |
|
|
286
289
|
| `NEMUS_JUDGE_MODEL` | Model for the `reflect` judge (overrides `--model`'s default). |
|
|
287
290
|
| `NEMUS_JUDGE_THINKING` | Thinking level for the `reflect` judge on pi (`off`…`max`). |
|
|
288
291
|
| `NEMUS_JUDGE_TIMEOUT_MS` | Timeout for the `reflect` judge call. |
|
|
@@ -424,7 +427,19 @@ export NEMUS_CACHE_DIR="$HOME/.cache/nemus" # default: ~/.nemus
|
|
|
424
427
|
|
|
425
428
|
(The legacy `WORKSPACE_MANAGER_DIR` / `WORKSPACE_MANAGER_CACHE_DIR` names still
|
|
426
429
|
work as fallbacks. On first run, state from the old `~/.workspace-manager-cache`
|
|
427
|
-
location is migrated to
|
|
430
|
+
location is migrated to the resolved config dir automatically.)
|
|
431
|
+
|
|
432
|
+
**Config/state location (XDG-aware).** Resolution order, first match wins —
|
|
433
|
+
designed so existing installs are never silently moved:
|
|
434
|
+
|
|
435
|
+
1. `NEMUS_CACHE_DIR` / `WORKSPACE_MANAGER_CACHE_DIR` (explicit override).
|
|
436
|
+
2. An existing `~/.nemus` that holds durable state (anything beyond regenerable
|
|
437
|
+
caches like `repos-cache.json` / `last-version-check.json` and the
|
|
438
|
+
`shell-integration.sh` file — e.g. `config.json`, `suites.json`, the
|
|
439
|
+
`reflect/` reports) — kept on every platform, never moved.
|
|
440
|
+
3. `XDG_CONFIG_HOME/nemus`, when `XDG_CONFIG_HOME` is set to an absolute path.
|
|
441
|
+
4. Linux with no prior install → `~/.config/nemus` (the XDG default).
|
|
442
|
+
5. Otherwise (macOS/Windows, fresh install) → `~/.nemus`.
|
|
428
443
|
|
|
429
444
|
Key settings: `githubOrg` (which org's repos to list — leave empty to list your
|
|
430
445
|
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 = {
|
|
@@ -491,17 +491,30 @@ else
|
|
|
491
491
|
exit 1
|
|
492
492
|
fi
|
|
493
493
|
|
|
494
|
-
#
|
|
495
|
-
|
|
494
|
+
# Keep the generated functions out of the user's shell config. The same file
|
|
495
|
+
# works in both bash and zsh; the RC file only needs this guarded source line.
|
|
496
|
+
SHELL_INTEGRATION_DIR="$HOME/.nemus"
|
|
497
|
+
SHELL_INTEGRATION_FILE="$SHELL_INTEGRATION_DIR/shell-integration.sh"
|
|
498
|
+
SOURCE_LINE='[ -f "$HOME/.nemus/shell-integration.sh" ] && source "$HOME/.nemus/shell-integration.sh"'
|
|
499
|
+
|
|
500
|
+
mkdir -p "$SHELL_INTEGRATION_DIR"
|
|
501
|
+
SHELL_TMPFILE=$(mktemp "$SHELL_INTEGRATION_DIR/.shell-integration.XXXXXX")
|
|
502
|
+
printf '%s\n' "$SHELL_FUNCTION" > "$SHELL_TMPFILE"
|
|
503
|
+
mv "$SHELL_TMPFILE" "$SHELL_INTEGRATION_FILE"
|
|
504
|
+
|
|
505
|
+
append_source_line() {
|
|
506
|
+
printf '\n# Nemus - Shell Integration\n%s\n' "$SOURCE_LINE" >> "$1"
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
# Check if already installed
|
|
510
|
+
if grep -Fq "$SOURCE_LINE" "$RC_FILE" 2>/dev/null; then
|
|
496
511
|
echo "✅ Shell integration already up to date in $RC_FILE"
|
|
497
512
|
elif grep -q "# Nemus - Shell Integration" "$RC_FILE" 2>/dev/null; then
|
|
498
|
-
# Old version installed — remove it and
|
|
513
|
+
# Old inline version installed — remove it and leave only the source line
|
|
499
514
|
echo "🔄 Upgrading shell integration in $RC_FILE..."
|
|
500
515
|
TMPFILE=$(mktemp)
|
|
501
516
|
awk -f "$SCRIPT_DIR/remove-shell-block.awk" "$RC_FILE" > "$TMPFILE"
|
|
502
|
-
|
|
503
|
-
echo "" >> "$TMPFILE"
|
|
504
|
-
echo "$SHELL_FUNCTION" >> "$TMPFILE"
|
|
517
|
+
append_source_line "$TMPFILE"
|
|
505
518
|
cp "$RC_FILE" "${RC_FILE}.backup.$(date +%Y%m%d_%H%M%S)"
|
|
506
519
|
chmod --reference="$RC_FILE" "$TMPFILE" 2>/dev/null || chmod "$(stat -f '%Lp' "$RC_FILE")" "$TMPFILE" 2>/dev/null || true
|
|
507
520
|
mv "$TMPFILE" "$RC_FILE"
|
|
@@ -514,9 +527,8 @@ else
|
|
|
514
527
|
echo "📦 Backup created: ${RC_FILE}.backup.*"
|
|
515
528
|
fi
|
|
516
529
|
|
|
517
|
-
# Append
|
|
518
|
-
|
|
519
|
-
echo "$SHELL_FUNCTION" >> "$RC_FILE"
|
|
530
|
+
# Append the source line (creates the file if it doesn't exist)
|
|
531
|
+
append_source_line "$RC_FILE"
|
|
520
532
|
|
|
521
533
|
echo "✅ Shell integration installed in $RC_FILE"
|
|
522
534
|
echo ""
|
package/package.json
CHANGED
package/remove-shell-block.awk
CHANGED
|
@@ -33,6 +33,14 @@ skip {
|
|
|
33
33
|
saw_func = 1
|
|
34
34
|
next
|
|
35
35
|
}
|
|
36
|
+
if (/\.nemus\/shell-integration\.sh/ && /source/) {
|
|
37
|
+
# Current installs keep the functions in a generated file and leave only
|
|
38
|
+
# this source line in the RC file.
|
|
39
|
+
skip = 0
|
|
40
|
+
saw_func = 0
|
|
41
|
+
buf = ""
|
|
42
|
+
next
|
|
43
|
+
}
|
|
36
44
|
# Non-function line: block is over — emit any buffered lines
|
|
37
45
|
skip = 0
|
|
38
46
|
if (buf != "") printf "%s", buf
|
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');
|
package/uninstall.sh
CHANGED
|
@@ -38,7 +38,7 @@ echo -e "${YELLOW}This will NOT remove:${NC}"
|
|
|
38
38
|
echo " • Your workspaces ($HOME/workspaces/)"
|
|
39
39
|
echo " • Cache files (~/.workspace-manager-cache/)"
|
|
40
40
|
echo " • Configuration files (~/.workspace-manager-claude-config.json)"
|
|
41
|
-
echo " • Shell integration (from .zshrc or .bashrc)"
|
|
41
|
+
echo " • Shell integration (from .zshrc or .bashrc and ~/.nemus/)"
|
|
42
42
|
echo " • ghq (if installed)"
|
|
43
43
|
echo ""
|
|
44
44
|
|
|
@@ -85,8 +85,11 @@ if [[ $REPLY =~ ^[Yy]$ ]]; then
|
|
|
85
85
|
if grep -q "# Nemus - Shell Integration" "$HOME/.zshrc"; then
|
|
86
86
|
# Create backup
|
|
87
87
|
cp "$HOME/.zshrc" "$HOME/.zshrc.backup.$(date +%Y%m%d_%H%M%S)"
|
|
88
|
-
# Remove the
|
|
89
|
-
|
|
88
|
+
# Remove the block/source line (awk handles both formats safely)
|
|
89
|
+
TMPFILE=$(mktemp)
|
|
90
|
+
awk -f "$SCRIPT_DIR/remove-shell-block.awk" "$HOME/.zshrc" > "$TMPFILE"
|
|
91
|
+
chmod --reference="$HOME/.zshrc" "$TMPFILE" 2>/dev/null || chmod "$(stat -f '%Lp' "$HOME/.zshrc")" "$TMPFILE" 2>/dev/null || true
|
|
92
|
+
mv "$TMPFILE" "$HOME/.zshrc"
|
|
90
93
|
print_success "Removed from .zshrc (backup created)"
|
|
91
94
|
fi
|
|
92
95
|
fi
|
|
@@ -96,11 +99,19 @@ if [[ $REPLY =~ ^[Yy]$ ]]; then
|
|
|
96
99
|
if grep -q "# Nemus - Shell Integration" "$HOME/.bashrc"; then
|
|
97
100
|
# Create backup
|
|
98
101
|
cp "$HOME/.bashrc" "$HOME/.bashrc.backup.$(date +%Y%m%d_%H%M%S)"
|
|
99
|
-
# Remove the
|
|
100
|
-
|
|
102
|
+
# Remove the block/source line (awk handles both formats safely)
|
|
103
|
+
TMPFILE=$(mktemp)
|
|
104
|
+
awk -f "$SCRIPT_DIR/remove-shell-block.awk" "$HOME/.bashrc" > "$TMPFILE"
|
|
105
|
+
chmod --reference="$HOME/.bashrc" "$TMPFILE" 2>/dev/null || chmod "$(stat -f '%Lp' "$HOME/.bashrc")" "$TMPFILE" 2>/dev/null || true
|
|
106
|
+
mv "$TMPFILE" "$HOME/.bashrc"
|
|
101
107
|
print_success "Removed from .bashrc (backup created)"
|
|
102
108
|
fi
|
|
103
109
|
fi
|
|
110
|
+
|
|
111
|
+
if [ -f "$HOME/.nemus/shell-integration.sh" ]; then
|
|
112
|
+
rm -f "$HOME/.nemus/shell-integration.sh"
|
|
113
|
+
print_success "Removed ~/.nemus/shell-integration.sh"
|
|
114
|
+
fi
|
|
104
115
|
fi
|
|
105
116
|
|
|
106
117
|
# Optionally remove cache
|