@liumir/lmcode 0.5.13

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.
@@ -0,0 +1,457 @@
1
+ /**
2
+ * Where the user actually types things, and what `lm` resolves to
3
+ * from there.
4
+ *
5
+ * Covers:
6
+ *
7
+ * - Package-manager detection ({@link detectPackageManager}) and
8
+ * manager-specific command hints ({@link pmGlobalBinCommand},
9
+ * {@link pmGlobalInstallCommand}) used in user-facing notices.
10
+ * - Global-install gating ({@link isGlobalInstall}) — what counts as
11
+ * a global install across npm / yarn classic / pnpm.
12
+ * - Own-package-root location ({@link ownPackageRoot}) — walks up
13
+ * from `import.meta.dirname` looking for `package.json`.
14
+ * - User-shell PATH ({@link userShellPath}) — spawns `$SHELL -l` so
15
+ * we can check reachability from the shell the user will type
16
+ * `lm` into, not just the installer's environment.
17
+ * - The combined PATH dispatcher ({@link postinstallPaths}) —
18
+ * called once by the orchestrator so detection and reachability
19
+ * stay symmetric and the shell probe doesn't run twice.
20
+ * - The reachability check ({@link findFirstResolvableScream}) —
21
+ * walks PATH treating the to-be-renamed legacy shims as gone
22
+ * and reports what wins resolution. Distinguishes our own shim
23
+ * from a blocked legacy that's still in the way vs. an
24
+ * unknown/foreign `lm` (e.g. a user-written wrapper).
25
+ *
26
+ * All functions are pure w.r.t. the filesystem (no mutations).
27
+ */
28
+
29
+ import { spawn } from 'node:child_process';
30
+ import { promises as fs } from 'node:fs';
31
+ import { delimiter, dirname, join, sep } from 'node:path';
32
+
33
+ const LEGACY_BIN = 'scream';
34
+ const IS_WINDOWS = process.platform === 'win32';
35
+
36
+ /**
37
+ * Expand a basename like `lm` into the set of filenames the OS
38
+ * would actually match on PATH.
39
+ *
40
+ * On POSIX: just `[,]`.
41
+ *
42
+ * On Windows: `[,, 'scream.exe', 'scream.cmd', …]` — every
43
+ * extension in `PATHEXT`. Without this, our PATH walk would miss
44
+ * the typical `scream.exe` shim produced by `uv tool install` on
45
+ * Windows.
46
+ */
47
+ export function executableCandidates(basename) {
48
+ if (!IS_WINDOWS) return [basename];
49
+ const pathext = (process.env['PATHEXT'] ?? '.EXE;.CMD;.BAT;.COM')
50
+ .toLowerCase()
51
+ .split(';')
52
+ .map((e) => e.trim())
53
+ .filter(Boolean);
54
+ return [basename, ...pathext.map((ext) => basename + ext)];
55
+ }
56
+
57
+ /**
58
+ * Identify which package manager ran us. `npm_config_user_agent` is
59
+ * set by npm, yarn (classic + berry), and pnpm, and starts with the
60
+ * manager's name plus version. Defaults to `'npm'` for unknown /
61
+ * missing values.
62
+ */
63
+ export function detectPackageManager() {
64
+ const ua = process.env['npm_config_user_agent'] ?? '';
65
+ if (ua.startsWith('pnpm/')) return 'pnpm';
66
+ if (ua.startsWith('yarn/')) return 'yarn';
67
+ return 'npm';
68
+ }
69
+
70
+ /**
71
+ * Manager-specific shell command that prints (or expands to) the
72
+ * global bin directory. Used in the PATH-fix hint so the suggestion
73
+ * is valid regardless of which manager the user ran.
74
+ */
75
+ export function pmGlobalBinCommand(pm) {
76
+ switch (pm) {
77
+ case 'pnpm':
78
+ return 'pnpm bin -g';
79
+ case 'yarn':
80
+ return 'yarn global bin';
81
+ case 'npm':
82
+ default:
83
+ return 'npm prefix -g';
84
+ }
85
+ }
86
+
87
+ /** Manager-specific reinstall command, used in the success-notice hint. */
88
+ export function pmGlobalInstallCommand(pm, pkg) {
89
+ switch (pm) {
90
+ case 'pnpm':
91
+ return `pnpm add -g ${pkg}`;
92
+ case 'yarn':
93
+ return `yarn global add ${pkg}`;
94
+ case 'npm':
95
+ default:
96
+ return `npm install -g ${pkg}`;
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Did the user run a global install via some Node package manager?
102
+ *
103
+ * We accept four signals, any of which means "this install is
104
+ * landing in the manager's global bin directory":
105
+ *
106
+ * - `npm_config_global === 'true'` — set by `npm install -g`, and
107
+ * by pnpm on its global paths for back-compat.
108
+ * - `pnpm_config_global === 'true'` — set by pnpm in addition to
109
+ * the npm-compat flag above.
110
+ * - `npm_config_location === 'global'` — set by npm 7+ when the
111
+ * user passes `--location=global` (or runs `npm config set
112
+ * location global` persistently). npm intentionally does NOT
113
+ * also set `npm_config_global` in this case, so without this
114
+ * branch the migration silently no-ops for `npm install
115
+ * --location=global @lmcode-cli/lmcode` — verified on npm
116
+ * 11.13.0.
117
+ * - {@link isYarnClassicGlobalAdd} — yarn classic does NOT set
118
+ * `npm_config_global` for `yarn global add`. The only reliable
119
+ * signal is parsing `npm_config_argv` and seeing the `global`
120
+ * subcommand. Verified on yarn 1.22.22.
121
+ *
122
+ * Local installs, `npx`, `pnpm dlx`, and workspace bootstraps leave
123
+ * all four signals false.
124
+ *
125
+ * Yarn berry (v2+) intentionally has no global-install concept, so
126
+ * it doesn't matter here. Postinstall on yarn berry runs in local
127
+ * context.
128
+ */
129
+ export function isGlobalInstall() {
130
+ return (
131
+ process.env['npm_config_global'] === 'true' ||
132
+ process.env['pnpm_config_global'] === 'true' ||
133
+ process.env['npm_config_location'] === 'global' ||
134
+ isYarnClassicGlobalAdd()
135
+ );
136
+ }
137
+
138
+ /**
139
+ * `yarn global add` (yarn classic, v1.x) runs lifecycle scripts but
140
+ * leaves both `npm_config_global` and `npm_config_location` unset.
141
+ * The only reliable in-band signal is `npm_config_argv`, which yarn
142
+ * populates with the original command line as JSON:
143
+ * { original: ["global", "add", "<pkg>", "--prefix=..."] }
144
+ * Parse it and require both:
145
+ * - `npm_config_user_agent` starts with `yarn/1.` (yarn classic;
146
+ * yarn berry has no global concept anyway).
147
+ * - Some token in argv is literally `"global"` AND the very next
148
+ * token is a known yarn-global subcommand (`add`, `remove`,
149
+ * etc). This handles the simple case (`yarn global add foo` →
150
+ * argv `["global","add",...]`) and the value-taking-flag case
151
+ * (`yarn --cwd /tmp global add foo` → argv `["--cwd","/tmp",
152
+ * "global","add",...]`) without having to maintain yarn's full
153
+ * flag table. It rejects `yarn add global` (the next token is
154
+ * undefined) and `yarn add @scope/global` (the literal string
155
+ * `"global"` doesn't appear). The remaining false positives
156
+ * (e.g., `yarn add global add` — installing two packages, one
157
+ * literally named `global`) are caught downstream by the
158
+ * reachability gate: a local install's bin dir isn't on PATH,
159
+ * so `isOwnCliResolvableFirst` will refuse to migrate.
160
+ */
161
+ function isYarnClassicGlobalAdd() {
162
+ const ua = process.env['npm_config_user_agent'] ?? '';
163
+ if (!ua.startsWith('yarn/1.')) return false;
164
+ const raw = process.env['npm_config_argv'];
165
+ if (!raw) return false;
166
+ let argv;
167
+ try {
168
+ argv = JSON.parse(raw);
169
+ } catch {
170
+ return false;
171
+ }
172
+ if (!Array.isArray(argv?.original)) return false;
173
+ const globalIdx = argv.original.indexOf('global');
174
+ if (globalIdx === -1) return false;
175
+ const next = argv.original[globalIdx + 1];
176
+ return typeof next === 'string' && YARN_GLOBAL_SUBCOMMANDS.has(next);
177
+ }
178
+
179
+ // Yarn 1.x global subcommands. The install-class ones (`add`,
180
+ // `upgrade`, `upgrade-interactive`) are the ones that actually run
181
+ // our postinstall, but the read-only ones are included so the
182
+ // detection is consistent across all `yarn global ...` invocations.
183
+ const YARN_GLOBAL_SUBCOMMANDS = new Set([
184
+ 'add',
185
+ 'remove',
186
+ 'upgrade',
187
+ 'upgrade-interactive',
188
+ 'list',
189
+ 'bin',
190
+ 'dir',
191
+ ]);
192
+
193
+ /**
194
+ * Locate the realpath of our own installed package root.
195
+ *
196
+ * Callers pass the starting directory (typically `import.meta.dirname`
197
+ * of the entry script, which lives at `<package-root>/scripts/`). We
198
+ * walk up looking for the nearest `package.json`, then `realpath` the
199
+ * directory so symlinked install layouts (e.g. `<prefix>/bin/scream`
200
+ * symlinked into `lib/node_modules/.../dist/main.mjs`) compare equal
201
+ * in the caller's prefix check.
202
+ *
203
+ * Returns null if no `package.json` is found within a few levels —
204
+ * callers should treat that as "can't locate ourselves" and bail.
205
+ */
206
+ export async function ownPackageRoot(startDir) {
207
+ let dir = startDir;
208
+ for (let i = 0; i < 6; i++) {
209
+ try {
210
+ await fs.access(join(dir, 'package.json'));
211
+ try {
212
+ return await fs.realpath(dir);
213
+ } catch {
214
+ return dir;
215
+ }
216
+ } catch {
217
+ // No `package.json` here; walk up.
218
+ }
219
+ const parent = dirname(dir);
220
+ if (parent === dir) break;
221
+ dir = parent;
222
+ }
223
+ return null;
224
+ }
225
+
226
+ async function isExecutableFile(filePath) {
227
+ try {
228
+ const info = await fs.stat(filePath);
229
+ if (!info.isFile()) return false;
230
+ // Windows: ACLs aren't visible through stat().mode meaningfully —
231
+ // existence + a recognized extension is what PATHEXT-style lookup
232
+ // checks. Callers only pass us candidates that already match an
233
+ // extension in `executableCandidates()`, so "is a file" suffices.
234
+ if (IS_WINDOWS) return true;
235
+ return (info.mode & 0o111) !== 0;
236
+ } catch {
237
+ return false;
238
+ }
239
+ }
240
+
241
+ // Package-manager shim generators embed the package's resolved path
242
+ // into the wrapper file. So any shim whose first KB contains our
243
+ // package name is one we own. Used as a fallback when realpath alone
244
+ // can't catch the shim — that happens for:
245
+ // - Windows cmd-shims (literal `.cmd` / `.ps1` files, not symlinks)
246
+ // - pnpm POSIX shims (literal `/bin/sh` scripts, not symlinks; pnpm
247
+ // does not symlink into the package root the way npm/yarn classic
248
+ // do on POSIX)
249
+ const PACKAGE_NAME_MARKERS = ['@lmcode-cli/lmcode', '@scream-cli\\lmcode'];
250
+
251
+ async function shimReferencesOwnPackage(shimPath) {
252
+ try {
253
+ const handle = await fs.open(shimPath, 'r');
254
+ try {
255
+ const buf = Buffer.alloc(4096);
256
+ const { bytesRead } = await handle.read(buf, 0, 4096, 0);
257
+ const text = buf.subarray(0, bytesRead).toString('latin1');
258
+ return PACKAGE_NAME_MARKERS.some((m) => text.includes(m));
259
+ } finally {
260
+ await handle.close().catch(() => {});
261
+ }
262
+ } catch {
263
+ return false;
264
+ }
265
+ }
266
+
267
+ async function classifyShim(shim, ownRoot, ownPrefix) {
268
+ let real;
269
+ try {
270
+ real = await fs.realpath(shim);
271
+ } catch {
272
+ return 'unreadable';
273
+ }
274
+ if (real === ownRoot || real.startsWith(ownPrefix)) return 'own';
275
+ if (await shimReferencesOwnPackage(shim)) return 'own';
276
+ return 'other';
277
+ }
278
+
279
+ /**
280
+ * Walk `pathString` and report what the user's shell would resolve
281
+ * `lm` to, AFTER the to-be-removed shims in `actionableShimPaths`
282
+ * are pretended gone. Returns the first match by PATH order:
283
+ *
284
+ * - { kind: 'own' } — our shim wins.
285
+ * - { kind: 'blocked-legacy', shim } — a legacy `lm` we
286
+ * detected but couldn't touch (a "blocked" shim) wins.
287
+ * - { kind: 'foreign', path } — something else wins: a
288
+ * `lm` we didn't recognize as a legacy CLI and didn't generate
289
+ * ourselves (e.g. a user-managed wrapper script in `~/bin`).
290
+ * - { kind: 'none' } — no `lm` resolves at all.
291
+ *
292
+ * Used by the orchestrator to give an accurate "why the takeover
293
+ * can't proceed" notice: a blocked-legacy blocker needs different
294
+ * remediation (sudo / admin delete) than a foreign blocker (the
295
+ * user's own file, which only they can decide what to do with).
296
+ *
297
+ * `allDetectedShimPaths` is every legacy shim our detector found
298
+ * (both blocked and actionable). It lets us distinguish a blocked
299
+ * legacy that survived our hypothetical removal pass from a totally
300
+ * unknown `lm`.
301
+ */
302
+ export async function findFirstResolvableScream(
303
+ ownRoot,
304
+ pathString,
305
+ actionableShimPaths,
306
+ allDetectedShimPaths,
307
+ ) {
308
+ if (!ownRoot || !pathString) return { kind: 'none' };
309
+ const ownPrefix = ownRoot + sep;
310
+ const candidates = executableCandidates(LEGACY_BIN);
311
+ const skipSet = new Set(actionableShimPaths ?? []);
312
+ const knownLegacySet = new Set(allDetectedShimPaths ?? []);
313
+ const seenDirs = new Set();
314
+ for (const dir of pathString.split(delimiter)) {
315
+ if (!dir || seenDirs.has(dir)) continue;
316
+ seenDirs.add(dir);
317
+ for (const name of candidates) {
318
+ const shim = join(dir, name);
319
+ if (skipSet.has(shim)) continue;
320
+ if (!(await isExecutableFile(shim))) continue;
321
+ const kind = await classifyShim(shim, ownRoot, ownPrefix);
322
+ if (kind === 'unreadable') continue;
323
+ if (kind === 'own') return { kind: 'own' };
324
+ if (knownLegacySet.has(shim)) {
325
+ return { kind: 'blocked-legacy', shim };
326
+ }
327
+ return { kind: 'foreign', path: shim };
328
+ }
329
+ }
330
+ return { kind: 'none' };
331
+ }
332
+
333
+ /**
334
+ * Read the user's default shell's view of `PATH`.
335
+ *
336
+ * Why: `process.env.PATH` reflects the environment of whichever
337
+ * shell the user happened to invoke the package manager from, which
338
+ * may or may not match the daily-driver shell they'll later type
339
+ * `lm` into. A `~/.zshrc`-only PATH entry, a `~/.bash_profile`
340
+ * that doesn't source `~/.bashrc`, or a sudo'd install that scrubs
341
+ * HOME all break the installer's-PATH heuristic.
342
+ *
343
+ * We spawn `$SHELL -l -c 'printf %s "$PATH"'` so the shell reads
344
+ * its login-profile files (`.profile`, `.bash_profile`, `.zprofile`).
345
+ * `-i` would also pull in interactive rc files (`.bashrc`, `.zshrc`)
346
+ * but it requires a tty for some shells and is much more likely to
347
+ * have side effects (prompts, agent startup) — we accept the
348
+ * narrower view and let `'unknown'` fall through to a gentler
349
+ * check.
350
+ *
351
+ * Outcomes:
352
+ * { kind: 'ok', path: string } — shell printed its PATH.
353
+ * { kind: 'unknown', reason: string } — `$SHELL` missing, spawn
354
+ * failed, timed out, or
355
+ * shell printed no `PATH`.
356
+ */
357
+ export async function userShellPath() {
358
+ // Windows has no `$SHELL`/login-shell concept worth probing — cmd.exe
359
+ // and PowerShell don't have profile files in the rc-chain sense, and
360
+ // their PATH already comes from the user's persistent registry env
361
+ // (which is what `process.env.PATH` reflects). Skip the spawn and
362
+ // let the caller fall back to `process.env.PATH`.
363
+ if (IS_WINDOWS) return { kind: 'unknown', reason: 'windows skip' };
364
+
365
+ const shell = process.env['SHELL'];
366
+ if (!shell) return { kind: 'unknown', reason: 'no SHELL env var' };
367
+
368
+ return new Promise((resolve) => {
369
+ let settled = false;
370
+ const settle = (value) => {
371
+ if (settled) return;
372
+ settled = true;
373
+ resolve(value);
374
+ };
375
+
376
+ let stdout = '';
377
+ // Wrap PATH in delimiters we can parse out, defensively, in case
378
+ // the shell prints anything else (motd, prompt redraw, …).
379
+ const probe =
380
+ 'printf "<<<SCREAM_PATH_BEGIN>>>%s<<<SCREAM_PATH_END>>>\\n" "$PATH"';
381
+ const child = spawn(shell, ['-l', '-c', probe], {
382
+ stdio: ['ignore', 'pipe', 'ignore'],
383
+ });
384
+ child.stdout.on('data', (chunk) => {
385
+ stdout += chunk.toString('utf-8');
386
+ });
387
+
388
+ // Bound the wait. Login rc files are usually quick, but a hostile
389
+ // `.profile` could hang indefinitely.
390
+ const timer = setTimeout(() => {
391
+ child.kill('SIGKILL');
392
+ settle({ kind: 'unknown', reason: 'shell spawn timed out' });
393
+ }, 5000);
394
+
395
+ child.on('close', (code) => {
396
+ clearTimeout(timer);
397
+ const match = stdout.match(
398
+ /<<<SCREAM_PATH_BEGIN>>>([\s\S]*?)<<<SCREAM_PATH_END>>>/,
399
+ );
400
+ if (match && match[1].length > 0) {
401
+ settle({ kind: 'ok', path: match[1] });
402
+ return;
403
+ }
404
+ settle({ kind: 'unknown', reason: `no PATH printed (exit ${code})` });
405
+ });
406
+ child.on('error', (err) => {
407
+ clearTimeout(timer);
408
+ settle({ kind: 'unknown', reason: `spawn error: ${err.message}` });
409
+ });
410
+ });
411
+ }
412
+
413
+ /**
414
+ * Compute the two PATH strings the postinstall consults.
415
+ *
416
+ * - `detection`: a `delimiter`-joined union of the user's shell
417
+ * PATH and `process.env.PATH`, deduplicated. Used by
418
+ * {@link detectLegacyShim} to walk for legacy shims. We union
419
+ * because either source may contain a legacy `lm` we want to
420
+ * rename — including one that lives in a directory the
421
+ * installer's environment can't see but the user's shell can
422
+ * (e.g. a sanitized lifecycle env from a packaged manager), and
423
+ * vice versa.
424
+ *
425
+ * - `reachability`: the user's shell PATH if we can probe it, else
426
+ * `process.env.PATH`. Used to verify our own shim is on the PATH
427
+ * the user will actually type `lm` into. We do NOT union here:
428
+ * a shim that's only in the installer's PATH wouldn't help the
429
+ * user after the install, so unioning would mis-classify the
430
+ * install as reachable.
431
+ *
432
+ * Returns a single object so the (potentially slow) shell-probe runs
433
+ * just once per postinstall.
434
+ */
435
+ export async function postinstallPaths() {
436
+ const shellResult = await userShellPath();
437
+ const processPath = process.env['PATH'] ?? '';
438
+ const shellPathStr = shellResult.kind === 'ok' ? shellResult.path : null;
439
+ const reachability = shellPathStr ?? processPath;
440
+ const detection = unionPaths(shellPathStr, processPath);
441
+ return { detection, reachability };
442
+ }
443
+
444
+ function unionPaths(...paths) {
445
+ const seen = new Set();
446
+ const out = [];
447
+ for (const p of paths) {
448
+ if (!p) continue;
449
+ for (const entry of p.split(delimiter)) {
450
+ if (!entry || seen.has(entry)) continue;
451
+ seen.add(entry);
452
+ out.push(entry);
453
+ }
454
+ }
455
+ return out.join(delimiter);
456
+ }
457
+
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Create a desktop shortcut for LMcode on Windows.
3
+ *
4
+ * Only runs on Win32 and for global installs. Never fails the install —
5
+ * errors are caught and swallowed.
6
+ */
7
+
8
+ import { execFileSync } from 'node:child_process';
9
+ import { resolve } from 'node:path';
10
+ import { existsSync } from 'node:fs';
11
+
12
+ export function createDesktopShortcut() {
13
+ if (process.platform !== 'win32') return;
14
+
15
+ const iconPath = resolve(import.meta.dirname, '../../icon.ico');
16
+
17
+ try {
18
+ execFileSync(
19
+ 'powershell.exe',
20
+ [
21
+ '-NoProfile',
22
+ '-ExecutionPolicy', 'Bypass',
23
+ '-Command',
24
+ shortcutPowerShellScript.replace(
25
+ '__ICON_LOCATION__',
26
+ existsSync(iconPath) ? iconPath : '',
27
+ ),
28
+ ],
29
+ { stdio: 'ignore', timeout: 10_000 },
30
+ );
31
+ } catch {
32
+ // Never fail the install over a shortcut.
33
+ }
34
+ }
35
+
36
+ const shortcutPowerShellScript = `
37
+ $ErrorActionPreference = 'Stop'
38
+
39
+ $DesktopPath = [Environment]::GetFolderPath('Desktop')
40
+ $ShortcutPath = "$DesktopPath\\LMcode.lnk"
41
+ $WshShell = New-Object -ComObject WScript.Shell
42
+ $Shortcut = $WshShell.CreateShortcut($ShortcutPath)
43
+
44
+ $wt = Get-Command wt.exe -ErrorAction SilentlyContinue
45
+ $pwsh7 = Get-Command pwsh.exe -ErrorAction SilentlyContinue
46
+ $ps5 = Get-Command powershell.exe -ErrorAction SilentlyContinue
47
+
48
+ if ($wt) {
49
+ $Shortcut.TargetPath = $wt.Source
50
+ $Shortcut.Arguments = '--title "LMcode" cmd /k "chcp 65001 > nul && scream"'
51
+ }
52
+ elseif ($pwsh7) {
53
+ $Shortcut.TargetPath = $pwsh7.Source
54
+ $Shortcut.Arguments = '-NoExit -Command "chcp 65001 > $null; scream"'
55
+ }
56
+ elseif ($ps5) {
57
+ $Shortcut.TargetPath = $ps5.Source
58
+ $Shortcut.Arguments = '-NoExit -Command "chcp 65001 > $null; [Console]::OutputEncoding = [Console]::InputEncoding = [Text.Encoding]::UTF8; $Host.UI.RawUI.WindowTitle = ''LMcode''; scream"'
59
+ }
60
+ else {
61
+ $Shortcut.TargetPath = 'powershell.exe'
62
+ $Shortcut.Arguments = '-NoExit -Command "chcp 65001 > $null; [Console]::OutputEncoding = [Console]::InputEncoding = [Text.Encoding]::UTF8; $Host.UI.RawUI.WindowTitle = ''LMcode''; scream"'
63
+ }
64
+
65
+ $Shortcut.WorkingDirectory = $env:USERPROFILE
66
+ $Shortcut.Description = 'LMcode - AI 命令行助手'
67
+
68
+ $IconPath = '__ICON_LOCATION__'
69
+ if ($IconPath -and (Test-Path $IconPath)) {
70
+ $Shortcut.IconLocation = $IconPath
71
+ }
72
+
73
+ $Shortcut.Save()
74
+ `.trim();