myshell-tools 2.8.0 → 2.10.0

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.
@@ -56,3 +56,31 @@ export declare function applyStoredCredentials(env: NodeJS.ProcessEnv, homeDir?:
56
56
  * extractClaudeToken('no token here') // → null
57
57
  */
58
58
  export declare function extractClaudeToken(text: string): string | null;
59
+ /**
60
+ * Strip surrounding whitespace and enclosing `"` or `'` quotes from a pasted
61
+ * string. Useful for normalising user-pasted tokens before extraction.
62
+ *
63
+ * Pure / never throws.
64
+ *
65
+ * @example
66
+ * stripPastedSecretWrapper('" sk-ant-oat01-abc "') // → 'sk-ant-oat01-abc'
67
+ * stripPastedSecretWrapper("'token'") // → 'token'
68
+ * stripPastedSecretWrapper(' plain ') // → 'plain'
69
+ */
70
+ export declare function stripPastedSecretWrapper(raw: string): string;
71
+ /**
72
+ * Classify a pasted secret string into one of three categories:
73
+ *
74
+ * - `'oauth-token'` — starts with `sk-ant-oat` (the expected setup-token output).
75
+ * - `'api-key'` — starts with `sk-ant-api` (a raw Anthropic API key, NOT what we want).
76
+ * - `'none'` — neither; blank or unrecognised.
77
+ *
78
+ * Input is pre-normalised (trimmed, quotes stripped) by the caller.
79
+ * Pure / never throws.
80
+ *
81
+ * @example
82
+ * classifyPastedSecret('sk-ant-oat01-abc-XYZ') // → 'oauth-token'
83
+ * classifyPastedSecret('sk-ant-api03-abc-XYZ') // → 'api-key'
84
+ * classifyPastedSecret('not-a-token') // → 'none'
85
+ */
86
+ export declare function classifyPastedSecret(s: string): 'oauth-token' | 'api-key' | 'none';
@@ -146,7 +146,7 @@ export async function applyStoredCredentials(env, homeDir) {
146
146
  }
147
147
  }
148
148
  // ---------------------------------------------------------------------------
149
- // Pure token extraction helper
149
+ // Pure token extraction and classification helpers
150
150
  // ---------------------------------------------------------------------------
151
151
  /**
152
152
  * Extract the first Claude long-lived OAuth token from `text`.
@@ -169,4 +169,55 @@ export function extractClaudeToken(text) {
169
169
  return null;
170
170
  }
171
171
  }
172
+ /**
173
+ * Strip surrounding whitespace and enclosing `"` or `'` quotes from a pasted
174
+ * string. Useful for normalising user-pasted tokens before extraction.
175
+ *
176
+ * Pure / never throws.
177
+ *
178
+ * @example
179
+ * stripPastedSecretWrapper('" sk-ant-oat01-abc "') // → 'sk-ant-oat01-abc'
180
+ * stripPastedSecretWrapper("'token'") // → 'token'
181
+ * stripPastedSecretWrapper(' plain ') // → 'plain'
182
+ */
183
+ export function stripPastedSecretWrapper(raw) {
184
+ try {
185
+ let s = raw.trim();
186
+ if ((s.startsWith('"') && s.endsWith('"')) ||
187
+ (s.startsWith("'") && s.endsWith("'"))) {
188
+ s = s.slice(1, -1).trim();
189
+ }
190
+ return s;
191
+ }
192
+ catch {
193
+ return raw;
194
+ }
195
+ }
196
+ /**
197
+ * Classify a pasted secret string into one of three categories:
198
+ *
199
+ * - `'oauth-token'` — starts with `sk-ant-oat` (the expected setup-token output).
200
+ * - `'api-key'` — starts with `sk-ant-api` (a raw Anthropic API key, NOT what we want).
201
+ * - `'none'` — neither; blank or unrecognised.
202
+ *
203
+ * Input is pre-normalised (trimmed, quotes stripped) by the caller.
204
+ * Pure / never throws.
205
+ *
206
+ * @example
207
+ * classifyPastedSecret('sk-ant-oat01-abc-XYZ') // → 'oauth-token'
208
+ * classifyPastedSecret('sk-ant-api03-abc-XYZ') // → 'api-key'
209
+ * classifyPastedSecret('not-a-token') // → 'none'
210
+ */
211
+ export function classifyPastedSecret(s) {
212
+ try {
213
+ if (s.includes('sk-ant-oat'))
214
+ return 'oauth-token';
215
+ if (s.includes('sk-ant-api'))
216
+ return 'api-key';
217
+ return 'none';
218
+ }
219
+ catch {
220
+ return 'none';
221
+ }
222
+ }
172
223
  //# sourceMappingURL=credentials.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"credentials.js","sourceRoot":"","sources":["../../src/infra/credentials.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"credentials.js","sourceRoot":"","sources":["../../src/infra/credentials.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,67 @@
