nexrall-code 0.5.107 → 0.5.109

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,393 @@
1
+ import chalk from 'chalk';
2
+ import prompts from 'prompts';
3
+ import { installPlugin, removePlugin, updatePlugin, readReceipt, loadPlugins, searchRegistry, getRegistryPlugin, reportInstall, inspectPluginDir, loadDeclaredSources, resolveSourceAlias, writeDeclaredSource, removeDeclaredSource, sourcesFileFor, allowedSourcePatterns, isSourceAllowed, parsePluginSource, } from '@nexrall/code-core';
4
+ // ─── nex plugin <install|remove|update|list> ─────────────────────────────────
5
+ //
6
+ // GitHub-first plugin distribution (no registry server):
7
+ // nex plugin install owner/repo
8
+ // nex plugin install owner/repo#v1.0.0
9
+ // nex plugin install owner/repo/plugins/foo
10
+ // nex plugin install https://github.com/owner/repo
11
+ // nex plugin install ./local/dir
12
+ //
13
+ // SAFETY: plugins that ship hooks.json or mcp.json can execute arbitrary
14
+ // commands on this machine. We surface a loud warning and require explicit
15
+ // confirmation before completing such installs (unless --yes).
16
+ function describeInspection(i, name, ctx) {
17
+ console.log();
18
+ console.log(chalk.bold(` Plugin: ${name}`) + (i.meta.version ? chalk.dim(` v${i.meta.version}`) : ''));
19
+ if (i.meta.description)
20
+ console.log(chalk.dim(` ${i.meta.description}`));
21
+ // Show WHICH code this is. On an update the change of commit is the single most
22
+ // decision-relevant fact: the same trusted name can point at wholly different
23
+ // contents, and "updating plugin X" alone hides that entirely.
24
+ if (ctx?.sha) {
25
+ const short = (s) => s.slice(0, 7);
26
+ if (ctx.previousSha && ctx.previousSha !== ctx.sha) {
27
+ console.log(chalk.dim(' commit: ') + chalk.yellow(`${short(ctx.previousSha)} → ${short(ctx.sha)}`));
28
+ }
29
+ else if (ctx.previousSha === ctx.sha) {
30
+ console.log(chalk.dim(` commit: ${short(ctx.sha)} (already up to date)`));
31
+ }
32
+ else {
33
+ console.log(chalk.dim(` commit: ${short(ctx.sha)}`));
34
+ }
35
+ }
36
+ else if (ctx?.isUpdate) {
37
+ console.log(chalk.dim(' commit: unknown (could not reach the GitHub API)'));
38
+ }
39
+ if (i.commands.length)
40
+ console.log(` ${chalk.cyan('commands:')} ${i.commands.map((c) => '/' + c).join(', ')}`);
41
+ if (i.agents.length)
42
+ console.log(` ${chalk.cyan('agents:')} ${i.agents.join(', ')}`);
43
+ if (i.hasHooks)
44
+ console.log(` ${chalk.yellow('hooks:')} yes`);
45
+ if (i.hasMcp)
46
+ console.log(` ${chalk.yellow('mcp:')} yes`);
47
+ }
48
+ async function confirmInstall(i, name, autoYes, ctx) {
49
+ describeInspection(i, name, ctx);
50
+ const dangerous = i.hasHooks || i.hasMcp;
51
+ if (dangerous) {
52
+ // An UPDATE that introduces hooks/MCP is the sharpest case: the user already
53
+ // trusts this plugin by name, and a routine `nex plugin update` would
54
+ // otherwise be the moment it gains the ability to run commands. Call that out
55
+ // separately from the standing warning below.
56
+ if (ctx?.isUpdate) {
57
+ console.log();
58
+ console.log(chalk.red.bold(' ⚠ THIS UPDATE SHIPS CODE EXECUTION'));
59
+ console.log(chalk.red(' A version of this plugin you already trust is being replaced by one'));
60
+ console.log(chalk.red(' that runs commands. Review the diff before continuing.'));
61
+ }
62
+ console.log();
63
+ console.log(chalk.red.bold(' ⚠ SECURITY WARNING'));
64
+ if (i.hasHooks) {
65
+ console.log(chalk.red(' This plugin ships hooks.json — hooks RUN SHELL COMMANDS on your'));
66
+ console.log(chalk.red(' machine automatically during agent turns.'));
67
+ }
68
+ if (i.hasMcp) {
69
+ console.log(chalk.red(' This plugin ships mcp.json — MCP servers are LOCAL PROCESSES'));
70
+ console.log(chalk.red(' started with the commands the plugin specifies.'));
71
+ }
72
+ console.log(chalk.red(' Only continue if you trust the author. Review the source first.'));
73
+ }
74
+ if (autoYes && !dangerous)
75
+ return true;
76
+ if (autoYes && dangerous) {
77
+ // --yes never silently approves code execution — that would defeat the gate.
78
+ console.log(chalk.yellow('\n --yes does not apply to plugins with hooks/MCP; confirmation required.'));
79
+ }
80
+ // Non-interactive with no way to ask: fail loudly instead of hanging.
81
+ //
82
+ // `prompts` on a non-TTY stdin never resolves — it renders the question and
83
+ // waits forever for input that cannot arrive. In CI, a Dockerfile or any piped
84
+ // script that reached this point (no --yes, or a hooks/MCP plugin where --yes
85
+ // deliberately does not apply) the install just stopped dead until something
86
+ // killed it, and because nothing threw, the process could exit 0 having
87
+ // installed nothing at all — a silent failure in exactly the setting where it
88
+ // is hardest to notice.
89
+ if (!process.stdin.isTTY) {
90
+ console.log();
91
+ console.log(chalk.red(' ✗ Cannot ask for confirmation: stdin is not a terminal.'));
92
+ console.log(chalk.dim(dangerous
93
+ ? ' This plugin ships hooks/MCP, which always require an interactive confirmation.'
94
+ : ' Re-run with --yes to install non-interactively.'));
95
+ return false;
96
+ }
97
+ console.log();
98
+ const { ok } = await prompts({
99
+ type: 'confirm',
100
+ name: 'ok',
101
+ message: dangerous ? 'Install anyway?' : 'Install this plugin?',
102
+ initial: !dangerous,
103
+ });
104
+ return ok === true;
105
+ }
106
+ export async function pluginInstallCommand(spec, opts) {
107
+ const scope = opts.project ? 'project' : 'global';
108
+ try {
109
+ // Short name (no slash, not a path) → a declared source alias first, then
110
+ // the registry.
111
+ //
112
+ // Aliases win because they are the more specific, locally-declared intent: a
113
+ // team that wrote `"deploy": "acme/deploy-tools"` in their settings means THAT
114
+ // repo, and silently resolving to a same-named registry entry instead would be
115
+ // the more surprising and more dangerous outcome of the two.
116
+ let source = spec;
117
+ let registryName;
118
+ const isShortName = /^[\w.-]+$/.test(spec.trim()) && !spec.includes('/');
119
+ if (isShortName) {
120
+ // Project-declared sources count only once the workspace is trusted; the
121
+ // chat session establishes that, and `nex plugin install` is itself an
122
+ // explicit, human-initiated act against the folder the user chose.
123
+ const alias = resolveSourceAlias(spec.trim(), process.cwd(), { includeProject: true });
124
+ if (alias) {
125
+ source = alias.spec;
126
+ console.log(chalk.dim(` Source: ${alias.name} (${alias.tier}) ← ${alias.spec}`));
127
+ }
128
+ }
129
+ if (isShortName && source === spec) {
130
+ const entry = await getRegistryPlugin(spec.trim()).catch(() => null);
131
+ if (entry) {
132
+ source = entry.source;
133
+ registryName = entry.name;
134
+ const badge = entry.official ? chalk.magenta(' [official]') : entry.verified ? chalk.green(' [verified]') : '';
135
+ console.log(chalk.dim(` Registry: ${entry.display_name}${badge} ← ${entry.source}`));
136
+ }
137
+ else {
138
+ console.error(chalk.red(` ✗ "${spec}" is not a declared source or a registry plugin.`));
139
+ console.error(chalk.dim(' Search: nex plugin search <keyword>'));
140
+ console.error(chalk.dim(' From GitHub: nex plugin install owner/repo'));
141
+ console.error(chalk.dim(' Declare it: nex plugin source add ' + spec.trim() + ' owner/repo'));
142
+ process.exitCode = 1;
143
+ return;
144
+ }
145
+ }
146
+ const res = await installPlugin(source, {
147
+ scope,
148
+ workDir: process.cwd(),
149
+ name: opts.name ?? registryName,
150
+ force: opts.force,
151
+ confirm: (i, name, ctx) => confirmInstall(i, name, opts.yes === true, ctx),
152
+ });
153
+ if (registryName)
154
+ reportInstall(registryName);
155
+ console.log();
156
+ console.log(chalk.green(` ✓ Installed "${res.name}" (${res.scope}) → ${res.dir}`)
157
+ + (res.sha ? chalk.dim(` @ ${res.sha.slice(0, 7)}`) : ''));
158
+ if (res.inspection.commands.length) {
159
+ console.log(chalk.dim(` Try: ${res.inspection.commands.map((c) => '/' + c).join(', ')}`));
160
+ }
161
+ console.log();
162
+ }
163
+ catch (err) {
164
+ console.error(chalk.red(` ✗ ${err.message}`));
165
+ process.exitCode = 1;
166
+ }
167
+ }
168
+ export function pluginRemoveCommand(name, opts) {
169
+ try {
170
+ const dir = removePlugin(name, {
171
+ scope: opts.project ? 'project' : undefined, // no flag → search both scopes
172
+ workDir: process.cwd(),
173
+ });
174
+ console.log(chalk.green(` ✓ Removed "${name}" (${dir})`));
175
+ }
176
+ catch (err) {
177
+ console.error(chalk.red(` ✗ ${err.message}`));
178
+ process.exitCode = 1;
179
+ }
180
+ }
181
+ export async function pluginUpdateCommand(name, opts) {
182
+ const workDir = process.cwd();
183
+ const targets = name
184
+ ? [name]
185
+ : loadPlugins(workDir).filter((p) => readReceipt(p.dir)?.kind === 'github').map((p) => p.name);
186
+ if (!targets.length) {
187
+ console.log(chalk.dim(' No updatable plugins (only GitHub-installed plugins with receipts can update).'));
188
+ return;
189
+ }
190
+ for (const t of targets) {
191
+ try {
192
+ const res = await updatePlugin(t, {
193
+ workDir,
194
+ confirm: (i, n, ctx) => confirmInstall(i, n, opts.yes === true, ctx),
195
+ });
196
+ const moved = res.sha && res.previousSha && res.sha !== res.previousSha
197
+ ? chalk.dim(` ${res.previousSha.slice(0, 7)} → ${res.sha.slice(0, 7)}`)
198
+ : res.sha && res.previousSha === res.sha
199
+ ? chalk.dim(' (unchanged)')
200
+ : '';
201
+ console.log(chalk.green(` ✓ Updated "${res.name}"`) + moved);
202
+ }
203
+ catch (err) {
204
+ console.error(chalk.red(` ✗ ${t}: ${err.message}`));
205
+ process.exitCode = 1;
206
+ }
207
+ }
208
+ }
209
+ function badgeFor(p) {
210
+ return p.official ? chalk.magenta(' official') : p.verified ? chalk.green(' verified') : '';
211
+ }
212
+ export async function pluginSearchCommand(query) {
213
+ try {
214
+ const results = await searchRegistry(query);
215
+ if (!results.length) {
216
+ console.log(chalk.dim(query ? ` No plugins matching "${query}".` : ' Registry is empty.'));
217
+ return;
218
+ }
219
+ console.log();
220
+ console.log(chalk.bold(query ? ` Plugins matching "${query}":` : ' Available plugins:'));
221
+ for (const p of results) {
222
+ const warn = p.has_hooks || p.has_mcp ? chalk.yellow(' ⚠ runs code') : '';
223
+ console.log(' ' + chalk.cyan(p.name.padEnd(20)) +
224
+ p.description.slice(0, 70) +
225
+ badgeFor(p) + warn +
226
+ chalk.dim(` ↓${p.install_count}`));
227
+ }
228
+ console.log(chalk.dim('\n nex plugin info <name> details'));
229
+ console.log(chalk.dim(' nex plugin install <name> install'));
230
+ console.log();
231
+ }
232
+ catch (err) {
233
+ console.error(chalk.red(` ✗ ${err.message}`));
234
+ process.exitCode = 1;
235
+ }
236
+ }
237
+ export async function pluginInfoCommand(name) {
238
+ try {
239
+ const p = await getRegistryPlugin(name);
240
+ if (!p) {
241
+ console.error(chalk.red(` ✗ "${name}" not found in the registry.`));
242
+ process.exitCode = 1;
243
+ return;
244
+ }
245
+ console.log();
246
+ console.log(chalk.bold(` ${p.display_name}`) + badgeFor(p) + chalk.dim(` ↓${p.install_count} installs`));
247
+ console.log(` ${p.description}`);
248
+ console.log();
249
+ console.log(chalk.dim(' author: ') + p.author);
250
+ console.log(chalk.dim(' source: ') + p.source);
251
+ if (p.homepage)
252
+ console.log(chalk.dim(' homepage: ') + p.homepage);
253
+ if (p.keywords?.length)
254
+ console.log(chalk.dim(' keywords: ') + p.keywords.join(', '));
255
+ const ships = [];
256
+ if (p.has_commands)
257
+ ships.push('commands');
258
+ if (p.has_agents)
259
+ ships.push('agents');
260
+ if (p.has_hooks)
261
+ ships.push(chalk.yellow('hooks (runs shell commands!)'));
262
+ if (p.has_mcp)
263
+ ships.push(chalk.yellow('MCP servers (spawns processes!)'));
264
+ if (ships.length)
265
+ console.log(chalk.dim(' ships: ') + ships.join(', '));
266
+ console.log();
267
+ console.log(chalk.dim(` Install: nex plugin install ${p.name}`));
268
+ console.log();
269
+ }
270
+ catch (err) {
271
+ console.error(chalk.red(` ✗ ${err.message}`));
272
+ process.exitCode = 1;
273
+ }
274
+ }
275
+ export function pluginListCommand() {
276
+ const plugins = loadPlugins(process.cwd());
277
+ if (!plugins.length) {
278
+ console.log(chalk.dim(' No plugins installed.'));
279
+ console.log(chalk.dim(' Install one: nex plugin install owner/repo'));
280
+ return;
281
+ }
282
+ console.log();
283
+ console.log(chalk.bold(' Installed plugins:'));
284
+ for (const p of plugins) {
285
+ const receipt = readReceipt(p.dir);
286
+ const src = receipt ? chalk.dim(` ← ${receipt.source}`) : chalk.dim(' (manual)');
287
+ // The commit is what makes an install auditable — "which code is actually
288
+ // sitting in my plugins dir" cannot be answered by a name and a version
289
+ // string the author chose.
290
+ const at = receipt?.sha ? chalk.dim(` @ ${receipt.sha.slice(0, 7)}`) : '';
291
+ // Flag the executable surfaces at a glance: these are decided at install
292
+ // time but live on forever, and `list` is where a user audits them.
293
+ const insp = inspectPluginDir(p.dir);
294
+ const runs = insp.hasHooks || insp.hasMcp
295
+ ? chalk.yellow(` ⚠ ${[insp.hasHooks && 'hooks', insp.hasMcp && 'mcp'].filter(Boolean).join('+')}`)
296
+ : '';
297
+ console.log(' ' + chalk.cyan(p.name.padEnd(24)) +
298
+ chalk.dim(`${(p.version ?? '').padEnd(10)}${p.scope.padEnd(9)}`) + src + at + runs);
299
+ }
300
+ console.log();
301
+ }
302
+ // ─── nex plugin source <list|add|remove> ─────────────────────────────────────
303
+ //
304
+ // A declared source binds a short alias to a plugin repo, so a team can commit
305
+ // "these are our plugins" instead of telling each developer a URL to type.
306
+ //
307
+ // Declaring is NOT installing. Nothing is fetched and no code runs until someone
308
+ // runs an install, which still passes the usual confirmation gate. That
309
+ // separation is deliberate: project-tier entries come from the repository, so if
310
+ // a declaration could install by itself, cloning a hostile repo would be enough
311
+ // to execute its code — the exact attack the workspace trust prompt exists for.
312
+ export function pluginSourceListCommand() {
313
+ const workDir = process.cwd();
314
+ const sources = loadDeclaredSources(workDir, { includeProject: true });
315
+ const patterns = allowedSourcePatterns();
316
+ if (patterns) {
317
+ console.log();
318
+ console.log(chalk.bold(' Organisation policy: ') + chalk.dim(`installs restricted to ${patterns.join(', ')}`));
319
+ }
320
+ if (!sources.length) {
321
+ console.log(chalk.dim('\n No plugin sources declared.'));
322
+ console.log(chalk.dim(' Add one: nex plugin source add <alias> owner/repo'));
323
+ console.log();
324
+ return;
325
+ }
326
+ console.log();
327
+ console.log(chalk.bold(' Declared plugin sources:'));
328
+ for (const s of sources) {
329
+ // The tier is the trust story, so it is never hidden: `project` means the
330
+ // repository chose this, which is a different claim from the user choosing it.
331
+ const tier = s.tier === 'project'
332
+ ? chalk.yellow(s.tier.padEnd(9))
333
+ : chalk.dim(s.tier.padEnd(9));
334
+ const blocked = !isSourceAllowed(s.spec, patterns) ? chalk.red(' ✗ blocked by policy') : '';
335
+ console.log(' ' + chalk.cyan(s.name.padEnd(20)) + tier + chalk.white(s.spec) + blocked);
336
+ }
337
+ console.log();
338
+ console.log(chalk.dim(' Install from one: nex plugin install <alias>'));
339
+ console.log(chalk.dim(' Declaring a source does not install anything.'));
340
+ console.log();
341
+ }
342
+ export function pluginSourceAddCommand(name, spec, opts) {
343
+ try {
344
+ // Validate the spec NOW rather than at install time: a typo caught while the
345
+ // author is looking at it costs a second, and one committed into a shared
346
+ // settings file costs every teammate a confusing failure later.
347
+ parsePluginSource(spec);
348
+ const patterns = allowedSourcePatterns();
349
+ if (patterns && !isSourceAllowed(spec, patterns)) {
350
+ console.error(chalk.red(` ✗ "${spec}" is not permitted by your organisation's policy.`));
351
+ console.error(chalk.dim(` Allowed: ${patterns.join(', ')}`));
352
+ process.exitCode = 1;
353
+ return;
354
+ }
355
+ const scope = opts.project ? 'project' : 'user';
356
+ const file = sourcesFileFor(scope, process.cwd());
357
+ writeDeclaredSource(file, name, spec);
358
+ console.log();
359
+ console.log(chalk.green(` ✓ Declared "${name}" → ${spec}`) + chalk.dim(` (${scope})`));
360
+ console.log(chalk.dim(` ${file}`));
361
+ console.log(chalk.dim(` Nothing installed yet — run: nex plugin install ${name}`));
362
+ console.log();
363
+ }
364
+ catch (err) {
365
+ console.error(chalk.red(` ✗ ${err.message}`));
366
+ process.exitCode = 1;
367
+ }
368
+ }
369
+ export function pluginSourceRemoveCommand(name, opts) {
370
+ const workDir = process.cwd();
371
+ const scope = opts.project ? 'project' : 'user';
372
+ const file = sourcesFileFor(scope, workDir);
373
+ if (removeDeclaredSource(file, name)) {
374
+ console.log(chalk.green(` ✓ Removed source "${name}" (${scope})`));
375
+ console.log(chalk.dim(' Plugins already installed from it are untouched — remove them with nex plugin remove <name>'));
376
+ return;
377
+ }
378
+ // Point at where it actually lives instead of a flat "not found": the most
379
+ // common cause is removing a project source while defaulting to user scope.
380
+ const found = resolveSourceAlias(name, workDir, { includeProject: true });
381
+ if (found) {
382
+ console.error(chalk.red(` ✗ "${name}" is declared in the ${found.tier} tier, not ${scope}.`));
383
+ console.error(chalk.dim(` ${found.file}`));
384
+ if (found.tier === 'project')
385
+ console.error(chalk.dim(' Retry with --project'));
386
+ if (found.tier === 'managed')
387
+ console.error(chalk.dim(' Managed sources are set by your organisation and cannot be removed here.'));
388
+ }
389
+ else {
390
+ console.error(chalk.red(` ✗ No source named "${name}".`));
391
+ }
392
+ process.exitCode = 1;
393
+ }
@@ -0,0 +1,62 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import * as os from 'os';
4
+ // NEXRALL_CLI_SESSIONS_DIR overrides the store root — used by tests to avoid
5
+ // touching the real ~/.nexrall/cli-sessions directory on the dev/CI machine
6
+ // (same pattern as core's NEXRALL_CHECKPOINT_DIR).
7
+ export const SESSIONS_DIR = process.env.NEXRALL_CLI_SESSIONS_DIR || path.join(os.homedir(), '.nexrall', 'cli-sessions');
8
+ function ensureDir() {
9
+ fs.mkdirSync(SESSIONS_DIR, { recursive: true });
10
+ }
11
+ export function sessionPath(id) {
12
+ return path.join(SESSIONS_DIR, `${id}.json`);
13
+ }
14
+ export function listSessions() {
15
+ try {
16
+ ensureDir();
17
+ return fs.readdirSync(SESSIONS_DIR)
18
+ .filter(f => f.endsWith('.json'))
19
+ .map(f => {
20
+ try {
21
+ return JSON.parse(fs.readFileSync(path.join(SESSIONS_DIR, f), 'utf-8'));
22
+ }
23
+ catch {
24
+ return null;
25
+ }
26
+ })
27
+ .filter((s) => s !== null)
28
+ .sort((a, b) => b.updatedAt - a.updatedAt);
29
+ }
30
+ catch {
31
+ return [];
32
+ }
33
+ }
34
+ export function loadSession(id) {
35
+ try {
36
+ return JSON.parse(fs.readFileSync(sessionPath(id), 'utf-8'));
37
+ }
38
+ catch {
39
+ return null;
40
+ }
41
+ }
42
+ export function saveSession(id, title, workDir, messages) {
43
+ try {
44
+ ensureDir();
45
+ const session = { id, title, updatedAt: Date.now(), workDir, messages };
46
+ fs.writeFileSync(sessionPath(id), JSON.stringify(session), 'utf-8');
47
+ }
48
+ catch { /* non-fatal */ }
49
+ }
50
+ export function deleteSession(id) {
51
+ try {
52
+ fs.unlinkSync(sessionPath(id));
53
+ return true;
54
+ }
55
+ catch {
56
+ return false;
57
+ }
58
+ }
59
+ export function lastSession() {
60
+ const all = listSessions();
61
+ return all.length ? all[0] : null;
62
+ }
@@ -0,0 +1,174 @@
1
+ import * as fs from 'fs';
2
+ import * as https from 'https';
3
+ import * as path from 'path';
4
+ import { fileURLToPath } from 'url';
5
+ import chalk from 'chalk';
6
+ // This file is bundled to ESM (see build.js) — there is no `__dirname` in
7
+ // ESM, referencing it throws ReferenceError, and that error was being
8
+ // silently swallowed by getCurrentVersion()'s try/catch, so the CLI always
9
+ // reported its own version as '0.0.0'. Deriving it from `import.meta.url`
10
+ // instead is the ESM-correct equivalent of `__dirname`.
11
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
12
+ // npm's registry is the single source of truth for "what is the latest
13
+ // version" — it always reflects whatever `npm publish` actually shipped, with
14
+ // no separate metadata to keep in sync. An earlier version of this file asked
15
+ // our own backend for that answer via a custom CDN, whose "latest version"
16
+ // was a hardcoded fallback that had drifted 5 months stale in production
17
+ // (every `nex update` would have silently done nothing, or worse, reported
18
+ // "up to date" while running a broken build). Delegating to npm removes that
19
+ // class of bug entirely.
20
+ const NPM_REGISTRY_LATEST = 'https://registry.npmjs.org/nexrall-code/latest';
21
+ function get(url) {
22
+ return new Promise((resolve, reject) => {
23
+ https.get(url, { headers: { 'User-Agent': 'nexrall-cli-updater', Accept: 'application/json' } }, res => {
24
+ if (res.statusCode === 301 || res.statusCode === 302) {
25
+ return get(res.headers.location).then(resolve).catch(reject);
26
+ }
27
+ if (res.statusCode !== 200) {
28
+ res.resume();
29
+ return reject(new Error(`npm registry returned HTTP ${res.statusCode}`));
30
+ }
31
+ let raw = '';
32
+ res.on('data', (c) => raw += c.toString());
33
+ res.on('end', () => resolve(raw));
34
+ }).on('error', reject);
35
+ });
36
+ }
37
+ function getCurrentVersion() {
38
+ try {
39
+ // The published npm package lays out as `nexrall-code/dist/index.js` +
40
+ // `nexrall-code/package.json` (verified via `npm pack --dry-run`), so
41
+ // from this bundled file's __dirname (= dist/) package.json is one level
42
+ // up, not two. The previous `../../package.json` resolved OUTSIDE the
43
+ // installed package entirely, so this always silently fell through to
44
+ // the '0.0.0' fallback — `nex update`/`checkForUpdate` never printed the
45
+ // real current version and never correctly reported "already up to
46
+ // date" once a `latest` genuinely matched what was installed.
47
+ const pkg = path.resolve(__dirname, '../package.json');
48
+ return JSON.parse(fs.readFileSync(pkg, 'utf-8')).version ?? '0.0.0';
49
+ }
50
+ catch {
51
+ return '0.0.0';
52
+ }
53
+ }
54
+ function semverGt(a, b) {
55
+ const pa = a.split('.').map(Number);
56
+ const pb = b.split('.').map(Number);
57
+ for (let i = 0; i < 3; i++) {
58
+ if ((pa[i] ?? 0) > (pb[i] ?? 0))
59
+ return true;
60
+ if ((pa[i] ?? 0) < (pb[i] ?? 0))
61
+ return false;
62
+ }
63
+ return false;
64
+ }
65
+ async function fetchLatestVersion() {
66
+ const raw = await get(NPM_REGISTRY_LATEST);
67
+ const meta = JSON.parse(raw);
68
+ return meta.version ?? '0.0.0';
69
+ }
70
+ /** Re-exec `npm install -g nexrall-code` to pick up whatever is currently
71
+ * published — npm handles platform/arch/permissions itself, so there is no
72
+ * bespoke download-and-replace logic to maintain here. */
73
+ function npmInstallGlobal() {
74
+ return new Promise(resolve => {
75
+ const { spawn } = require('child_process');
76
+ const child = spawn('npm', ['install', '-g', 'nexrall-code'], { stdio: 'pipe' });
77
+ let stderr = '';
78
+ child.stderr?.on('data', (c) => { stderr += c.toString(); });
79
+ child.on('close', (code) => {
80
+ if (code === 0)
81
+ return resolve({ ok: true, usedSudo: false });
82
+ // Permission errors (EACCES) are the common case for a system Node
83
+ // without a user-writable global prefix — retry once with sudo.
84
+ if (/EACCES|permission denied/i.test(stderr)) {
85
+ const sudo = spawn('sudo', ['npm', 'install', '-g', 'nexrall-code'], { stdio: 'inherit' });
86
+ sudo.on('close', (sudoCode) => resolve({ ok: sudoCode === 0, usedSudo: true }));
87
+ }
88
+ else {
89
+ console.error(chalk.dim(stderr.trim()));
90
+ resolve({ ok: false, usedSudo: false });
91
+ }
92
+ });
93
+ });
94
+ }
95
+ // ─── Check ────────────────────────────────────────────────────────────────────
96
+ export async function checkForUpdate() {
97
+ const current = getCurrentVersion();
98
+ let latest;
99
+ try {
100
+ latest = await fetchLatestVersion();
101
+ }
102
+ catch {
103
+ console.log(chalk.dim(' Could not reach npm registry.'));
104
+ return;
105
+ }
106
+ if (semverGt(latest, current)) {
107
+ console.log('\n' + chalk.yellow(' ◆ Update available!') +
108
+ chalk.dim(` v${current} → `) + chalk.cyan(`v${latest}`) +
109
+ '\n' + chalk.dim(' Run ') + chalk.bold('nex update') + chalk.dim(' to install.\n'));
110
+ }
111
+ else {
112
+ console.log(chalk.dim(` Already on latest: v${current}`));
113
+ }
114
+ }
115
+ // ─── Update ───────────────────────────────────────────────────────────────────
116
+ export async function updateCommand(opts) {
117
+ const current = getCurrentVersion();
118
+ console.log();
119
+ console.log(chalk.bold(' Nexrall CLI — Updater'));
120
+ console.log(chalk.dim(' ─────────────────────────────────────'));
121
+ console.log(chalk.dim(` Current version: v${current}`));
122
+ console.log();
123
+ // ── Fetch latest version info ─────────────────────────────────────────────
124
+ process.stdout.write(chalk.dim(' → Checking latest version…'));
125
+ let latest;
126
+ try {
127
+ latest = await fetchLatestVersion();
128
+ process.stdout.write('\r\x1b[K');
129
+ }
130
+ catch (e) {
131
+ process.stdout.write('\r\x1b[K');
132
+ console.error(chalk.red(' ✗ Could not reach npm registry: ') + String(e.message));
133
+ process.exit(1);
134
+ }
135
+ if (!semverGt(latest, current)) {
136
+ console.log(chalk.green(` ✓ Already up to date — v${current}`));
137
+ console.log();
138
+ return;
139
+ }
140
+ console.log(chalk.cyan(` New version available: v${latest}`) +
141
+ chalk.dim(` (current: v${current})`));
142
+ console.log();
143
+ // ── --check only ──────────────────────────────────────────────────────────
144
+ if (opts.check) {
145
+ console.log(chalk.dim(' Run ') + chalk.bold('nex update') + chalk.dim(' to install.'));
146
+ console.log();
147
+ return;
148
+ }
149
+ // ── Confirm (unless --yes) ────────────────────────────────────────────────
150
+ if (!opts.yes) {
151
+ const readline = await import('readline');
152
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
153
+ const answer = await new Promise(resolve => rl.question(chalk.bold(' Update now?') + chalk.dim(' [y/n] > '), a => { rl.close(); resolve(a.trim()); }));
154
+ if (answer !== 'y' && answer !== 'yes') {
155
+ console.log(chalk.dim('\n Cancelled.\n'));
156
+ return;
157
+ }
158
+ console.log();
159
+ }
160
+ // ── npm install -g nexrall-code ───────────────────────────────────────────
161
+ process.stdout.write(chalk.dim(` → Running npm install -g nexrall-code…`));
162
+ const { ok, usedSudo } = await npmInstallGlobal();
163
+ process.stdout.write('\r\x1b[K');
164
+ if (!ok) {
165
+ console.error(chalk.red(' ✗ Update failed. Run manually:'));
166
+ console.error(chalk.dim(' npm install -g nexrall-code'));
167
+ process.exit(1);
168
+ }
169
+ console.log(chalk.green(` ✓ Updated → v${latest}${usedSudo ? ' (via sudo)' : ''}`));
170
+ console.log();
171
+ console.log(chalk.bold(` Nexrall CLI v${latest} installed!`));
172
+ console.log(chalk.dim(' Restart your terminal or run: exec $SHELL'));
173
+ console.log();
174
+ }