@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,276 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Postinstall hook for @lmcode-cli/lmcode.
4
+ *
5
+ * Goal: when this package is installed globally, ensure typing `lm`
6
+ * invokes the new TypeScript CLI. The npm `package.json` bin field
7
+ * installs a fresh `lm` shim into the global bin dir; this script
8
+ * removes any pre-existing `lm` shim left behind by the previous
9
+ * Python CLI (installed via `uv tool install`, `pipx install`,
10
+ * `pip install`, etc.) that would otherwise shadow ours via PATH
11
+ * ordering. The renamed shim is kept as `scream-legacy` so users can
12
+ * still invoke the old CLI if they want to fall back.
13
+ *
14
+ * ## Hard rules
15
+ *
16
+ * - Only runs for global installs across npm, yarn (classic), and
17
+ * pnpm. Non-global installs (npx, local project deps, workspace
18
+ * bootstraps, `pnpm dlx`) are silent no-ops.
19
+ * - Never fails the install. Any error here is caught and reported,
20
+ * but the script always exits 0.
21
+ * - Does not touch a `lm` we don't recognize as the previous
22
+ * Python CLI (matched by realpath-resolved shim head containing
23
+ * `scream_cli`).
24
+ * - Cross-platform: POSIX and Windows. Windows-specific bits live
25
+ * in the helpers (PATHEXT-aware PATH walking, whole-file marker
26
+ * sniff for uv's Rust launcher .exe, extension-preserving
27
+ * rename target like `scream.exe` → `scream-legacy.exe`).
28
+ *
29
+ * ## Code layout
30
+ *
31
+ * This file is the orchestrator; the actual logic lives in
32
+ * sibling modules to keep each file under a manageable size:
33
+ *
34
+ * - `./postinstall/reach.mjs` — package-manager detection,
35
+ * global-install gate, own-package-root resolution, user-shell
36
+ * PATH lookup, reachability check.
37
+ * - `./postinstall/migrate.mjs` — legacy detection,
38
+ * `lm`-vs-`scream-legacy` classification, the rename / unlink
39
+ * primitives.
40
+ * - `./postinstall/ui.mjs` — `notify()` (with `/dev/tty` fallback),
41
+ * ANSI styling, the fixed-width box, and the five outcome
42
+ * renderers.
43
+ *
44
+ * ## Workflow
45
+ *
46
+ * What runs when a user types `npm install -g @lmcode-cli/lmcode`
47
+ * (or the yarn / pnpm equivalent):
48
+ *
49
+ * 1. The manager extracts the package and runs lifecycle scripts.
50
+ * The `bin.scream` mapping in `package.json` tells the manager to
51
+ * install a `lm` shim under its global bin directory.
52
+ * 2. The manager invokes this script via the `scripts.postinstall`
53
+ * entry — orchestrated by `main` below.
54
+ * 3. Install-context gate: only proceed when this is a global
55
+ * install (`isGlobalInstall` checks `npm_config_global` /
56
+ * `pnpm_config_global` / `npm_config_location`).
57
+ * 4. Probe PATH once via `postinstallPaths()`: detection uses the
58
+ * union of shell PATH + process PATH; reachability uses the
59
+ * shell PATH alone (with a fallback to process PATH if the
60
+ * shell can't be probed). Sharing one probe keeps detection
61
+ * and reachability symmetric and avoids running `$SHELL -l`
62
+ * twice.
63
+ * 5. Detect EVERY previous Python `scream-cli` shim on the detection
64
+ * PATH (`detectLegacyShims`). Returns `[]` for fresh-install /
65
+ * no-op. Multiple results happen when the user has installed
66
+ * `scream-cli` through more than one Python tool (uv + pipx, or
67
+ * sudo-pip + pip-user). PATH order is preserved.
68
+ * 6. Pre-flight classify each shim (`classifyShim`) — pure
69
+ * filesystem inspection, no writes. Each shim ends up
70
+ * `renameable`, `consolidate`, `delete-only`, or `blocked`.
71
+ * 7. Decide abort vs proceed against the WHOLE set:
72
+ * `findFirstResolvableScream` walks PATH treating the actionable
73
+ * shims as gone and reports what wins:
74
+ * - `own` → proceed to execute.
75
+ * - `blocked-legacy` → a legacy we can't remove still wins.
76
+ * Surface `logMigrationBlocked` with sudo / admin
77
+ * instructions; touch nothing.
78
+ * - `foreign` → some `lm` we don't recognize (a user's own
79
+ * file) wins. Surface `logForeignScreamInTheWay` asking the
80
+ * user to delete or rename their own file; touch nothing.
81
+ * - `none` → no `lm` on PATH at all (our shim's bin dir
82
+ * isn't in the shell's PATH). Surface
83
+ * `logNewCliNotOnPath`; touch nothing.
84
+ * 8. Execute. The FIRST classification in PATH order that we can
85
+ * touch becomes `scream-legacy` (preserves what `lm` referred
86
+ * to before this install). Each subsequent shim is `unlink`ed —
87
+ * keeping it as a dormant duplicate adds no value. If the
88
+ * first shim's `scream-legacy` target is already user-managed,
89
+ * we delete `lm` anyway (still achieves takeover) and tell
90
+ * the user we couldn't preserve a fallback. Extension is
91
+ * preserved on Windows (`scream.exe` → `scream-legacy.exe`).
92
+ * 9. One end-of-orchestration notice (`logMigrationDone`)
93
+ * summarizes every action — renames, consolidates,
94
+ * delete-only, deletes, and harmless blocked leftovers. The
95
+ * takeover-success line only fires on this path because Step 7
96
+ * already certified it.
97
+ * 10. The manager completes the install with its usual summary.
98
+ * This script always exits 0; any uncaught error is swallowed
99
+ * by the top-level `catch` so the install never fails because
100
+ * of the migration.
101
+ */
102
+
103
+ import {
104
+ detectPackageManager,
105
+ findFirstResolvableScream,
106
+ isGlobalInstall,
107
+ ownPackageRoot,
108
+ postinstallPaths,
109
+ } from './postinstall/reach.mjs';
110
+ import {
111
+ classifyShim,
112
+ deleteShim,
113
+ detectLegacyShims,
114
+ renameInPlace,
115
+ } from './postinstall/migrate.mjs';
116
+ import { createDesktopShortcut } from './postinstall/shortcut.mjs';
117
+ import {
118
+ logForeignScreamInTheWay,
119
+ logMigrationBlocked,
120
+ logMigrationDone,
121
+ logNewCliNotOnPath,
122
+ notify,
123
+ } from './postinstall/ui.mjs';
124
+
125
+ async function main() {
126
+ // Step 1: skip non-global installs (npx, local project deps,
127
+ // workspace bootstraps). Windows is supported natively; the
128
+ // platform-specific bits (PATHEXT-aware PATH walk, whole-file
129
+ // marker sniff for uv's launcher .exe, extension-preserving
130
+ // rename) live in the helpers.
131
+ if (!isGlobalInstall()) return;
132
+
133
+ // Step 2: locate our own installed package root once and share it
134
+ // with both detection (skip files inside our package) and
135
+ // reachability (only count our shim as "found").
136
+ const ownRoot = await ownPackageRoot(import.meta.dirname);
137
+ const pm = detectPackageManager();
138
+
139
+ // Step 3: probe the user's shell PATH once so detection and
140
+ // reachability share a single consistent view. Detection uses the
141
+ // union of shell PATH + process PATH (so we catch a legacy shim
142
+ // visible to either); reachability uses the shell PATH alone (so
143
+ // we don't claim "scream works now" when the shim only sits in the
144
+ // installer's env).
145
+ const paths = await postinstallPaths();
146
+
147
+ // Step 4: detect EVERY previous Python `scream-cli` shim on the
148
+ // detection PATH. A user with both `uv tool install` and `pipx
149
+ // install` would have two; we must address all of them or the
150
+ // survivor still shadows the new CLI.
151
+ const detections = await detectLegacyShims(ownRoot, paths.detection);
152
+ if (detections.length === 0) return;
153
+
154
+ // Step 5: pre-flight classify every shim WITHOUT touching the
155
+ // filesystem yet. The orchestrator decides abort-or-proceed against
156
+ // the whole set rather than discovering mid-loop that we got partway
157
+ // and have to backtrack.
158
+ const classifications = await Promise.all(
159
+ detections.map(async (detection) => {
160
+ const c = await classifyShim(detection.shimPath);
161
+ return { ...c, detection };
162
+ }),
163
+ );
164
+
165
+ // Step 6: figure out what wins PATH resolution once every shim we
166
+ // CAN touch is treated as gone. Three possible blockers:
167
+ // - a legacy shim we couldn't classify as actionable (sudo/admin
168
+ // needed)
169
+ // - an unrelated `lm` we don't recognize (a user's own wrapper
170
+ // script — they own the decision)
171
+ // - nothing resolves (our shim isn't on PATH at all)
172
+ // For each we render a different notice and touch NOTHING. The
173
+ // common-case fourth result is "our shim wins" — we proceed.
174
+ const actionable = classifications.filter((c) => c.kind !== 'blocked');
175
+ const blocked = classifications.filter((c) => c.kind === 'blocked');
176
+ const actionableShimPaths = actionable.map((c) => c.shimPath);
177
+ const allDetectedShimPaths = classifications.map((c) => c.shimPath);
178
+
179
+ const blocker = await findFirstResolvableScream(
180
+ ownRoot,
181
+ paths.reachability,
182
+ actionableShimPaths,
183
+ allDetectedShimPaths,
184
+ );
185
+ if (blocker.kind !== 'own') {
186
+ if (blocker.kind === 'blocked-legacy') {
187
+ logMigrationBlocked(blocked, actionable, pm);
188
+ } else if (blocker.kind === 'foreign') {
189
+ logForeignScreamInTheWay(blocker.path, pm);
190
+ } else {
191
+ // 'none' — our shim isn't on PATH at all.
192
+ logNewCliNotOnPath(detections[0], pm);
193
+ }
194
+ return;
195
+ }
196
+
197
+ // Step 7: execute. The FIRST classification in PATH order that
198
+ // we can touch becomes `scream-legacy` (preserves what the user's
199
+ // `lm` used to refer to). Every subsequent shim is just
200
+ // deleted — keeping it as a dormant duplicate adds no value.
201
+ const renames = [];
202
+ const consolidates = [];
203
+ const skippedForeignTarget = [];
204
+ const deletes = [];
205
+ const errors = [];
206
+ let preservedFirst = false;
207
+
208
+ for (const c of classifications) {
209
+ if (c.kind === 'blocked') continue; // already established harmless
210
+
211
+ if (!preservedFirst) {
212
+ preservedFirst = true;
213
+ if (c.kind === 'renameable') {
214
+ const r = await renameInPlace(c.shimPath, c.target);
215
+ if (r.success) {
216
+ renames.push(c);
217
+ } else {
218
+ errors.push({ ...c, ...r });
219
+ }
220
+ continue;
221
+ }
222
+ if (c.kind === 'consolidate') {
223
+ const r = await deleteShim(c.shimPath);
224
+ if (r.success) {
225
+ consolidates.push(c);
226
+ } else {
227
+ errors.push({ ...c, ...r });
228
+ }
229
+ continue;
230
+ }
231
+ if (c.kind === 'delete-only') {
232
+ const r = await deleteShim(c.shimPath);
233
+ if (r.success) {
234
+ skippedForeignTarget.push(c);
235
+ } else {
236
+ errors.push({ ...c, ...r });
237
+ }
238
+ continue;
239
+ }
240
+ } else {
241
+ // Not the first actionable shim. Just delete it.
242
+ const r = await deleteShim(c.shimPath);
243
+ if (r.success) {
244
+ deletes.push(c);
245
+ } else {
246
+ errors.push({ ...c, ...r });
247
+ }
248
+ }
249
+ }
250
+
251
+ // Step 8: one notice summarizing everything that happened. The
252
+ // takeover-success language is only emitted when we know it's true
253
+ // (we already passed the reachability gate above).
254
+ logMigrationDone(
255
+ {
256
+ renames,
257
+ consolidates,
258
+ skippedForeignTarget,
259
+ deletes,
260
+ blockedHarmless: blocked,
261
+ errors,
262
+ },
263
+ pm,
264
+ );
265
+ }
266
+
267
+ try {
268
+ createDesktopShortcut();
269
+ } catch {
270
+ // Never fail the install over a shortcut.
271
+ }
272
+
273
+ main().catch((err) => {
274
+ const message = err instanceof Error ? err.message : String(err);
275
+ notify(`[lmcode] postinstall warning: ${message}`);
276
+ });