1
+ /**
2
+ * src/infra/update-check.ts — Self-update detection for myshell-tools.
3
+ *
4
+ * Checks the npm registry for the latest published version, caches the result
5
+ * so the check costs nothing on subsequent runs within the TTL, and reports
6
+ * whether an update is available.
7
+ *
8
+ * Architecture rules:
9
+ * - NEVER throws — all errors are caught and return a safe default.
10
+ * - NEVER hits the network in tests — `fetchLatest` is injected so tests stay hermetic.
11
+ * - Cache is stored atomically at ~/.myshell-tools/update-check.json.
12
+ * - Date.now() is allowed here (infra layer, same as config.ts / atomic.ts).
13
+ */
14
+ export interface UpdateCheckResult {
15
+ readonly current: string;
16
+ readonly latest: string | null;
17
+ readonly updateAvailable: boolean;
18
+ }
19
+ interface UpdateCache {
20
+ checkedAt: number;
21
+ latest: string;
22
+ }
23
+ /**
24
+ * Load the update cache from disk. Returns null on missing/corrupt file.
25
+ * Never throws.
26
+ */
27
+ export declare function loadUpdateCache(homeDir?: string): Promise<UpdateCache | null>;
28
+ /**
29
+ * Persist the update cache atomically. Creates the .myshell-tools directory
30
+ * if it does not exist. Never throws.
31
+ */
32
+ export declare function saveUpdateCache(latest: string, now: number, homeDir?: string): Promise<void>;
33
+ /**
34
+ * Compare two semver-ish version strings.
35
+ *
36
+ * Splits on `.`, compares numeric major/minor/patch segments in order.
37
+ * Any `-prerelease` suffix is stripped before comparison.
38
+ * Non-numeric segments are treated as 0.
39
+ * Returns true iff latest > current.
40
+ * Never throws.
41
+ */
42
+ export declare function isNewerVersion(latest: string, current: string): boolean;
43
+ /**
44
+ * Fetch the latest published version of myshell-tools from the npm registry.
45
+ *
46
+ * Uses global fetch with a 1500ms AbortSignal timeout.
47
+ * Returns the version string, or null on any error/timeout.
48
+ * Never throws.
49
+ */
50
+ export declare function fetchLatestFromNpm(): Promise<string | null>;
51
+ export interface CheckForUpdateOpts {
52
+ readonly currentVersion: string;
53
+ readonly now: number;
54
+ readonly homeDir?: string;
55
+ readonly ttlMs?: number;
56
+ readonly fetchLatest?: () => Promise<string | null>;
57
+ }
58
+ /**
59
+ * Check whether a newer version of myshell-tools is available.
60
+ *
61
+ * Uses a cache file so the npm registry is only contacted once per TTL (default 24h).
62
+ * If the cache is fresh, the registry is NOT contacted at all.
63
+ * On any error returns { current, latest: null, updateAvailable: false }.
64
+ * Never throws.
65
+ */
66
+ export declare function checkForUpdate(opts: CheckForUpdateOpts): Promise<UpdateCheckResult>;
67
+ export {};
@@ -0,0 +1,181 @@
1
+ /**
2
+ * src/infra/update-check.ts — Self-update detection for myshell-tools.
3
+ *
4
+ * Checks the npm registry for the latest published version, caches the result
5
+ * so the check costs nothing on subsequent runs within the TTL, and reports
6
+ * whether an update is available.
7
+ *
8
+ * Architecture rules:
9
+ * - NEVER throws — all errors are caught and return a safe default.
10
+ * - NEVER hits the network in tests — `fetchLatest` is injected so tests stay hermetic.
11
+ * - Cache is stored atomically at ~/.myshell-tools/update-check.json.
12
+ * - Date.now() is allowed here (infra layer, same as config.ts / atomic.ts).
13
+ */
14
+ import { mkdir, readFile } from 'node:fs/promises';
15
+ import { homedir } from 'node:os';
16
+ import { join } from 'node:path';
17
+ import { atomicWrite } from './atomic.js';
18
+ // ---------------------------------------------------------------------------
19
+ // Constants
20
+ // ---------------------------------------------------------------------------
21
+ const TTL_MS_DEFAULT = 24 * 60 * 60 * 1000; // 24 hours
22
+ const FETCH_TIMEOUT_MS = 1_500;
23
+ // ---------------------------------------------------------------------------
24
+ // Path helpers
25
+ // ---------------------------------------------------------------------------
26
+ function getCacheDir(homeDir) {
27
+ return join(homeDir, '.myshell-tools');
28
+ }
29
+ function getCachePath(homeDir) {
30
+ return join(getCacheDir(homeDir), 'update-check.json');
31
+ }
32
+ // ---------------------------------------------------------------------------
33
+ // Cache helpers
34
+ // ---------------------------------------------------------------------------
35
+ /**
36
+ * Load the update cache from disk. Returns null on missing/corrupt file.
37
+ * Never throws.
38
+ */
39
+ export async function loadUpdateCache(homeDir) {
40
+ const home = homeDir ?? homedir();
41
+ try {
42
+ const raw = await readFile(getCachePath(home), 'utf8');
43
+ const parsed = JSON.parse(raw);
44
+ if (parsed !== null &&
45
+ typeof parsed === 'object' &&
46
+ 'checkedAt' in parsed &&
47
+ 'latest' in parsed &&
48
+ typeof parsed['checkedAt'] === 'number' &&
49
+ typeof parsed['latest'] === 'string') {
50
+ return parsed;
51
+ }
52
+ return null;
53
+ }
54
+ catch {
55
+ return null;
56
+ }
57
+ }
58
+ /**
59
+ * Persist the update cache atomically. Creates the .myshell-tools directory
60
+ * if it does not exist. Never throws.
61
+ */
62
+ export async function saveUpdateCache(latest, now, homeDir) {
63
+ const home = homeDir ?? homedir();
64
+ try {
65
+ await mkdir(getCacheDir(home), { recursive: true });
66
+ const cache = { checkedAt: now, latest };
67
+ await atomicWrite(getCachePath(home), JSON.stringify(cache, null, 2));
68
+ }
69
+ catch {
70
+ // Silently ignore — failing to cache is not a fatal error.
71
+ }
72
+ }
73
+ // ---------------------------------------------------------------------------
74
+ // Version comparison
75
+ // ---------------------------------------------------------------------------
76
+ /**
77
+ * Compare two semver-ish version strings.
78
+ *
79
+ * Splits on `.`, compares numeric major/minor/patch segments in order.
80
+ * Any `-prerelease` suffix is stripped before comparison.
81
+ * Non-numeric segments are treated as 0.
82
+ * Returns true iff latest > current.
83
+ * Never throws.
84
+ */
85
+ export function isNewerVersion(latest, current) {
86
+ try {
87
+ const parse = (v) => {
88
+ // Strip any prerelease suffix (e.g. "1.2.3-beta.1" → "1.2.3")
89
+ const base = v.split('-')[0] ?? '';
90
+ return base.split('.').map((part) => {
91
+ const n = parseInt(part, 10);
92
+ return Number.isFinite(n) ? n : 0;
93
+ });
94
+ };
95
+ const lParts = parse(latest);
96
+ const cParts = parse(current);
97
+ const len = Math.max(lParts.length, cParts.length);
98
+ for (let i = 0; i < len; i++) {
99
+ const l = lParts[i] ?? 0;
100
+ const c = cParts[i] ?? 0;
101
+ if (l > c)
102
+ return true;
103
+ if (l < c)
104
+ return false;
105
+ }
106
+ return false; // equal
107
+ }
108
+ catch {
109
+ return false;
110
+ }
111
+ }
112
+ // ---------------------------------------------------------------------------
113
+ // npm registry fetch (real network — injected in tests)
114
+ // ---------------------------------------------------------------------------
115
+ /**
116
+ * Fetch the latest published version of myshell-tools from the npm registry.
117
+ *
118
+ * Uses global fetch with a 1500ms AbortSignal timeout.
119
+ * Returns the version string, or null on any error/timeout.
120
+ * Never throws.
121
+ */
122
+ export async function fetchLatestFromNpm() {
123
+ const ac = new AbortController();
124
+ const timer = setTimeout(() => { ac.abort(); }, FETCH_TIMEOUT_MS);
125
+ try {
126
+ const res = await fetch('https://registry.npmjs.org/myshell-tools/latest', {
127
+ signal: ac.signal,
128
+ });
129
+ if (!res.ok)
130
+ return null;
131
+ const data = await res.json();
132
+ if (data !== null &&
133
+ typeof data === 'object' &&
134
+ 'version' in data &&
135
+ typeof data['version'] === 'string') {
136
+ return data['version'] ?? null;
137
+ }
138
+ return null;
139
+ }
140
+ catch {
141
+ return null;
142
+ }
143
+ finally {
144
+ clearTimeout(timer);
145
+ }
146
+ }
147
+ /**
148
+ * Check whether a newer version of myshell-tools is available.
149
+ *
150
+ * Uses a cache file so the npm registry is only contacted once per TTL (default 24h).
151
+ * If the cache is fresh, the registry is NOT contacted at all.
152
+ * On any error returns { current, latest: null, updateAvailable: false }.
153
+ * Never throws.
154
+ */
155
+ export async function checkForUpdate(opts) {
156
+ const { currentVersion, now, homeDir, ttlMs = TTL_MS_DEFAULT } = opts;
157
+ const fetchFn = opts.fetchLatest ?? fetchLatestFromNpm;
158
+ try {
159
+ // Check if we have a fresh cache
160
+ const cache = await loadUpdateCache(homeDir);
161
+ if (cache !== null && now - cache.checkedAt < ttlMs) {
162
+ // Cache is fresh — use it without hitting the network
163
+ const updateAvailable = isNewerVersion(cache.latest, currentVersion);
164
+ return { current: currentVersion, latest: cache.latest, updateAvailable };
165
+ }
166
+ // Cache is stale or missing — fetch from the registry
167
+ const latest = await fetchFn();
168
+ if (latest !== null) {
169
+ await saveUpdateCache(latest, now, homeDir);
170
+ }
171
+ if (latest === null) {
172
+ return { current: currentVersion, latest: null, updateAvailable: false };
173
+ }
174
+ const updateAvailable = isNewerVersion(latest, currentVersion);
175
+ return { current: currentVersion, latest, updateAvailable };
176
+ }
177
+ catch {
178
+ return { current: currentVersion, latest: null, updateAvailable: false };
179
+ }
180
+ }
181
+ //# sourceMappingURL=update-check.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"update-check.js","sourceRoot":"","sources":["../../src/infra/update-check.ts"],"names":[],"mappings":"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"}
@@ -23,6 +23,7 @@ import type { EnvironmentStatus } from '../providers/detect.js';
23
23
  import type { Provider, ProviderId, SandboxLevel } from '../providers/port.js';
