@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,458 @@
1
+ /**
2
+ * User-facing output for the postinstall: where lines go, ANSI styling,
3
+ * the fixed-width box layout, and the four outcome renderers:
4
+ *
5
+ * - `logMigrationDone` — the takeover succeeded (one or more legacy
6
+ * shims were processed). Lists every action taken: renames,
7
+ * consolidates, delete-only, plain deletes, and harmless blocked
8
+ * leftovers. Footer branches three ways: preserved-somewhere
9
+ * (standard "type scream-legacy"), only skippedForeignTarget (we
10
+ * couldn't save the old CLI because a user file took the name),
11
+ * and only blockedHarmless (just notes the leftovers, no
12
+ * phantom-file talk).
13
+ * - `logMigrationBlocked` — a legacy `lm` we can't remove sits
14
+ * on PATH ahead of our shim. Nothing was touched; user is told
15
+ * which paths need their manual attention with sudo / admin.
16
+ * - `logForeignScreamInTheWay` — a `lm` we don't recognize (not
17
+ * ours, not a legacy CLI) sits ahead of our shim on PATH. User
18
+ * needs to delete or rename their own file. Different remediation
19
+ * from `logMigrationBlocked`.
20
+ * - `logNewCliNotOnPath` — we found a legacy but our own shim
21
+ * isn't on the user's shell PATH at all. Same "touch nothing"
22
+ * behavior, different prose.
23
+ *
24
+ * This module is intentionally self-contained: no PATH walking, no fs
25
+ * mutations, no shell spawning — just rendering. The orchestrator
26
+ * (`postinstall.mjs`) makes the abort-or-proceed decision once and
27
+ * calls exactly one renderer at the end.
28
+ */
29
+
30
+ import { writeFileSync } from 'node:fs';
31
+
32
+ import { pmGlobalInstallCommand, pmGlobalBinCommand } from './reach.mjs';
33
+
34
+ // Fixed-width box rendering. 80 cols is the de facto terminal default.
35
+ // We can't reliably read TTY width from a piped postinstall context, so
36
+ // we pin the width and truncate long content with an ellipsis if needed.
37
+ const BOX_WIDTH = 80;
38
+ const BOX_INNER = BOX_WIDTH - 2; // chars between the two vertical borders
39
+ const BOX_PAD_LEFT = 2; // leading spaces inside the box for breathing room
40
+
41
+ // ANSI styling. Disabled when NO_COLOR is set (https://no-color.org/).
42
+ // We can't reliably tell whether `/dev/tty`'s far end supports color,
43
+ // but modern terminals all do; users who want plain output can set
44
+ // NO_COLOR.
45
+ const USE_COLOR = !process.env['NO_COLOR'];
46
+ const ANSI_ESCAPE = /\x1b\[[0-9;]*[a-zA-Z]/g;
47
+ const C_RESET = USE_COLOR ? '\x1b[0m' : '';
48
+ const C_DIM = USE_COLOR ? '\x1b[2m' : '';
49
+ const C_BOLD_GREEN = USE_COLOR ? '\x1b[1;32m' : '';
50
+ const C_BOLD_YELLOW = USE_COLOR ? '\x1b[1;33m' : '';
51
+ const C_CYAN = USE_COLOR ? '\x1b[36m' : '';
52
+
53
+ function color(c, text) {
54
+ return USE_COLOR ? c + text + C_RESET : text;
55
+ }
56
+
57
+ function visibleLength(s) {
58
+ return s.replace(ANSI_ESCAPE, '').length;
59
+ }
60
+
61
+ function stripAnsi(s) {
62
+ return s.replace(ANSI_ESCAPE, '');
63
+ }
64
+
65
+ // Platform-specific path to the controlling terminal device. Writing
66
+ // here bypasses the package manager's lifecycle stdout capture (npm 7+
67
+ // hides script stdout/stderr by default). On POSIX it's `/dev/tty`;
68
+ // on Windows it's the special filename `CON`, which Node resolves to
69
+ // the console device. (The fully-qualified `\\.\CON` form looks
70
+ // equivalent but Node appends a trailing backslash that breaks the
71
+ // open call — confirmed empirically on Windows 11 / Node 22.)
72
+ const TERMINAL_DEVICE = process.platform === 'win32' ? 'CON' : '/dev/tty';
73
+
74
+ /**
75
+ * Print a user-facing line. npm 7+ captures lifecycle stdout/stderr by
76
+ * default, so messages here would be invisible to a user running
77
+ * `npm install -g`. Writing directly to the platform's terminal
78
+ * device bypasses the manager's capture when one is available
79
+ * (interactive terminals). In CI / non-TTY contexts the device isn't
80
+ * writable; fall back to stdout so the message is still preserved in
81
+ * npm's lifecycle log under `~/.npm/_logs/`, with ANSI stripped so
82
+ * the log file stays readable.
83
+ */
84
+ export function notify(line) {
85
+ const text = line + '\n';
86
+ try {
87
+ writeFileSync(TERMINAL_DEVICE, text);
88
+ return;
89
+ } catch {
90
+ // Terminal device not writable (CI, sandboxed environments).
91
+ }
92
+ process.stdout.write(stripAnsi(text));
93
+ }
94
+
95
+ // Single-quote `path` for safe interpolation in a POSIX `sh` command.
96
+ // Wraps in single quotes and escapes any embedded `'` as `'\''`.
97
+ function quotePosixPath(path) {
98
+ return "'" + path.replace(/'/g, "'\\''") + "'";
99
+ }
100
+
101
+ // Single-quote `path` for safe interpolation in a PowerShell command.
102
+ // PowerShell single-quoted strings disable expansion; embedded `'` is
103
+ // escaped by doubling.
104
+ function quotePowerShellPath(path) {
105
+ return "'" + path.replace(/'/g, "''") + "'";
106
+ }
107
+
108
+ function boxBorder(left, right, fill = '─') {
109
+ return color(C_DIM, left + fill.repeat(BOX_INNER) + right);
110
+ }
111
+
112
+ function boxLine(content = '') {
113
+ const visible = visibleLength(content);
114
+ const padding =
115
+ visible < BOX_INNER ? ' '.repeat(BOX_INNER - visible) : '';
116
+ return color(C_DIM, '│') + content + padding + color(C_DIM, '│');
117
+ }
118
+
119
+ function pad(content) {
120
+ return ' '.repeat(BOX_PAD_LEFT) + content;
121
+ }
122
+
123
+ function renderBox(lines) {
124
+ const out = [boxBorder('╭', '╮'), boxLine('')];
125
+ for (const line of lines) out.push(boxLine(line));
126
+ out.push(boxLine(''), boxBorder('╰', '╯'));
127
+ return out;
128
+ }
129
+
130
+ function emit(lines) {
131
+ notify('');
132
+ for (const line of lines) notify(line);
133
+ notify('');
134
+ }
135
+
136
+ function pathInBox(path) {
137
+ // 7-space lead = box pad (2) + prose indent (3) + nesting under
138
+ // label (2). We intentionally do NOT truncate overflowing content:
139
+ // for command lines (`sudo rm <path>`, `mv <a> <b>`), left-truncation
140
+ // would swallow the command verb and leave the user with
141
+ // un-copy-pasteable instructions. Long lines just overflow the box
142
+ // border, which is visually less pretty but keeps the content
143
+ // intact.
144
+ const lead = ' '.repeat(BOX_PAD_LEFT + 5);
145
+ return lead + color(C_CYAN, path);
146
+ }
147
+
148
+ function successHeading(text) {
149
+ return pad(color(C_BOLD_GREEN, '✓ ' + text));
150
+ }
151
+
152
+ function warningHeading(text) {
153
+ return pad(color(C_BOLD_YELLOW, '! ' + text));
154
+ }
155
+
156
+ /**
157
+ * The takeover completed. Renders one box that lists every action
158
+ * taken, so the user sees a single coherent picture even when several
159
+ * shims were involved.
160
+ *
161
+ * Sections (each only shown when non-empty):
162
+ * - "Renamed" — the first PATH-order shim, preserved as
163
+ * `scream-legacy`. The "`lm` now launches the new CLI" claim is
164
+ * safe to make here because the orchestrator already verified
165
+ * reachability after this set of removals.
166
+ * - "Consolidated" — first shim's `scream-legacy` already pointed at
167
+ * a legacy file (re-migration case); we deleted the duplicate
168
+ * source and kept the existing target. Same end state, different
169
+ * mechanism.
170
+ * - "Couldn't preserve as scream-legacy" — first shim's `scream-legacy`
171
+ * slot was a user-managed file; we deleted the source `lm` to
172
+ * remove the shadow but left their file alone, so no fallback
173
+ * exists in that dir.
174
+ * - "Also removed" — non-first PATH-order shims that would have
175
+ * shadowed our new shim. Just `unlink`ed.
176
+ * - "Note: legacy left behind" — blocked shims that couldn't be
177
+ * removed but PATH order means they don't shadow us; the user
178
+ * can clean them up at leisure.
179
+ * - "Errors" — anything that failed during execution despite
180
+ * pre-flight saying it should work (race conditions, transient
181
+ * fs errors). Listed last so the user can see what to retry.
182
+ */
183
+ export function logMigrationDone(outcomes, pm) {
184
+ const reinstallCmd = pmGlobalInstallCommand(pm, '@lmcode-cli/lmcode');
185
+ const {
186
+ renames,
187
+ consolidates,
188
+ skippedForeignTarget,
189
+ deletes,
190
+ blockedHarmless,
191
+ errors,
192
+ } = outcomes;
193
+
194
+ const lines = [successHeading('scream now runs the new version'), ''];
195
+
196
+ if (renames.length > 0) {
197
+ lines.push(pad(' Renamed your old lmcode so you can still run it as'));
198
+ lines.push(pad(' scream-legacy:'));
199
+ for (const c of renames) {
200
+ lines.push(pathInBox(c.shimPath + ' -> ' + c.target));
201
+ }
202
+ lines.push('');
203
+ }
204
+
205
+ if (consolidates.length > 0) {
206
+ lines.push(pad(' Removed an extra copy of your old lmcode (scream-legacy'));
207
+ lines.push(pad(' was already set up here from before):'));
208
+ for (const c of consolidates) {
209
+ lines.push(pathInBox(c.shimPath));
210
+ lines.push(pathInBox(' (scream-legacy is at ' + c.target + ')'));
211
+ }
212
+ lines.push('');
213
+ }
214
+
215
+ if (skippedForeignTarget.length > 0) {
216
+ lines.push(pad(' Removed your old lmcode (a file you created was already'));
217
+ lines.push(pad(' using the name scream-legacy, so we left it alone):'));
218
+ for (const c of skippedForeignTarget) {
219
+ lines.push(pathInBox(c.shimPath));
220
+ lines.push(pathInBox(' (your file at ' + c.target + ' is untouched)'));
221
+ }
222
+ lines.push('');
223
+ }
224
+
225
+ if (deletes.length > 0) {
226
+ lines.push(pad(' Also removed (these would have run instead of the'));
227
+ lines.push(pad(' new lmcode if we left them):'));
228
+ for (const c of deletes) {
229
+ lines.push(pathInBox(c.shimPath));
230
+ }
231
+ lines.push('');
232
+ }
233
+
234
+ if (blockedHarmless.length > 0) {
235
+ lines.push(pad(' Note: we can\'t change these files, but it\'s OK —'));
236
+ lines.push(pad(' they won\'t run instead of the new lmcode:'));
237
+ for (const c of blockedHarmless) {
238
+ lines.push(pathInBox(c.shimPath));
239
+ }
240
+ lines.push('');
241
+ }
242
+
243
+ if (errors.length > 0) {
244
+ lines.push(pad(' Some changes didn\'t go through:'));
245
+ for (const e of errors) {
246
+ lines.push(
247
+ pathInBox(e.shimPath + ' (' + (e.message ?? e.code ?? 'error') + ')'),
248
+ );
249
+ }
250
+ lines.push('');
251
+ }
252
+
253
+ // Footer has three branches based on what actually happened:
254
+ //
255
+ // 1. Preserved somewhere (rename or consolidate): the old CLI is
256
+ // available as `scream-legacy`. Standard takeover footer.
257
+ // 2. Only skippedForeignTarget (no rename, no consolidate, no
258
+ // blockedHarmless): user has a file they made called
259
+ // `scream-legacy`, so we couldn't save the old CLI under that
260
+ // name. Explain the situation honestly.
261
+ // 3. Only blockedHarmless (no rename, no consolidate, no
262
+ // skippedForeignTarget): we have nothing to celebrate or
263
+ // apologize for — the "Note: we can't change these files but
264
+ // it's OK" section above already covers it. Plain footer.
265
+ //
266
+ // If both skippedForeignTarget AND blockedHarmless are present
267
+ // (but no preservation), branch 2 wins — the foreign-target story
268
+ // is the more useful one for the user to know about.
269
+ const preservedSomewhere = renames.length > 0 || consolidates.length > 0;
270
+ if (preservedSomewhere) {
271
+ lines.push(
272
+ pad(' Now typing `lm` runs the new version. To run the old'),
273
+ pad(' version, type `scream-legacy` instead. Your settings from'),
274
+ pad(' the old version will be moved over the first time you'),
275
+ pad(' run `lm`.'),
276
+ '',
277
+ pad(' Note: if you reinstall the old lmcode later (e.g. with'),
278
+ pad(' `uv tool`, `pip`, or `pipx`), it will put `lm` back.'),
279
+ pad(' Run this command again to switch to the new one:'),
280
+ pathInBox(reinstallCmd),
281
+ '',
282
+ pad(' If typing `lm` still runs the old version, open a new'),
283
+ pad(' terminal window — your current one may have remembered'),
284
+ pad(' the old path.'),
285
+ );
286
+ } else if (skippedForeignTarget.length > 0) {
287
+ lines.push(
288
+ pad(' Now typing `lm` runs the new version. Your settings'),
289
+ pad(' from the old version will be moved over the first time'),
290
+ pad(' you run `lm`.'),
291
+ '',
292
+ pad(' We couldn\'t save the old lmcode as `scream-legacy` because'),
293
+ pad(' that name was already taken by a file you\'d created.'),
294
+ pad(' If you need the old lmcode back, install it again with'),
295
+ pad(' `uv tool install scream-cli` (or pipx / pip).'),
296
+ '',
297
+ pad(' If typing `lm` still runs the old version, open a new'),
298
+ pad(' terminal window — your current one may have remembered'),
299
+ pad(' the old path.'),
300
+ );
301
+ } else {
302
+ lines.push(
303
+ pad(' Now typing `lm` runs the new version. Your settings'),
304
+ pad(' from the old version will be moved over the first time'),
305
+ pad(' you run `lm`.'),
306
+ '',
307
+ pad(' If typing `lm` still runs the old version, open a new'),
308
+ pad(' terminal window — your current one may have remembered'),
309
+ pad(' the old path.'),
310
+ );
311
+ }
312
+
313
+ emit(renderBox(lines));
314
+ }
315
+
316
+ /**
317
+ * At least one blocked legacy shim still sits on PATH ahead of where
318
+ * our new shim would land. We refused to touch anything (pre-flight
319
+ * abort), so neither the user's existing setup nor the new install
320
+ * gets a half-migrated state. List each blocking path with the
321
+ * platform-appropriate manual fix.
322
+ */
323
+ export function logMigrationBlocked(blocked, actionable, pm) {
324
+ const isWindows = process.platform === 'win32';
325
+ const reinstallCmd = pmGlobalInstallCommand(pm, '@lmcode-cli/lmcode');
326
+
327
+ const lines = [
328
+ warningHeading('Can\'t switch to the new lmcode yet'),
329
+ '',
330
+ pad(' There\'s an old lmcode on your computer that we can\'t change.'),
331
+ pad(' As long as it\'s there, typing `lm` will still run the old'),
332
+ pad(' version. Files we can\'t change:'),
333
+ ];
334
+
335
+ for (const c of blocked) {
336
+ lines.push(pathInBox(c.shimPath));
337
+ }
338
+
339
+ lines.push('', pad(' Please delete them yourself, then install again:'));
340
+
341
+ for (const c of blocked) {
342
+ if (isWindows && c.isSystemPath) {
343
+ // Admin PowerShell needed.
344
+ lines.push(pathInBox('# in an elevated PowerShell:'));
345
+ lines.push(pathInBox('Remove-Item ' + quotePowerShellPath(c.shimPath)));
346
+ } else if (c.isSystemPath) {
347
+ lines.push(pathInBox('sudo rm ' + quotePosixPath(c.shimPath)));
348
+ } else if (isWindows) {
349
+ lines.push(pathInBox('Remove-Item ' + quotePowerShellPath(c.shimPath)));
350
+ } else {
351
+ lines.push(pathInBox('rm ' + quotePosixPath(c.shimPath)));
352
+ }
353
+ }
354
+
355
+ if (actionable.length > 0) {
356
+ lines.push(
357
+ '',
358
+ pad(' We also found these old lmcode files. We could remove them'),
359
+ pad(' ourselves, once the ones above are gone:'),
360
+ );
361
+ for (const c of actionable) {
362
+ lines.push(pathInBox(c.shimPath));
363
+ }
364
+ }
365
+
366
+ lines.push(
367
+ '',
368
+ pad(' After deleting them, install again to finish:'),
369
+ pathInBox(reinstallCmd),
370
+ '',
371
+ pad(' Nothing on your computer was changed.'),
372
+ );
373
+
374
+ emit(renderBox(lines));
375
+ }
376
+
377
+ /**
378
+ * The reachability check found a `lm` ahead of our shim on PATH
379
+ * that's neither ours nor a legacy Python CLI — almost certainly a
380
+ * wrapper the user wrote themselves (or installed from somewhere we
381
+ * don't recognize). Deleting blocked legacy shims wouldn't help
382
+ * here: the foreign file would still win resolution. So the
383
+ * remediation is "delete or rename your own file", which only the
384
+ * user can decide.
385
+ */
386
+ export function logForeignScreamInTheWay(foreignPath, pm) {
387
+ const reinstallCmd = pmGlobalInstallCommand(pm, '@lmcode-cli/lmcode');
388
+ emit(
389
+ renderBox([
390
+ warningHeading('Can\'t switch to the new lmcode yet'),
391
+ '',
392
+ pad(' There\'s another file called `lm` on your computer that\'s'),
393
+ pad(' not the new CLI and not the old one — it looks like'),
394
+ pad(' something you set up yourself. As long as it\'s there,'),
395
+ pad(' typing `lm` will run it instead of the new version.'),
396
+ '',
397
+ pad(' We found it at:'),
398
+ pathInBox(foreignPath),
399
+ '',
400
+ pad(' To use the new lmcode, delete or rename that file, then'),
401
+ pad(' install again:'),
402
+ pathInBox(reinstallCmd),
403
+ '',
404
+ pad(' Nothing on your computer was changed.'),
405
+ ]),
406
+ );
407
+ }
408
+
409
+ /**
410
+ * The legacy `lm` was found, but the directory where the package
411
+ * manager placed the new `lm` shim is not on the user's PATH.
412
+ * Renaming the legacy shim now would leave the user with NO reachable
413
+ * `lm` command — the new one would still not be discoverable by
414
+ * their shell. Show them the PATH fix and leave the legacy CLI alone.
415
+ *
416
+ * The PATH-fix hint uses the manager-specific subshell command (one
417
+ * of `npm prefix -g`, `yarn global bin`, `pnpm bin -g`) so it works
418
+ * regardless of which manager the user ran, and renders in the
419
+ * syntax of the user's likely shell:
420
+ * - POSIX : `export PATH=...`.
421
+ * - Windows: `$env:Path = ...` (PowerShell).
422
+ * On Windows, npm places global shims directly under `<prefix>` (no
423
+ * `bin` subdir), and pnpm/yarn already report the bin dir, so we
424
+ * skip the `/bin` suffix the POSIX branch needs for npm.
425
+ */
426
+ export function logNewCliNotOnPath(detection, pm) {
427
+ const isWindows = process.platform === 'win32';
428
+ const binCmd = pmGlobalBinCommand(pm);
429
+ const reinstallCmd = pmGlobalInstallCommand(pm, '@lmcode-cli/lmcode');
430
+
431
+ const newPathHint = isWindows
432
+ ? `$env:Path = "$(${binCmd});$env:Path"`
433
+ : pm === 'npm'
434
+ ? `export PATH="$(${binCmd})/bin:$PATH"`
435
+ : `export PATH="$(${binCmd}):$PATH"`;
436
+ const rcLabel = isWindows ? 'PowerShell profile' : 'shell rc';
437
+
438
+ emit(
439
+ renderBox([
440
+ warningHeading('New scream is installed, but your terminal can\'t find it'),
441
+ '',
442
+ pad(' The old lmcode is still here:'),
443
+ pathInBox(detection.shimPath),
444
+ '',
445
+ pad(' The new lmcode was installed by ' + pm + ', but it landed in a'),
446
+ pad(' folder your terminal doesn\'t search. (Your terminal looks'),
447
+ pad(' for commands in folders listed in your PATH.) If we removed'),
448
+ pad(' the old lmcode now, typing `lm` wouldn\'t find anything.'),
449
+ '',
450
+ pad(' Add the new lmcode\'s folder to your PATH (and save the change'),
451
+ pad(' in your ' + rcLabel + ' so it sticks), then install again:'),
452
+ pathInBox(newPathHint),
453
+ pathInBox(reinstallCmd),
454
+ '',
455
+ pad(' The old lmcode is still where it was.'),
456
+ ]),
457
+ );
458
+ }