@nemus-cli/nemus 0.15.1 → 0.15.2
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 +20 -0
- package/package.json +1 -1
- package/scripts/postinstall.js +89 -13
- package/src/postinstall.test.ts +115 -42
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.15.2] - 2026-09-04
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- **First-run setup now runs for global installs via yarn and pnpm too — without
|
|
15
|
+
re-introducing the hang for their transient runners.** 0.15.1 gated postinstall
|
|
16
|
+
setup on `npm_config_global`, which only npm sets, so `yarn global add` /
|
|
17
|
+
`pnpm add -g` silently skipped `configure` + shell integration (the auto-cd /
|
|
18
|
+
`nemgo` feature). The gate now classifies the install context: it recognizes a
|
|
19
|
+
global yarn/pnpm install from `npm_config_user_agent`, but detects transient
|
|
20
|
+
one-off runners (`npx`, `pnpm dlx`, `yarn dlx`) by the per-run cache path they
|
|
21
|
+
stage into (`.../_npx/...`, `.../dlx/...`, the OS temp dir, with the macOS
|
|
22
|
+
`/private` symlink normalized) — because `pnpm dlx`/`yarn dlx` set *neither*
|
|
23
|
+
`npm_command` *nor* `npm_config_global`, so a user-agent check alone would
|
|
24
|
+
misclassify them as a global install. Hang-safety itself comes from a separate
|
|
25
|
+
guard — the interactive `configure` only fires for a confirmed npm `-g`
|
|
26
|
+
install, so yarn/pnpm globals get the non-interactive shell integration plus a
|
|
27
|
+
hint and can never hang; the transient path detection's job is to stop a
|
|
28
|
+
throwaway `dlx` run from spuriously writing the shell RC. (#92)
|
|
29
|
+
|
|
10
30
|
## [0.15.1] - 2026-09-04
|
|
11
31
|
|
|
12
32
|
### Fixed
|
package/package.json
CHANGED
package/scripts/postinstall.js
CHANGED
|
@@ -17,18 +17,88 @@ const fs = require('fs');
|
|
|
17
17
|
const os = require('os');
|
|
18
18
|
const path = require('path');
|
|
19
19
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
20
|
+
/**
|
|
21
|
+
* Classify the install context, so first-run setup (interactive `configure` +
|
|
22
|
+
* shell integration) runs only when it makes sense — and NEVER on a transient
|
|
23
|
+
* one-off runner (npx / dlx), where an interactive prompt on the controlling
|
|
24
|
+
* terminal would HANG. Pure (all inputs injected) so it's unit-tested.
|
|
25
|
+
*
|
|
26
|
+
* Returns: 'ci' | 'transient' | 'local' | 'global-npm' | 'global-other'.
|
|
27
|
+
*
|
|
28
|
+
* Detection is empirical (env dumped from real runners), because the managers
|
|
29
|
+
* disagree on what they set:
|
|
30
|
+
* • npm — npx sets npm_command="exec"; `-g` sets npm_config_global="true"; a
|
|
31
|
+
* local install sets it "false". Reliable.
|
|
32
|
+
* • pnpm — `pnpm dlx` sets NEITHER npm_command NOR npm_config_global, only
|
|
33
|
+
* npm_config_user_agent="pnpm/…". It DOES stage the package under a
|
|
34
|
+
* per-run cache path ("…/pnpm/dlx/<hash>/…"), which we key on.
|
|
35
|
+
* • yarn — classic `yarn global add` sets only "yarn/…" (no npm_command /
|
|
36
|
+
* npm_config_global); berry `yarn dlx` stages under a temp dir.
|
|
37
|
+
* So a bare yarn/pnpm user-agent is treated as a GLOBAL install UNLESS the
|
|
38
|
+
* install PATH shows a transient runner cache. The path is matched RAW (only the
|
|
39
|
+
* macOS /private symlink prefix is string-normalized, below) — deliberately NOT
|
|
40
|
+
* fs.realpathSync'd: realpath resolves a pnpm/yarn staging dir through its
|
|
41
|
+
* symlinks into a content-addressed store path that no longer contains /dlx/,
|
|
42
|
+
* which would defeat the marker. String-normalizing just the /private prefix
|
|
43
|
+
* fixes the macOS /var↔/private/var temp-dir case without touching the markers.
|
|
44
|
+
*
|
|
45
|
+
* NOTE on failure mode: because the interactive `configure` (the step that can
|
|
46
|
+
* hang on /dev/tty) is separately gated to a confirmed npm `-g` install (see
|
|
47
|
+
* `certainNpmGlobal`), a misclassified `pnpm dlx`/`yarn dlx` lands in
|
|
48
|
+
* 'global-other' and can never hang — it skips `configure`. The path signal's
|
|
49
|
+
* real job is therefore to stop a throwaway dlx run from spuriously appending
|
|
50
|
+
* the source line to the user's shell RC, not to provide hang-safety.
|
|
51
|
+
*/
|
|
52
|
+
function classifyInstall({ env, dirname, tmpDir }) {
|
|
53
|
+
if (env.CI) return 'ci';
|
|
54
|
+
|
|
55
|
+
const cmd = (env.npm_command || '').toLowerCase();
|
|
56
|
+
// macOS surfaces the temp dir both as /var/folders/… (os.tmpdir(), a symlink)
|
|
57
|
+
// and /private/var/folders/… (the realpath a staged package resolves to). Strip
|
|
58
|
+
// the well-known /private symlink prefix from both sides so the temp-dir
|
|
59
|
+
// comparison survives it — done by string, not fs.realpathSync, to keep this
|
|
60
|
+
// function pure (and correct for paths that don't exist yet, e.g. in tests).
|
|
61
|
+
const norm = (p) => String(p || '').replace(/\\/g, '/').replace(/^\/private(?=\/)/, '');
|
|
62
|
+
const dir = norm(dirname);
|
|
63
|
+
const tmp = norm(tmpDir);
|
|
64
|
+
const stagedInRunnerCache =
|
|
65
|
+
/\/_npx\//.test(dir) || // npm npx
|
|
66
|
+
/\/dlx\//.test(dir) || // pnpm dlx (or any /dlx/ cache)
|
|
67
|
+
(tmp !== '' && (dir === tmp || dir.startsWith(tmp + '/'))); // yarn berry dlx et al.
|
|
68
|
+
if (cmd === 'exec' || cmd === 'dlx' || stagedInRunnerCache) return 'transient';
|
|
69
|
+
|
|
70
|
+
const global = String(env.npm_config_global).toLowerCase();
|
|
71
|
+
if (global === 'true') return 'global-npm';
|
|
72
|
+
if (global === 'false') return 'local';
|
|
73
|
+
|
|
74
|
+
// No npm_config_global (yarn/pnpm): a bare manager user-agent means a global
|
|
75
|
+
// install here (their transient runners were caught above).
|
|
76
|
+
const manager = (env.npm_config_user_agent || '').toLowerCase().split('/')[0];
|
|
77
|
+
if (manager === 'yarn' || manager === 'pnpm') return 'global-other';
|
|
78
|
+
return 'local';
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
module.exports = { classifyInstall };
|
|
82
|
+
|
|
83
|
+
// When imported (unit tests) rather than run as the postinstall script, stop
|
|
84
|
+
// here — export the classifier without executing any install side effects.
|
|
85
|
+
// (CommonJS wraps modules in a function, so a top-level return is valid.)
|
|
86
|
+
if (require.main !== module) return;
|
|
87
|
+
|
|
88
|
+
// Pass the RAW __dirname (not realpath'd): the transient-runner markers below
|
|
89
|
+
// (/_npx/, /dlx/) live in the path the runner *constructs*, and fs.realpathSync
|
|
90
|
+
// would resolve a pnpm/yarn staging dir through its symlinks into a
|
|
91
|
+
// content-addressed store path that no longer contains the marker — defeating
|
|
92
|
+
// the very check. The macOS /var↔/private/var temp symlink is instead handled
|
|
93
|
+
// deterministically by string normalization inside classifyInstall.
|
|
94
|
+
const installDecision = classifyInstall({ env: process.env, dirname: __dirname, tmpDir: os.tmpdir() });
|
|
95
|
+
// Only 'global-*' installs get first-run setup; ci/transient/local are no-ops.
|
|
96
|
+
if (installDecision !== 'global-npm' && installDecision !== 'global-other') process.exit(0);
|
|
97
|
+
// Interactive `configure` (reaches /dev/tty) fires ONLY when we're certain it's
|
|
98
|
+
// an npm `-g` install. yarn/pnpm can't be told apart from their dlx runners by
|
|
99
|
+
// env alone, so they get the NON-interactive shell integration below + a hint —
|
|
100
|
+
// which can never hang.
|
|
101
|
+
const certainNpmGlobal = installDecision === 'global-npm';
|
|
32
102
|
|
|
33
103
|
const PKG_ROOT = path.join(__dirname, '..');
|
|
34
104
|
const SHELL_SCRIPT = path.join(PKG_ROOT, 'install-shell-integration.sh');
|
|
@@ -79,7 +149,7 @@ function openControllingTty() {
|
|
|
79
149
|
}
|
|
80
150
|
}
|
|
81
151
|
|
|
82
|
-
if (!optedOut() && fs.existsSync(CLI_BIN) && !alreadyConfigured()) {
|
|
152
|
+
if (certainNpmGlobal && !optedOut() && fs.existsSync(CLI_BIN) && !alreadyConfigured()) {
|
|
83
153
|
const tty = openControllingTty();
|
|
84
154
|
if (tty !== null) {
|
|
85
155
|
try {
|
|
@@ -156,6 +226,12 @@ console.log('');
|
|
|
156
226
|
console.log(' Or open a new terminal tab. Until then, auto-CD into a new');
|
|
157
227
|
console.log(' workspace won\'t work (the CLI itself still runs fine).');
|
|
158
228
|
console.log('');
|
|
229
|
+
if (!certainNpmGlobal) {
|
|
230
|
+
// yarn/pnpm global: we installed shell integration but deliberately did NOT
|
|
231
|
+
// launch the interactive configure — point the user at it explicitly.
|
|
232
|
+
console.log(' \x1b[90m(yarn/pnpm install — run \x1b[36mnemus configure\x1b[90m to finish first-time setup.)\x1b[0m');
|
|
233
|
+
console.log('');
|
|
234
|
+
}
|
|
159
235
|
console.log(' Tip: \x1b[36mnemus\x1b[0m works immediately (\x1b[36mgv\x1b[0m is a short alias):');
|
|
160
236
|
console.log(' \x1b[36mnemus configure\x1b[0m \x1b[90m# first-time setup\x1b[0m');
|
|
161
237
|
console.log(' \x1b[36mnemus list\x1b[0m \x1b[90m# list workspaces\x1b[0m');
|
package/src/postinstall.test.ts
CHANGED
|
@@ -1,52 +1,125 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest';
|
|
2
2
|
import { execFileSync } from 'node:child_process';
|
|
3
|
+
import { createRequire } from 'node:module';
|
|
3
4
|
import { mkdtempSync, writeFileSync, readFileSync, rmSync } from 'node:fs';
|
|
4
5
|
import { tmpdir } from 'node:os';
|
|
5
6
|
import { join } from 'node:path';
|
|
6
7
|
|
|
7
8
|
const SCRIPT = join(__dirname, '..', 'scripts', 'postinstall.js');
|
|
9
|
+
// postinstall.js guards its side effects with `require.main !== module`, so
|
|
10
|
+
// importing it just exposes the pure classifier.
|
|
11
|
+
const { classifyInstall } = createRequire(__filename)(SCRIPT) as {
|
|
12
|
+
classifyInstall: (i: { env: Record<string, string | undefined>; dirname: string; tmpDir: string }) => string;
|
|
13
|
+
};
|
|
8
14
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
it('
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
15
|
+
const NPM_UA = 'npm/10.9.0 node/v22.13.0 darwin arm64 workspaces/false';
|
|
16
|
+
const YARN_UA = 'yarn/1.22.22 npm/? node/v22.13.0 darwin arm64';
|
|
17
|
+
const PNPM_UA = 'pnpm/10.34.5 npm/? node/v22.13.0';
|
|
18
|
+
const TMP = '/var/folders/xy/T';
|
|
19
|
+
|
|
20
|
+
describe('classifyInstall', () => {
|
|
21
|
+
it('CI → ci', () => {
|
|
22
|
+
expect(classifyInstall({ env: { CI: 'true' }, dirname: '/anywhere', tmpDir: TMP })).toBe('ci');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('npm -g → global-npm; npm local → local', () => {
|
|
26
|
+
expect(classifyInstall({ env: { npm_config_global: 'true', npm_config_user_agent: NPM_UA }, dirname: '/usr/local/lib/node_modules/@nemus-cli/nemus/scripts', tmpDir: TMP })).toBe('global-npm');
|
|
27
|
+
expect(classifyInstall({ env: { npm_config_global: 'false', npm_config_user_agent: NPM_UA }, dirname: '/proj/node_modules/@nemus-cli/nemus/scripts', tmpDir: TMP })).toBe('local');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('npx (npm_command=exec) → transient', () => {
|
|
31
|
+
expect(classifyInstall({ env: { npm_command: 'exec', npm_config_user_agent: NPM_UA }, dirname: '/home/u/.npm/_npx/abc123/node_modules/@nemus-cli/nemus/scripts', tmpDir: TMP })).toBe('transient');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('yarn global add (bare yarn UA, no npm_command/global) → global-other', () => {
|
|
35
|
+
expect(classifyInstall({ env: { npm_config_user_agent: YARN_UA }, dirname: '/home/u/.config/yarn/global/node_modules/@nemus-cli/nemus/scripts', tmpDir: TMP })).toBe('global-other');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('pnpm add -g (bare pnpm UA) → global-other', () => {
|
|
39
|
+
expect(classifyInstall({ env: { npm_config_user_agent: PNPM_UA }, dirname: '/home/u/Library/pnpm/global/5/node_modules/@nemus-cli/nemus/scripts', tmpDir: TMP })).toBe('global-other');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// The reviewer's case: `pnpm dlx` sets NEITHER npm_command NOR
|
|
43
|
+
// npm_config_global — only the pnpm user-agent. UA-only logic would call this
|
|
44
|
+
// "global-other" and re-introduce the /dev/tty hang. The install PATH
|
|
45
|
+
// (".../pnpm/dlx/<hash>/...") is the signal that saves us.
|
|
46
|
+
it('pnpm dlx (only pnpm UA, staged under .../pnpm/dlx/...) → transient', () => {
|
|
47
|
+
const dir = '/home/u/Library/Caches/pnpm/dlx/ed050d93/1a07/node_modules/@nemus-cli/nemus/scripts';
|
|
48
|
+
expect(classifyInstall({ env: { npm_config_user_agent: PNPM_UA }, dirname: dir, tmpDir: TMP })).toBe('transient');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// A realistic pnpm dlx __dirname (verified by dumping a real run): the /dlx/
|
|
52
|
+
// marker sits BEFORE the symlinky `.pnpm` store segments. postinstall.js
|
|
53
|
+
// passes the RAW __dirname (never fs.realpathSync'd) precisely so the marker
|
|
54
|
+
// isn't resolved away into a content-addressed store path — this asserts the
|
|
55
|
+
// deep, real-shaped path still classifies transient.
|
|
56
|
+
it('pnpm dlx deep real path (marker before the .pnpm store segments) → transient', () => {
|
|
57
|
+
const dir =
|
|
58
|
+
'/Users/u/Library/Caches/pnpm/dlx/9622a716fa/1a0757951f6-12d63/node_modules/.pnpm/nemus@file+..+..+tmp/node_modules/@nemus-cli/nemus/scripts';
|
|
59
|
+
expect(classifyInstall({ env: { npm_config_user_agent: PNPM_UA }, dirname: dir, tmpDir: TMP })).toBe('transient');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('yarn berry dlx (only yarn UA, staged under the OS temp dir) → transient', () => {
|
|
63
|
+
const dir = `${TMP}/xfs-9f/node_modules/@nemus-cli/nemus/scripts`;
|
|
64
|
+
expect(classifyInstall({ env: { npm_config_user_agent: YARN_UA }, dirname: dir, tmpDir: TMP })).toBe('transient');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// The real macOS case: os.tmpdir() reports /var/folders/… (a symlink) while a
|
|
68
|
+
// package staged under it resolves to /private/var/folders/… . A raw
|
|
69
|
+
// startsWith would miss this and misclassify the dlx run as global-other
|
|
70
|
+
// (spurious shell-RC write). The classifier must normalize the /private prefix.
|
|
71
|
+
it('yarn/pnpm dlx staged under /private/var while tmpDir is /var → transient', () => {
|
|
72
|
+
const tmpDir = '/var/folders/xy/T';
|
|
73
|
+
const dir = '/private/var/folders/xy/T/xfs-9f/node_modules/@nemus-cli/nemus/scripts';
|
|
74
|
+
expect(classifyInstall({ env: { npm_config_user_agent: YARN_UA }, dirname: dir, tmpDir })).toBe('transient');
|
|
75
|
+
// symmetric: tmpDir realpath'd to /private while dir stays /var
|
|
76
|
+
expect(classifyInstall({ env: { npm_config_user_agent: PNPM_UA }, dirname: '/var/folders/xy/T/d/node_modules/x', tmpDir: '/private/var/folders/xy/T' })).toBe('transient');
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
/** Run the real postinstall.js in a sandbox HOME with a controlled environment.
|
|
81
|
+
* stdio is ignored and there's no tty, and NEMUS_SKIP_CONFIGURE guards a
|
|
82
|
+
* tty-bearing host, so we exercise the gate + non-interactive shell-integration
|
|
83
|
+
* path end-to-end (dirname is the real repo path — never transient). */
|
|
84
|
+
function runPostinstall(rcName: string, extraEnv: Record<string, string | undefined>) {
|
|
85
|
+
const home = mkdtempSync(join(tmpdir(), 'nemus-postinstall-'));
|
|
86
|
+
const rc = join(home, rcName);
|
|
87
|
+
writeFileSync(rc, '# user rc\n');
|
|
88
|
+
const env: Record<string, string> = {
|
|
89
|
+
...(process.env as Record<string, string>),
|
|
90
|
+
HOME: home,
|
|
91
|
+
NEMUS_CACHE_DIR: join(home, '.nemus'),
|
|
92
|
+
NEMUS_SKIP_CONFIGURE: '1',
|
|
93
|
+
};
|
|
94
|
+
delete env.CI;
|
|
95
|
+
for (const [k, v] of Object.entries(extraEnv)) {
|
|
96
|
+
if (v === undefined) delete env[k];
|
|
97
|
+
else env[k] = v;
|
|
98
|
+
}
|
|
99
|
+
execFileSync(process.execPath, [SCRIPT], { env, stdio: 'ignore', timeout: 20_000 });
|
|
100
|
+
const rcAfter = readFileSync(rc, 'utf8');
|
|
101
|
+
rmSync(home, { recursive: true, force: true });
|
|
102
|
+
return rcAfter;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
describe('postinstall.js (end-to-end)', () => {
|
|
106
|
+
it('npx / npm exec → no-op, RC untouched', () => {
|
|
107
|
+
expect(runPostinstall('.zshrc', { npm_command: 'exec', npm_config_user_agent: NPM_UA, npm_config_global: undefined, SHELL: '/bin/zsh' })).toBe('# user rc\n');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('npm local dependency install (global="false") → no-op, RC untouched', () => {
|
|
111
|
+
expect(runPostinstall('.zshrc', { npm_command: 'install', npm_config_user_agent: NPM_UA, npm_config_global: 'false', SHELL: '/bin/zsh' })).toBe('# user rc\n');
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('npm global install → runs shell integration (RC gets the source line)', () => {
|
|
115
|
+
expect(runPostinstall('.zshrc', { npm_command: 'install', npm_config_user_agent: NPM_UA, npm_config_global: 'true', SHELL: '/bin/zsh' })).toContain('.nemus/shell-integration.sh');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('yarn global add (no npm_config_global) → runs shell integration', () => {
|
|
119
|
+
expect(runPostinstall('.bashrc', { npm_command: undefined, npm_config_user_agent: YARN_UA, npm_config_global: undefined, SHELL: '/bin/bash' })).toContain('.nemus/shell-integration.sh');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('pnpm add -g (no npm_config_global) → runs shell integration', () => {
|
|
123
|
+
expect(runPostinstall('.bashrc', { npm_command: undefined, npm_config_user_agent: PNPM_UA, npm_config_global: undefined, SHELL: '/bin/bash' })).toContain('.nemus/shell-integration.sh');
|
|
51
124
|
});
|
|
52
125
|
});
|