24
24
  import type { OutputSink } from './render.js';
25
25
  import type { LoginMethod } from '../commands/login.js';
26
+ import type { UpdateCheckResult } from '../infra/update-check.js';
26
27
  export interface MenuContext {
27
28
  readonly version: string;
28
29
  readonly clock: Clock;
@@ -67,6 +68,30 @@ export interface MenuContext {
67
68
  * spawning during tests (e.g. after first-run onboarding or [j]/[k]/[o] login).
68
69
  */
69
70
  readonly detectEnvironment?: () => Promise<EnvironmentStatus>;
71
+ /**
72
+ * Optional injected update-check for testing. When provided, `startMenu` uses
73
+ * this instead of the real `checkForUpdate` from infra/update-check.ts,
74
+ * preventing real npm registry requests from being made during tests.
75
+ *
76
+ * Returns the update check result (current, latest, updateAvailable).
77
+ */
78
+ readonly checkForUpdate?: () => Promise<UpdateCheckResult>;
79
+ /**
80
+ * Optional injected self-update function for testing. When provided, `startMenu`
81
+ * uses this instead of the real `npm install -g myshell-tools@latest` subprocess.
82
+ *
83
+ * Returns true when the update succeeded (exit code 0), false otherwise.
84
+ * Never throws.
85
+ */
86
+ readonly updateSelf?: (out: OutputSink) => Promise<boolean>;
87
+ /**
88
+ * Optional injected relaunch function for testing. When provided, `startMenu`
89
+ * uses this instead of the real `execa('myshell-tools', …)` re-exec.
90
+ *
91
+ * Returns the exit code of the relaunched process (or 1 on spawn failure).
92
+ * Used only for the opt-in auto-update path.
93
+ */
94
+ readonly relaunch?: () => Promise<number>;
70
95
  }
71
96
  /**
72
97
  * Parse a yes/no answer from a raw input line, with a configurable default.
@@ -322,10 +322,15 @@ async function runWelcome(ctx, out, readLine, mutableConfig, installProviderFn,
322
322
  out.write('Set myshell-tools as your default shell tool? (y/N) ');
323
323
  const defaultAns = await readLine();
324
324
  const setAsDefault = parseYesNo(defaultAns, false);
325
+ // Default is NO for auto-update — require explicit 'y' to enable.
326
+ out.write('Keep myshell-tools up to date automatically? (y/N) ');
327
+ const autoUpdateAns = await readLine();
328
+ const autoUpdate = parseYesNo(autoUpdateAns, false);
325
329
  const saved = {
326
330
  onboarded: true,
327
331
  setAsDefault,
328
332
  ...(updated.mode !== undefined ? { mode: updated.mode } : {}),
333
+ ...(autoUpdate ? { autoUpdate: true } : {}),
329
334
  };
330
335
  await saveConfig(saved);
331
336
  // When the user opts in, actually write the shell startup hook (real install,
@@ -393,6 +398,7 @@ async function runSettings(_ctx, mutableCtx, out, readLine) {
393
398
  '',
394
399
  ` [1] Mode: ${cfg.mode ?? 'balanced'}`,
395
400
  ` [2] Set as default shell: ${cfg.setAsDefault ? 'on' : 'off'}`,
401
+ ` [3] Auto-update: ${cfg.autoUpdate === true ? 'on' : 'off'}`,
396
402
  '',
397
403
  ' [Enter] Back',
398
404
  '',
@@ -409,8 +415,27 @@ async function runSettings(_ctx, mutableCtx, out, readLine) {
409
415
  else if (key === '2') {
410
416
  mutableCtx.config = await toggleDefaultShell(mutableCtx.config, out);
411
417
  }
418
+ else if (key === '3') {
419
+ mutableCtx.config = await toggleAutoUpdate(mutableCtx.config, out);
420
+ }
412
421
  // anything else → back
413
422
  }
423
+ /**
424
+ * Toggle the auto-update preference and persist the updated config.
425
+ * Reports the new state so the user knows what changed.
426
+ */
427
+ async function toggleAutoUpdate(config, out) {
428
+ const enable = config.autoUpdate !== true;
429
+ const updated = {
430
+ onboarded: config.onboarded,
431
+ setAsDefault: config.setAsDefault,
432
+ ...(config.mode !== undefined ? { mode: config.mode } : {}),
433
+ ...(enable ? { autoUpdate: true } : {}),
434
+ };
435
+ await saveConfig(updated);
436
+ out.write(`Auto-update: ${enable ? 'on' : 'off'}\n`);
437
+ return updated;
438
+ }
414
439
  // ---------------------------------------------------------------------------
415
440
  // Manage conversations screen
416
441
  // ---------------------------------------------------------------------------
@@ -693,11 +718,15 @@ async function runChatLoop(ctx, mutableCtx, convId, out, readLine) {
693
718
  // ---------------------------------------------------------------------------
694
719
  // Main screen render
695
720
  // ---------------------------------------------------------------------------
696
- async function renderMainScreen(ctx, mutableCtx, metas, out) {
721
+ async function renderMainScreen(ctx, mutableCtx, metas, out, updateInfo) {
697
722
  out.write('\n');
698
723
  // Header box — always box(), 🧠 emoji, real provider data
699
724
  const headerLines = renderHeaderLines(mutableCtx.env, ctx.version);
700
725
  out.write(box(`🧠 myshell-tools v${ctx.version}`, headerLines) + '\n\n');
726
+ // Update banner — only shown when a newer version is genuinely available
727
+ if (updateInfo?.updateAvailable === true && updateInfo.latest !== null) {
728
+ out.write(` ▲ Update available: ${updateInfo.current} → ${updateInfo.latest} (press u)\n\n`);
729
+ }
701
730
  // Budget line — real ledger data, never fabricated
702
731
  const entries = await readLedger(ctx.cwd);
703
732
  const spend = summarizeSpend(entries, ctx.clock.isoNow());
@@ -727,6 +756,10 @@ async function renderMainScreen(ctx, mutableCtx, metas, out) {
727
756
  { key: 'k', label: 'Login Codex', section: 'Auth' },
728
757
  { key: 'o', label: opencodeLabel, section: 'Auth' },
729
758
  ];
759
+ // [u] Update now — shown only when a newer version is actually available
760
+ const updateEntry = updateInfo?.updateAvailable === true && updateInfo.latest !== null
761
+ ? [{ key: 'u', label: `Update now (→ ${updateInfo.latest})`, section: 'Options' }]
762
+ : [];
730
763
  // Menu — sectioned via menu()
731
764
  out.write(menu([
732
765
  { key: 'c', label: 'Continue last conversation', section: 'Conversations' },
@@ -739,6 +772,7 @@ async function renderMainScreen(ctx, mutableCtx, metas, out) {
739
772
  { key: 's', label: 'Settings', section: 'Options' },
740
773
  { key: 'd', label: 'Doctor', section: 'Options' },
741
774
  { key: '$', label: 'Cost', section: 'Options' },
775
+ ...updateEntry,
742
776
  { key: 'q', label: 'Quit', section: 'Options' },
743
777
  ]) + '\n\n');
744
778
  }
@@ -766,6 +800,9 @@ export async function startMenu(ctx, out) {
766
800
  const installProviderFn = ctx.installProvider !== undefined ? ctx.installProvider : installProvider;
767
801
  const loginFn = ctx.login !== undefined ? ctx.login : runLogin;
768
802
  const detectEnvironmentFn = ctx.detectEnvironment !== undefined ? ctx.detectEnvironment : detectEnvironment;
803
+ const checkForUpdateFn = ctx.checkForUpdate;
804
+ const updateSelfFn = ctx.updateSelf;
805
+ const relaunchFn = ctx.relaunch;
769
806
  // Build the readLine function — either injected (for tests) or backed by a
770
807
  // real readline interface driven by the event-driven LineReader queue.
771
808
  let readLine;
@@ -803,10 +840,33 @@ export async function startMenu(ctx, out) {
803
840
  // status (e.g. codex now "ready" if the user signed in during setup).
804
841
  mutableCtx.env = await detectEnvironmentFn();
805
842
  }
843
+ // ---- Update check (once per launch, after onboarding) --------------------
844
+ // Fast path: uses the cache when fresh, so it never hangs.
845
+ // Injected seam allows tests to stay hermetic (no real npm registry calls).
846
+ let updateInfo;
847
+ if (checkForUpdateFn !== undefined) {
848
+ updateInfo = await checkForUpdateFn().catch(() => undefined);
849
+ }
850
+ // ---- Opt-in auto-update at launch ----------------------------------------
851
+ // Guard: only runs once; requires both the update and relaunch seams to be wired.
852
+ if (mutableCtx.config.autoUpdate === true &&
853
+ updateInfo?.updateAvailable === true &&
854
+ updateInfo.latest !== null &&
855
+ updateSelfFn !== undefined) {
856
+ out.write(`▲ Auto-updating ${updateInfo.current} → ${updateInfo.latest}…\n`);
857
+ const ok = await updateSelfFn(out).catch(() => false);
858
+ if (ok) {
859
+ if (relaunchFn !== undefined) {
860
+ await relaunchFn().catch(() => 1);
861
+ }
862
+ return; // Relinquish control to the freshly-installed version.
863
+ }
864
+ out.write('Auto-update failed — continuing with current version.\n');
865
+ }
806
866
  // ---- B. Main screen loop -------------------------------------------------
807
867
  while (true) {
808
868
  const metas = await ctx.store.list();
809
- await renderMainScreen(ctx, mutableCtx, metas, out);
869
+ await renderMainScreen(ctx, mutableCtx, metas, out, updateInfo);
810
870
  out.write('> ');
811
871
  const key = await readLine();
812
872
  // ---- EOF / close — exit gracefully (FIX 1: no ERR_USE_AFTER_CLOSE) ----
@@ -908,6 +968,18 @@ export async function startMenu(ctx, out) {
908
968
  mutableCtx.env = await detectEnvironmentFn();
909
969
  continue;
910
970
  }
971
+ // ---- [u] Update now -----------------------------------------------------
972
+ // Only active when an update is actually available and the seam is wired.
973
+ if (key === 'u' && updateInfo?.updateAvailable === true && updateSelfFn !== undefined) {
974
+ const ok = await updateSelfFn(out).catch(() => false);
975
+ if (ok && updateInfo.latest !== null) {
976
+ out.write(`✓ Updated to ${updateInfo.latest} — restart myshell-tools to use it.\n`);
977
+ }
978
+ else if (!ok) {
979
+ out.write('Update failed. Run: npm install -g myshell-tools@latest\n');
980
+ }
981
+ continue;
982
+ }
911
983
  // ---- [s] Settings -------------------------------------------------------
912
984
  if (key === 's') {
913
985
  await runSettings(ctx, mutableCtx, out, readLine);