nexrall-code 0.5.107 → 0.5.108
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/dist/audit.js +102 -0
- package/dist/commands/auth.js +134 -0
- package/dist/commands/chat.js +2715 -0
- package/dist/commands/plugin.js +393 -0
- package/dist/commands/sessions.js +62 -0
- package/dist/commands/update.js +174 -0
- package/dist/index.js +1677 -36
- package/dist/permissions/handler.js +332 -0
- package/dist/terminal/terminalSetup.js +395 -0
- package/dist/trust.js +137 -0
- package/dist/ui/diff.js +51 -0
- package/dist/ui/inkReadlineAdapter.js +116 -0
- package/dist/ui/inkTerminal.js +1097 -0
- package/dist/ui/resizeRepaint.js +36 -0
- package/dist/ui/screen.js +482 -0
- package/dist/ui/theme.js +385 -0
- package/dist/ui/trustPrompt.js +126 -0
- package/package.json +1 -1
|
@@ -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
|
+
}
|