mu-coding 0.10.0 → 0.11.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.
- package/package.json +4 -4
- package/src/app/startApp.ts +6 -0
- package/src/cli/args.ts +2 -0
- package/src/cli/subcommands.ts +9 -0
- package/src/cli/update.ts +205 -0
- package/src/runtime/startupUpdateCheck.ts +150 -0
- package/src/runtime/updateCheck.ts +136 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mu-coding",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"description": "Minimal terminal AI assistant for local models",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -24,9 +24,9 @@
|
|
|
24
24
|
},
|
|
25
25
|
"dependencies": {
|
|
26
26
|
"ink": "^7.0.1",
|
|
27
|
-
"mu-agents": "0.
|
|
28
|
-
"mu-core": "0.
|
|
29
|
-
"mu-openai-provider": "0.
|
|
27
|
+
"mu-agents": "0.11.0",
|
|
28
|
+
"mu-core": "0.11.0",
|
|
29
|
+
"mu-openai-provider": "0.11.0",
|
|
30
30
|
"react": "^19.2.5"
|
|
31
31
|
}
|
|
32
32
|
}
|
package/src/app/startApp.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { parseArgs, resolveInitialMessages } from '../cli/args';
|
|
|
3
3
|
import { handleSubcommand } from '../cli/subcommands';
|
|
4
4
|
import { loadConfig } from '../config/index';
|
|
5
5
|
import { createRegistry } from '../runtime/createRegistry';
|
|
6
|
+
import { checkForUpdatesInBackground } from '../runtime/startupUpdateCheck';
|
|
6
7
|
import { InkUIService } from '../tui/plugins/InkUIService';
|
|
7
8
|
import { registerShutdown } from './shutdown';
|
|
8
9
|
|
|
@@ -29,6 +30,11 @@ async function runApp(): Promise<void> {
|
|
|
29
30
|
});
|
|
30
31
|
registryRef = registry;
|
|
31
32
|
|
|
33
|
+
// Fire-and-forget npm registry probe — surfaces a toast via uiService.notify
|
|
34
|
+
// if mu or an installed npm plugin has a newer version. Cached for 24h to
|
|
35
|
+
// avoid hammering the registry; disable with MU_NO_UPDATE_CHECK=1.
|
|
36
|
+
void checkForUpdatesInBackground(uiService);
|
|
37
|
+
|
|
32
38
|
// The TUI is registered as a `Channel` by `createCodingPlugin`. Starting
|
|
33
39
|
// it mounts Ink with the same options that were captured at activation
|
|
34
40
|
// time (config, initialMessages, registry, messageBus, uiService, shutdown).
|
package/src/cli/args.ts
CHANGED
|
@@ -21,6 +21,8 @@ Usage:
|
|
|
21
21
|
mu --session <path> Resume a specific session file
|
|
22
22
|
mu install npm:<package> Install a plugin from npm
|
|
23
23
|
mu uninstall npm:<package> Remove an installed plugin
|
|
24
|
+
mu update [plugins|self|all] Update plugins and/or mu (default: all)
|
|
25
|
+
mu outdated [plugins|self] List available updates without applying
|
|
24
26
|
mu -v, --version Print version and exit
|
|
25
27
|
mu -h, --help Show this help
|
|
26
28
|
|
package/src/cli/subcommands.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { runInstall, runUninstall } from './install';
|
|
2
|
+
import { runOutdated, runUpdate } from './update';
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Handle CLI subcommands that run before the TUI.
|
|
@@ -14,6 +15,14 @@ export async function handleSubcommand(): Promise<boolean> {
|
|
|
14
15
|
case 'uninstall':
|
|
15
16
|
await runUninstall(process.argv.slice(3));
|
|
16
17
|
return true;
|
|
18
|
+
case 'update':
|
|
19
|
+
case 'upgrade':
|
|
20
|
+
await runUpdate(process.argv.slice(3));
|
|
21
|
+
return true;
|
|
22
|
+
case 'outdated':
|
|
23
|
+
case 'ping':
|
|
24
|
+
await runOutdated(process.argv.slice(3));
|
|
25
|
+
return true;
|
|
17
26
|
default:
|
|
18
27
|
return false;
|
|
19
28
|
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `mu update` — bring installed plugins (and optionally mu itself) to the
|
|
3
|
+
* latest version published on the npm registry.
|
|
4
|
+
*
|
|
5
|
+
* Plugins live in `~/.local/share/mu/node_modules/<name>` and are listed in
|
|
6
|
+
* `config.plugins` as `npm:<name>` specifiers. Updating them is a thin
|
|
7
|
+
* wrapper around `bun update --latest <name>` against the data dir, which
|
|
8
|
+
* already owns its own `package.json`.
|
|
9
|
+
*
|
|
10
|
+
* mu itself is updated by re-running its global installer. We probe the
|
|
11
|
+
* binary path to detect which manager owns the install (bun, npm, pnpm) and
|
|
12
|
+
* fall back to a generic `npm i -g` instructions message on unknown setups.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { execFileSync } from 'node:child_process';
|
|
16
|
+
import { realpathSync } from 'node:fs';
|
|
17
|
+
import {
|
|
18
|
+
listConfiguredNpmPlugins,
|
|
19
|
+
type NpmRegistryView,
|
|
20
|
+
PACKAGE_NAME,
|
|
21
|
+
probePluginSync,
|
|
22
|
+
probeSelfSync,
|
|
23
|
+
} from '../runtime/updateCheck';
|
|
24
|
+
import { ensureDataDir } from './install';
|
|
25
|
+
|
|
26
|
+
function printRow(name: string, view: NpmRegistryView | null) {
|
|
27
|
+
if (!view) {
|
|
28
|
+
console.log(` ${name.padEnd(28)} ? (npm view failed)`);
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
const arrow = view.hasUpdate ? '→' : '=';
|
|
32
|
+
const tail = view.hasUpdate ? `${view.current} ${arrow} ${view.latest}` : `${view.current} (up to date)`;
|
|
33
|
+
console.log(` ${name.padEnd(28)} ${tail}`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ─── outdated ────────────────────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
export async function runOutdated(args: string[]): Promise<void> {
|
|
39
|
+
const scope = args[0];
|
|
40
|
+
const wantsPlugins = !scope || scope === 'plugins' || scope === 'all';
|
|
41
|
+
const wantsSelf = !scope || scope === 'self' || scope === 'mu' || scope === 'all';
|
|
42
|
+
|
|
43
|
+
const dataDir = ensureDataDir();
|
|
44
|
+
let anyUpdate = false;
|
|
45
|
+
|
|
46
|
+
if (wantsSelf) {
|
|
47
|
+
console.log('mu:');
|
|
48
|
+
const view = probeSelfSync();
|
|
49
|
+
printRow(PACKAGE_NAME, view);
|
|
50
|
+
if (view?.hasUpdate) anyUpdate = true;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (wantsPlugins) {
|
|
54
|
+
const names = listConfiguredNpmPlugins();
|
|
55
|
+
console.log(`\nplugins (${names.length}):`);
|
|
56
|
+
if (names.length === 0) {
|
|
57
|
+
console.log(' (none configured)');
|
|
58
|
+
} else {
|
|
59
|
+
for (const name of names) {
|
|
60
|
+
const view = probePluginSync(name, dataDir);
|
|
61
|
+
printRow(name, view);
|
|
62
|
+
if (view?.hasUpdate) anyUpdate = true;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (!anyUpdate) {
|
|
68
|
+
console.log('\nEverything is up to date.');
|
|
69
|
+
} else {
|
|
70
|
+
console.log("\nRun 'mu update' to apply.");
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ─── update plugins ──────────────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
function updatePlugin(name: string, dataDir: string): boolean {
|
|
77
|
+
try {
|
|
78
|
+
execFileSync('bun', ['update', '--latest', name], { cwd: dataDir, stdio: 'inherit' });
|
|
79
|
+
return true;
|
|
80
|
+
} catch (err) {
|
|
81
|
+
console.error(`Failed to update ${name}: ${err instanceof Error ? err.message : err}`);
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function runUpdatePlugins(): Promise<{ ok: number; failed: number }> {
|
|
87
|
+
const dataDir = ensureDataDir();
|
|
88
|
+
const names = listConfiguredNpmPlugins();
|
|
89
|
+
if (names.length === 0) {
|
|
90
|
+
console.log('No npm plugins configured.');
|
|
91
|
+
return { ok: 0, failed: 0 };
|
|
92
|
+
}
|
|
93
|
+
let ok = 0;
|
|
94
|
+
let failed = 0;
|
|
95
|
+
for (const name of names) {
|
|
96
|
+
console.log(`\nUpdating ${name}…`);
|
|
97
|
+
if (updatePlugin(name, dataDir)) {
|
|
98
|
+
ok += 1;
|
|
99
|
+
console.log(`✓ ${name}`);
|
|
100
|
+
} else {
|
|
101
|
+
failed += 1;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return { ok, failed };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ─── update self ─────────────────────────────────────────────────────────────
|
|
108
|
+
|
|
109
|
+
interface SelfInstallStrategy {
|
|
110
|
+
manager: 'bun' | 'npm' | 'pnpm' | 'yarn' | 'unknown';
|
|
111
|
+
command?: [string, string[]];
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Best-effort detection of which package manager installed `mu` globally.
|
|
116
|
+
* We resolve the absolute binary path of the running process and look for
|
|
117
|
+
* tell-tale path segments.
|
|
118
|
+
*/
|
|
119
|
+
function detectSelfInstall(): SelfInstallStrategy {
|
|
120
|
+
let bin = process.argv[1] ?? '';
|
|
121
|
+
try {
|
|
122
|
+
bin = realpathSync(bin);
|
|
123
|
+
} catch {
|
|
124
|
+
// keep raw argv path
|
|
125
|
+
}
|
|
126
|
+
const norm = bin.replace(/\\/g, '/');
|
|
127
|
+
|
|
128
|
+
if (norm.includes('/.bun/') || norm.includes('/bun/install/')) {
|
|
129
|
+
return { manager: 'bun', command: ['bun', ['add', '-g', `${PACKAGE_NAME}@latest`]] };
|
|
130
|
+
}
|
|
131
|
+
if (norm.includes('/pnpm/')) {
|
|
132
|
+
return { manager: 'pnpm', command: ['pnpm', ['add', '-g', `${PACKAGE_NAME}@latest`]] };
|
|
133
|
+
}
|
|
134
|
+
if (norm.includes('/.yarn/') || norm.includes('/yarn/global/')) {
|
|
135
|
+
return { manager: 'yarn', command: ['yarn', ['global', 'add', `${PACKAGE_NAME}@latest`]] };
|
|
136
|
+
}
|
|
137
|
+
if (norm.includes('/npm/') || norm.includes('node_modules/.bin/mu')) {
|
|
138
|
+
return { manager: 'npm', command: ['npm', ['i', '-g', `${PACKAGE_NAME}@latest`]] };
|
|
139
|
+
}
|
|
140
|
+
return { manager: 'unknown' };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export async function runUpdateSelf(): Promise<boolean> {
|
|
144
|
+
const view = probeSelfSync();
|
|
145
|
+
if (view && !view.hasUpdate) {
|
|
146
|
+
console.log(`mu is already up to date (${view.current}).`);
|
|
147
|
+
return true;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const strategy = detectSelfInstall();
|
|
151
|
+
if (!strategy.command) {
|
|
152
|
+
console.log(
|
|
153
|
+
[
|
|
154
|
+
'Could not auto-detect how mu was installed.',
|
|
155
|
+
'Re-install with one of:',
|
|
156
|
+
` bun add -g ${PACKAGE_NAME}@latest`,
|
|
157
|
+
` npm i -g ${PACKAGE_NAME}@latest`,
|
|
158
|
+
` pnpm add -g ${PACKAGE_NAME}@latest`,
|
|
159
|
+
].join('\n'),
|
|
160
|
+
);
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const [bin, args] = strategy.command;
|
|
165
|
+
console.log(`Updating mu via ${strategy.manager}…`);
|
|
166
|
+
console.log(`$ ${bin} ${args.join(' ')}`);
|
|
167
|
+
try {
|
|
168
|
+
execFileSync(bin, args, { stdio: 'inherit' });
|
|
169
|
+
console.log('✓ mu updated. Restart any running mu sessions.');
|
|
170
|
+
return true;
|
|
171
|
+
} catch (err) {
|
|
172
|
+
console.error(`Failed to update mu: ${err instanceof Error ? err.message : err}`);
|
|
173
|
+
return false;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ─── dispatcher ──────────────────────────────────────────────────────────────
|
|
178
|
+
|
|
179
|
+
export async function runUpdate(args: string[]): Promise<void> {
|
|
180
|
+
const scope = args[0];
|
|
181
|
+
|
|
182
|
+
if (scope === 'plugins') {
|
|
183
|
+
const { failed } = await runUpdatePlugins();
|
|
184
|
+
if (failed > 0) process.exit(1);
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (scope === 'self' || scope === 'mu') {
|
|
189
|
+
const ok = await runUpdateSelf();
|
|
190
|
+
if (!ok) process.exit(1);
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (scope && scope !== 'all') {
|
|
195
|
+
console.error('Usage: mu update [plugins|self|all]');
|
|
196
|
+
process.exit(1);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Default: update everything.
|
|
200
|
+
console.log('=== Updating plugins ===');
|
|
201
|
+
const pluginRes = await runUpdatePlugins();
|
|
202
|
+
console.log('\n=== Updating mu ===');
|
|
203
|
+
const selfOk = await runUpdateSelf();
|
|
204
|
+
if (pluginRes.failed > 0 || !selfOk) process.exit(1);
|
|
205
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Startup update check — fires off `npm view` probes in the background after
|
|
3
|
+
* the TUI is up, then surfaces a toast through `uiService.notify` when mu or
|
|
4
|
+
* any installed npm plugin has a newer version on the registry.
|
|
5
|
+
*
|
|
6
|
+
* Design constraints:
|
|
7
|
+
* - Must never block startup. Caller fire-and-forgets the returned promise.
|
|
8
|
+
* - Must never crash the host on network / DNS errors. Every probe is wrapped.
|
|
9
|
+
* - Must not hammer npm on every boot. Results are cached in
|
|
10
|
+
* `<cacheDir>/update-check.json` for `CACHE_TTL_MS` (24h).
|
|
11
|
+
* - Disable with `MU_NO_UPDATE_CHECK=1` (kill switch for offline / CI / tests).
|
|
12
|
+
*
|
|
13
|
+
* Toasts are routed through the existing `InkUIService.notify` queue, which
|
|
14
|
+
* buffers messages emitted before any toast listener attaches — so even if
|
|
15
|
+
* the probe finishes before the TUI mounts (rare; see fast `npm view` runs)
|
|
16
|
+
* the alert still surfaces.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
20
|
+
import { join } from 'node:path';
|
|
21
|
+
import { getCacheDir, getDataDir } from '../config/index';
|
|
22
|
+
import type { InkUIService } from '../tui/plugins/InkUIService';
|
|
23
|
+
import {
|
|
24
|
+
listConfiguredNpmPlugins,
|
|
25
|
+
type NpmRegistryView,
|
|
26
|
+
PACKAGE_NAME,
|
|
27
|
+
probePluginAsync,
|
|
28
|
+
probeSelfAsync,
|
|
29
|
+
} from './updateCheck';
|
|
30
|
+
|
|
31
|
+
const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 h
|
|
32
|
+
const CACHE_FILENAME = 'update-check.json';
|
|
33
|
+
|
|
34
|
+
interface CacheShape {
|
|
35
|
+
ts: number;
|
|
36
|
+
results: Record<string, NpmRegistryView | null>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function cachePath(): string {
|
|
40
|
+
return join(getCacheDir(), CACHE_FILENAME);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function readCache(): CacheShape | null {
|
|
44
|
+
try {
|
|
45
|
+
const raw = readFileSync(cachePath(), 'utf-8');
|
|
46
|
+
const parsed = JSON.parse(raw) as CacheShape;
|
|
47
|
+
if (typeof parsed?.ts !== 'number' || typeof parsed?.results !== 'object') return null;
|
|
48
|
+
if (Date.now() - parsed.ts > CACHE_TTL_MS) return null;
|
|
49
|
+
return parsed;
|
|
50
|
+
} catch {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function writeCache(cache: CacheShape): void {
|
|
56
|
+
try {
|
|
57
|
+
mkdirSync(getCacheDir(), { recursive: true });
|
|
58
|
+
writeFileSync(cachePath(), JSON.stringify(cache), 'utf-8');
|
|
59
|
+
} catch {
|
|
60
|
+
// Cache writes are best-effort; ignore disk errors.
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function isDisabled(): boolean {
|
|
65
|
+
const v = process.env.MU_NO_UPDATE_CHECK;
|
|
66
|
+
return v === '1' || v === 'true' || v === 'yes';
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
interface ProbeOutcome {
|
|
70
|
+
self: NpmRegistryView | null;
|
|
71
|
+
plugins: Map<string, NpmRegistryView | null>;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function runProbes(): Promise<ProbeOutcome> {
|
|
75
|
+
const dataDir = getDataDir();
|
|
76
|
+
const pluginNames = listConfiguredNpmPlugins();
|
|
77
|
+
|
|
78
|
+
const [self, ...plugins] = await Promise.all([
|
|
79
|
+
probeSelfAsync().catch(() => null),
|
|
80
|
+
...pluginNames.map((name) => probePluginAsync(name, dataDir).catch(() => null)),
|
|
81
|
+
]);
|
|
82
|
+
|
|
83
|
+
const pluginMap = new Map<string, NpmRegistryView | null>();
|
|
84
|
+
pluginNames.forEach((name, i) => {
|
|
85
|
+
pluginMap.set(name, plugins[i] ?? null);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
return { self, plugins: pluginMap };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function outcomeFromCache(cache: CacheShape, pluginNames: string[]): ProbeOutcome {
|
|
92
|
+
const self = cache.results[PACKAGE_NAME] ?? null;
|
|
93
|
+
const plugins = new Map<string, NpmRegistryView | null>();
|
|
94
|
+
for (const name of pluginNames) {
|
|
95
|
+
plugins.set(name, cache.results[name] ?? null);
|
|
96
|
+
}
|
|
97
|
+
return { self, plugins };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function outcomeToCache(outcome: ProbeOutcome): CacheShape {
|
|
101
|
+
const results: Record<string, NpmRegistryView | null> = {};
|
|
102
|
+
results[PACKAGE_NAME] = outcome.self;
|
|
103
|
+
for (const [name, view] of outcome.plugins) {
|
|
104
|
+
results[name] = view;
|
|
105
|
+
}
|
|
106
|
+
return { ts: Date.now(), results };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function notifyOutcome(outcome: ProbeOutcome, ui: InkUIService): void {
|
|
110
|
+
const stale: string[] = [];
|
|
111
|
+
if (outcome.self?.hasUpdate) {
|
|
112
|
+
stale.push(`mu ${outcome.self.current} → ${outcome.self.latest}`);
|
|
113
|
+
}
|
|
114
|
+
for (const [name, view] of outcome.plugins) {
|
|
115
|
+
if (view?.hasUpdate) {
|
|
116
|
+
stale.push(`${name} ${view.current} → ${view.latest}`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
if (stale.length === 0) return;
|
|
120
|
+
|
|
121
|
+
const header = stale.length === 1 ? 'Update available' : `${stale.length} updates available`;
|
|
122
|
+
const body = stale.join(', ');
|
|
123
|
+
ui.notify(`${header}: ${body}. Run \`mu update\` to apply.`, 'info');
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Background entry point. Resolves once the probe (or cache lookup) has
|
|
128
|
+
* completed and any toast has been emitted. Callers should fire-and-forget.
|
|
129
|
+
*/
|
|
130
|
+
export async function checkForUpdatesInBackground(ui: InkUIService): Promise<void> {
|
|
131
|
+
if (isDisabled()) return;
|
|
132
|
+
|
|
133
|
+
const pluginNames = listConfiguredNpmPlugins();
|
|
134
|
+
|
|
135
|
+
const cached = readCache();
|
|
136
|
+
if (cached) {
|
|
137
|
+
notifyOutcome(outcomeFromCache(cached, pluginNames), ui);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
let outcome: ProbeOutcome;
|
|
142
|
+
try {
|
|
143
|
+
outcome = await runProbes();
|
|
144
|
+
} catch {
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
writeCache(outcomeToCache(outcome));
|
|
149
|
+
notifyOutcome(outcome, ui);
|
|
150
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared "is there a newer version?" probes used by both the `mu update` /
|
|
3
|
+
* `mu outdated` CLI subcommands (synchronous, blocking) and the in-TUI
|
|
4
|
+
* startup alert (asynchronous, cached). Pure & side-effect free except for
|
|
5
|
+
* the actual `npm view` exec — no toast / IO concerns live here.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { execFile, execFileSync } from 'node:child_process';
|
|
9
|
+
import { readFileSync } from 'node:fs';
|
|
10
|
+
import { dirname, join } from 'node:path';
|
|
11
|
+
import { fileURLToPath } from 'node:url';
|
|
12
|
+
import { promisify } from 'node:util';
|
|
13
|
+
import { loadConfig, parseBareNpmSpec } from '../config/index';
|
|
14
|
+
|
|
15
|
+
const execFileAsync = promisify(execFile);
|
|
16
|
+
|
|
17
|
+
export const PACKAGE_NAME = 'mu-coding';
|
|
18
|
+
|
|
19
|
+
export interface NpmRegistryView {
|
|
20
|
+
current: string;
|
|
21
|
+
latest: string;
|
|
22
|
+
hasUpdate: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function npmViewLatestSync(name: string): string | undefined {
|
|
26
|
+
try {
|
|
27
|
+
const out = execFileSync('npm', ['view', name, 'version'], {
|
|
28
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
29
|
+
encoding: 'utf-8',
|
|
30
|
+
});
|
|
31
|
+
return out.trim() || undefined;
|
|
32
|
+
} catch {
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function npmViewLatest(name: string, timeoutMs = 8000): Promise<string | undefined> {
|
|
38
|
+
try {
|
|
39
|
+
const { stdout } = await execFileAsync('npm', ['view', name, 'version'], {
|
|
40
|
+
timeout: timeoutMs,
|
|
41
|
+
encoding: 'utf-8',
|
|
42
|
+
});
|
|
43
|
+
return stdout.trim() || undefined;
|
|
44
|
+
} catch {
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function readInstalledVersion(name: string, cwd: string): string | undefined {
|
|
50
|
+
try {
|
|
51
|
+
const path = join(cwd, 'node_modules', name, 'package.json');
|
|
52
|
+
const pkg = JSON.parse(readFileSync(path, 'utf-8'));
|
|
53
|
+
return typeof pkg.version === 'string' ? pkg.version : undefined;
|
|
54
|
+
} catch {
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function readSelfVersion(): string | undefined {
|
|
60
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
61
|
+
const candidates = [
|
|
62
|
+
join(here, '..', '..', 'package.json'),
|
|
63
|
+
join(here, '..', 'package.json'),
|
|
64
|
+
join(here, '..', '..', '..', 'package.json'),
|
|
65
|
+
];
|
|
66
|
+
for (const path of candidates) {
|
|
67
|
+
try {
|
|
68
|
+
const pkg = JSON.parse(readFileSync(path, 'utf-8'));
|
|
69
|
+
if (pkg?.name === PACKAGE_NAME && typeof pkg.version === 'string') return pkg.version;
|
|
70
|
+
} catch {
|
|
71
|
+
// try next
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function listConfiguredNpmPlugins(): string[] {
|
|
78
|
+
const config = loadConfig();
|
|
79
|
+
const out: string[] = [];
|
|
80
|
+
for (const entry of config.plugins ?? []) {
|
|
81
|
+
const spec = typeof entry === 'string' ? entry : entry.name;
|
|
82
|
+
if (!spec.startsWith('npm:')) continue;
|
|
83
|
+
const { name } = parseBareNpmSpec(spec.slice(4));
|
|
84
|
+
out.push(name);
|
|
85
|
+
}
|
|
86
|
+
return out;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Best-effort "is `latest` newer than `current`?". Splits on `.+-` and
|
|
91
|
+
* compares numeric segments left-to-right. Returns `true` when `current` is
|
|
92
|
+
* unknown so a missing local install reads as "needs install / update".
|
|
93
|
+
*/
|
|
94
|
+
export function isVersionNewer(current: string | undefined, latest: string): boolean {
|
|
95
|
+
if (!current) return true;
|
|
96
|
+
if (current === latest) return false;
|
|
97
|
+
const cur = current.split(/[.+-]/).map((p) => Number.parseInt(p, 10));
|
|
98
|
+
const lat = latest.split(/[.+-]/).map((p) => Number.parseInt(p, 10));
|
|
99
|
+
const len = Math.max(cur.length, lat.length);
|
|
100
|
+
for (let i = 0; i < len; i++) {
|
|
101
|
+
const a = cur[i];
|
|
102
|
+
const b = lat[i];
|
|
103
|
+
if (Number.isNaN(a ?? Number.NaN) || Number.isNaN(b ?? Number.NaN)) return current !== latest;
|
|
104
|
+
if ((a ?? 0) < (b ?? 0)) return true;
|
|
105
|
+
if ((a ?? 0) > (b ?? 0)) return false;
|
|
106
|
+
}
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function probePluginSync(name: string, dataDir: string): NpmRegistryView | null {
|
|
111
|
+
const latest = npmViewLatestSync(name);
|
|
112
|
+
if (!latest) return null;
|
|
113
|
+
const current = readInstalledVersion(name, dataDir);
|
|
114
|
+
return { current: current ?? '(not installed)', latest, hasUpdate: isVersionNewer(current, latest) };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function probeSelfSync(): NpmRegistryView | null {
|
|
118
|
+
const latest = npmViewLatestSync(PACKAGE_NAME);
|
|
119
|
+
if (!latest) return null;
|
|
120
|
+
const current = readSelfVersion();
|
|
121
|
+
return { current: current ?? '(unknown)', latest, hasUpdate: isVersionNewer(current, latest) };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export async function probePluginAsync(name: string, dataDir: string): Promise<NpmRegistryView | null> {
|
|
125
|
+
const latest = await npmViewLatest(name);
|
|
126
|
+
if (!latest) return null;
|
|
127
|
+
const current = readInstalledVersion(name, dataDir);
|
|
128
|
+
return { current: current ?? '(not installed)', latest, hasUpdate: isVersionNewer(current, latest) };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export async function probeSelfAsync(): Promise<NpmRegistryView | null> {
|
|
132
|
+
const latest = await npmViewLatest(PACKAGE_NAME);
|
|
133
|
+
if (!latest) return null;
|
|
134
|
+
const current = readSelfVersion();
|
|
135
|
+
return { current: current ?? '(unknown)', latest, hasUpdate: isVersionNewer(current, latest) };
|
|
136
|
+
}
|