@stevezhou/sisu 0.3.14 → 0.3.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -13,7 +13,7 @@ sisu login
13
13
  sisu
14
14
  ```
15
15
 
16
- `npm install -g` is a small JS package. postinstall fetches the stamped SiSu TUI pager for **this package version** into `~/.sisu/bin` when a prebuilt exists. GitHub Release tags ship `darwin-arm64`, `linux-x64`, and `linux-arm64`. `darwin-x64` is opt-in (`workflow_dispatch` with `platforms` containing `darwin-x64`) and often missing; platforms without a binary, or a missing GitHub Release asset, keep the Node TUI.
16
+ Postinstall puts `sisu` on a PATH users actually have: `~/.local/bin` on Unix, `%LOCALAPPDATA%\sisu\bin` on Windows (wrappers that call npm's `sisu.cmd`, plus a Git Bash `sisu`). It also appends that Windows directory to the user PATH. If this shell still cannot see the command, it prints `export PATH=...` (Unix) or `set PATH=` / `$env:Path` (Windows). `npm install -g` is a small JS package. It also fetches the stamped SiSu TUI pager for **this package version** into `~/.sisu/bin` when a prebuilt exists. GitHub Release tags ship `darwin-arm64`, `linux-x64`, and `linux-arm64`. `darwin-x64` is opt-in (`workflow_dispatch` with `platforms` containing `darwin-x64`) and often missing; platforms without a binary, or a missing GitHub Release asset, keep the Node TUI.
17
17
 
18
18
  Requires Node.js 20 or newer. `npx sisu` works without a global install.
19
19
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stevezhou/sisu",
3
- "version": "0.3.14",
3
+ "version": "0.3.16",
4
4
  "description": "SiSu CLI — 思溯 / SiSu · 思有所溯",
5
5
  "license": "UNLICENSED",
6
6
  "homepage": "https://www.sisu.chat",
@@ -26,7 +26,8 @@
26
26
  "NOTICE",
27
27
  "third_party/grok-build",
28
28
  "scripts/postinstall.js",
29
- "scripts/install-pager.js"
29
+ "scripts/install-pager.js",
30
+ "scripts/ensure-cli-path.js"
30
31
  ],
31
32
  "publishConfig": {
32
33
  "access": "public"
@@ -0,0 +1,206 @@
1
+ #!/usr/bin/env node
2
+ /** After `npm i -g`, put `sisu` on a PATH users actually have.
3
+ * npm's global bin is often missing from Debian/login PATH and from Windows PATH.
4
+ * Do not copy npm's sisu.cmd: it uses %~dp0 and breaks outside the prefix.
5
+ */
6
+ const { execFileSync } = require('child_process')
7
+ const fs = require('fs')
8
+ const os = require('os')
9
+ const path = require('path')
10
+
11
+ function isWin(options = {}) {
12
+ return (options.platform || process.platform) === 'win32'
13
+ }
14
+
15
+ function pathDelim(options = {}) {
16
+ if (options.delimiter) return options.delimiter
17
+ return isWin(options) ? ';' : options.platform ? ':' : path.delimiter
18
+ }
19
+
20
+ function pathDirs(pathEnv = process.env.PATH || '', options = {}) {
21
+ return pathEnv.split(pathDelim(options)).filter(Boolean)
22
+ }
23
+
24
+ function normalizePathEntry(dir, options = {}) {
25
+ if (isWin(options)) {
26
+ return String(dir).replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase()
27
+ }
28
+ try {
29
+ return path.resolve(dir)
30
+ } catch {
31
+ return dir
32
+ }
33
+ }
34
+
35
+ function pathContains(dir, pathEnv = process.env.PATH || '', options = {}) {
36
+ const target = normalizePathEntry(dir, options)
37
+ return pathDirs(pathEnv, options).some((entry) => normalizePathEntry(entry, options) === target)
38
+ }
39
+
40
+ function globalSisuBin(options = {}) {
41
+ const win = isWin(options)
42
+ const name = win ? 'sisu.cmd' : 'sisu'
43
+ const prefix = options.prefix || process.env.npm_config_prefix || ''
44
+ const fromPrefix = prefix
45
+ ? (win ? path.join(prefix, name) : path.join(prefix, 'bin', name))
46
+ : ''
47
+ const fromLayout = path.resolve(
48
+ options.packageRoot || path.join(__dirname, '..'),
49
+ '..',
50
+ '..',
51
+ win ? name : path.join('bin', name),
52
+ )
53
+ const exists = options.exists || ((file) => fs.existsSync(file))
54
+ for (const candidate of [fromPrefix, fromLayout]) {
55
+ if (candidate && exists(candidate)) return candidate
56
+ }
57
+ return fromPrefix || fromLayout
58
+ }
59
+
60
+ function userLocalBin(home = os.homedir(), options = {}) {
61
+ if (isWin(options)) {
62
+ const localAppData =
63
+ options.localAppData || process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local')
64
+ return path.join(localAppData, 'sisu', 'bin')
65
+ }
66
+ return path.join(home, '.local', 'bin')
67
+ }
68
+
69
+ function toGitBashPath(winPath) {
70
+ const normalized = String(winPath).replace(/\\/g, '/')
71
+ const drive = normalized.match(/^([A-Za-z]):\/(.*)$/)
72
+ if (drive) return `/${drive[1].toLowerCase()}/${drive[2]}`
73
+ return normalized
74
+ }
75
+
76
+ function writeShim(dest, body) {
77
+ fs.writeFileSync(dest, body)
78
+ try {
79
+ fs.chmodSync(dest, 0o755)
80
+ } catch {
81
+ // Windows may ignore chmod; the file still runs via PATHEXT / shebang
82
+ }
83
+ }
84
+
85
+ function ensureUserShim(target, options = {}) {
86
+ if (!target) return null
87
+ const dir = userLocalBin(options.home || os.homedir(), options)
88
+ fs.mkdirSync(dir, { recursive: true, mode: 0o755 })
89
+ if (isWin(options)) {
90
+ return writeWindowsWrappers(target, dir, options)
91
+ }
92
+ const dest = path.join(dir, 'sisu')
93
+ try {
94
+ if (fs.lstatSync(dest)) fs.unlinkSync(dest)
95
+ } catch {
96
+ // missing
97
+ }
98
+ fs.symlinkSync(path.resolve(target), dest)
99
+ try {
100
+ fs.chmodSync(dest, 0o755)
101
+ } catch {
102
+ // symlink mode follows the target on most POSIX
103
+ }
104
+ return dest
105
+ }
106
+
107
+ function writeWindowsWrappers(target, dir, options = {}) {
108
+ const exists = options.exists || ((file) => fs.existsSync(file))
109
+ const cmdTarget = String(target).replace(/"/g, '')
110
+ const cmdDest = path.join(dir, 'sisu.cmd')
111
+ writeShim(cmdDest, `@echo off\r\ncall "${cmdTarget}" %*\r\n`)
112
+
113
+ const ps1Source = cmdTarget.replace(/\.cmd$/i, '.ps1')
114
+ const ps1Target = exists(ps1Source) ? ps1Source : cmdTarget
115
+ writeShim(path.join(dir, 'sisu.ps1'), `& "${ps1Target.replace(/"/g, '')}" @args\r\n`)
116
+
117
+ const shSource = cmdTarget.replace(/\.cmd$/i, '')
118
+ const shTarget = exists(shSource) ? shSource : cmdTarget
119
+ writeShim(path.join(dir, 'sisu'), `#!/bin/sh\nexec "${toGitBashPath(shTarget).replace(/"/g, '')}" "$@"\n`)
120
+ return cmdDest
121
+ }
122
+
123
+ function pathHint(npmBin, localBin, pathEnv = process.env.PATH || '', options = {}) {
124
+ const dirs = []
125
+ if (localBin && !pathContains(localBin, pathEnv, options)) dirs.push(localBin)
126
+ if (npmBin && !pathContains(npmBin, pathEnv, options)) dirs.push(npmBin)
127
+ if (!dirs.length) return ''
128
+ if (isWin(options)) {
129
+ const joined = dirs.join(';')
130
+ const git = dirs.map(toGitBashPath).join(':')
131
+ return [
132
+ `set PATH=${joined};%PATH%`,
133
+ `$env:Path = "${joined};" + $env:Path`,
134
+ `export PATH="${git}:$PATH"`,
135
+ ].join('\n ')
136
+ }
137
+ return `export PATH="${dirs.join(':')}:$PATH" && hash -r`
138
+ }
139
+
140
+ function readWindowsUserPath() {
141
+ const out = execFileSync('reg', ['query', 'HKCU\\Environment', '/v', 'Path'], {
142
+ encoding: 'utf8',
143
+ stdio: ['ignore', 'pipe', 'pipe'],
144
+ })
145
+ const line = out.split(/\r?\n/).find((row) => /\bPath\s+REG_/i.test(row))
146
+ if (!line) return ''
147
+ const match = line.match(/REG_\w+\s+(.*)$/)
148
+ return match ? match[1].trim() : ''
149
+ }
150
+
151
+ function writeWindowsUserPath(value) {
152
+ execFileSync('reg', ['add', 'HKCU\\Environment', '/v', 'Path', '/t', 'REG_EXPAND_SZ', '/d', value, '/f'], {
153
+ stdio: ['ignore', 'pipe', 'pipe'],
154
+ })
155
+ }
156
+
157
+ function persistUserPath(dir, options = {}) {
158
+ if (!isWin(options) || !dir) return { added: false }
159
+ const read = options.readUserPath || readWindowsUserPath
160
+ const write = options.writeUserPath || writeWindowsUserPath
161
+ const current = String(read() || '')
162
+ if (pathContains(dir, current, options)) return { added: false }
163
+ const next = current ? `${current.replace(/;+$/, '')};${dir}` : dir
164
+ write(next)
165
+ return { added: true }
166
+ }
167
+
168
+ function installCliPath(options = {}) {
169
+ const writes = options.write || ((text) => process.stdout.write(text))
170
+ const bin = globalSisuBin(options)
171
+ const npmBinDir = bin ? path.dirname(bin) : ''
172
+ let shim = null
173
+ try {
174
+ shim = ensureUserShim(bin, options)
175
+ } catch (error) {
176
+ writes(`sisu: could not install user command (${error instanceof Error ? error.message : String(error)})\n`)
177
+ }
178
+ const localDir = shim ? path.dirname(shim) : userLocalBin(options.home, options)
179
+ try {
180
+ const persisted = persistUserPath(localDir, options)
181
+ if (persisted.added) writes('sisu: added to user PATH (new terminals will see it)\n')
182
+ } catch (error) {
183
+ writes(`sisu: could not update user PATH (${error instanceof Error ? error.message : String(error)})\n`)
184
+ }
185
+ const hint = pathHint(npmBinDir, localDir, options.pathEnv, options)
186
+ if (bin) writes(`sisu: command -> ${shim || bin}\n`)
187
+ if (hint) {
188
+ writes('sisu: if `sisu` is not found in this shell, run:\n')
189
+ writes(` ${hint}\n`)
190
+ }
191
+ return { bin, shim, hint }
192
+ }
193
+
194
+ module.exports = {
195
+ ensureUserShim,
196
+ globalSisuBin,
197
+ installCliPath,
198
+ pathContains,
199
+ pathHint,
200
+ persistUserPath,
201
+ userLocalBin,
202
+ }
203
+
204
+ if (require.main === module) {
205
+ installCliPath()
206
+ }
@@ -3,6 +3,13 @@
3
3
  * Never fail `npm install` — Node TUI still works without the binary.
4
4
  */
5
5
  const { installPager } = require('./install-pager')
6
+ const { installCliPath } = require('./ensure-cli-path')
7
+
8
+ try {
9
+ installCliPath()
10
+ } catch (error) {
11
+ process.stdout.write(`sisu: path setup skipped (${error instanceof Error ? error.message : String(error)})\n`)
12
+ }
6
13
 
7
14
  installPager().then(
8
15
  (result) => {