@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.
- package/LICENSE +21 -0
- package/README.md +147 -0
- package/dist/app-BrCDSMM8.mjs +139435 -0
- package/dist/assets/tokenizers.win32-x64-msvc-7FuPBfvC.node +0 -0
- package/dist/chunk-apG1qJts.mjs +41 -0
- package/dist/dist-0bMQWc-B.mjs +1258 -0
- package/dist/esm-CmfJNv9s.mjs +8590 -0
- package/dist/from-CKE2n10i.mjs +3849 -0
- package/dist/main.d.mts +2 -0
- package/dist/main.mjs +15 -0
- package/dist/multipart-parser-BxHsVgPe.mjs +299 -0
- package/dist/src-BMbLXrAA.mjs +1182 -0
- package/dist/suppress-sqlite-warning-C2VB0doZ.mjs +52 -0
- package/icon.ico +0 -0
- package/package.json +80 -0
- package/scripts/postinstall/migrate.mjs +351 -0
- package/scripts/postinstall/reach.mjs +457 -0
- package/scripts/postinstall/shortcut.mjs +74 -0
- package/scripts/postinstall/ui.mjs +458 -0
- package/scripts/postinstall.mjs +276 -0
|
@@ -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();
|