@kiran_nandi_123/conxa 1.0.3 → 1.0.5

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/lib/browser.js CHANGED
@@ -6,19 +6,36 @@ const { getPluginConfig, getPluginDir, getAuthJson } = require("./config");
6
6
 
7
7
  // ─── Browser cache (per-slug, 5-min idle timeout) ────────────────────────────
8
8
 
9
- const _cache = new Map(); // slug → { browser, context, idleTimer }
9
+ const _cache = new Map(); // slug → { browser, context, idleTimer }
10
+ const _holds = new Map(); // slug → hold count
11
+ const _inflight = new Map(); // slug → Promise<{browser,context}> (dedup concurrent launches)
10
12
  const BROWSER_IDLE_MS = 5 * 60 * 1000;
13
+ const BROWSER_MAX = 5;
11
14
 
12
15
  function _scheduleCleanup(slug) {
13
16
  const entry = _cache.get(slug);
14
17
  if (!entry) return;
15
18
  clearTimeout(entry.idleTimer);
16
19
  entry.idleTimer = setTimeout(async () => {
20
+ if ((_holds.get(slug) || 0) > 0) {
21
+ _scheduleCleanup(slug); // still held — defer
22
+ return;
23
+ }
17
24
  console.error(`[browser-cache] Idle timeout for ${slug} — closing browser`);
18
25
  const b = entry.browser;
19
26
  _cache.delete(slug);
20
27
  if (b) await b.close().catch(() => {});
21
28
  }, BROWSER_IDLE_MS);
29
+ entry.idleTimer.unref?.();
30
+ }
31
+
32
+ function holdBrowser(slug) {
33
+ _holds.set(slug, (_holds.get(slug) || 0) + 1);
34
+ }
35
+
36
+ function releaseBrowserHold(slug) {
37
+ const n = (_holds.get(slug) || 0) - 1;
38
+ if (n <= 0) _holds.delete(slug); else _holds.set(slug, n);
22
39
  }
23
40
 
24
41
  async function getCachedBrowser(slug) {
@@ -33,11 +50,35 @@ async function getCachedBrowser(slug) {
33
50
  _cache.delete(slug);
34
51
  }
35
52
  }
36
- const { browser, context } = await getAuthContext(slug, false);
37
- _cache.set(slug, { browser, context, idleTimer: null });
38
- _scheduleCleanup(slug);
39
- console.error(`[browser-cache] Launched new browser for ${slug}`);
40
- return { browser, context, cached: false };
53
+ // Dedup concurrent launch requests for the same slug
54
+ if (_inflight.has(slug)) {
55
+ console.error(`[browser-cache] Waiting for in-flight browser launch for ${slug}`);
56
+ return _inflight.get(slug);
57
+ }
58
+ const launch = (async () => {
59
+ try {
60
+ const { browser, context } = await getAuthContext(slug, false);
61
+ // Evict LRU entry if at capacity (skip held browsers)
62
+ if (_cache.size >= BROWSER_MAX) {
63
+ for (const [evictSlug, evictEntry] of _cache.entries()) {
64
+ if ((_holds.get(evictSlug) || 0) > 0) continue;
65
+ clearTimeout(evictEntry.idleTimer);
66
+ _cache.delete(evictSlug);
67
+ if (evictEntry.browser) evictEntry.browser.close().catch(() => {});
68
+ console.error(`[browser-cache] Evicted ${evictSlug} (cache limit ${BROWSER_MAX})`);
69
+ break;
70
+ }
71
+ }
72
+ _cache.set(slug, { browser, context, idleTimer: null });
73
+ _scheduleCleanup(slug);
74
+ console.error(`[browser-cache] Launched new browser for ${slug}`);
75
+ return { browser, context, cached: false };
76
+ } finally {
77
+ _inflight.delete(slug);
78
+ }
79
+ })();
80
+ _inflight.set(slug, launch);
81
+ return launch;
41
82
  }
42
83
 
43
84
  // ─── Session management ───────────────────────────────────────────────────────
@@ -76,6 +117,9 @@ async function getAuthContext(slug, headless) {
76
117
  console.error(`[auth:${slug}] No auth.json — starting manual login`);
77
118
  }
78
119
 
120
+ const isCI = process.env.CI || process.env.DISPLAY === "" || (!process.env.DISPLAY && process.platform === "linux");
121
+ if (isCI) throw new Error(`[auth:${slug}] Cannot open login browser in headless/CI environment. Pre-generate auth.json and place it at ${authJson}`);
122
+
79
123
  console.error(`[auth:${slug}] Opening login browser — waiting for user to authenticate...`);
80
124
  const loginBrowser = await chromium.launch({ headless: false });
81
125
  const loginCtx = await loginBrowser.newContext();
@@ -92,8 +136,8 @@ async function getAuthContext(slug, headless) {
92
136
  }
93
137
 
94
138
  const state = await loginCtx.storageState();
95
- fs.mkdirSync(path.dirname(authJson), { recursive: true });
96
- fs.writeFileSync(authJson, JSON.stringify(state, null, 2));
139
+ fs.mkdirSync(path.dirname(authJson), { recursive: true, mode: 0o700 });
140
+ fs.writeFileSync(authJson, JSON.stringify(state, null, 2), { encoding: "utf8", mode: 0o600 });
97
141
  console.error(`[auth:${slug}] Session saved to auth.json — closing login browser`);
98
142
  await loginBrowser.close();
99
143
 
@@ -126,4 +170,4 @@ async function gracefulShutdown() {
126
170
  process.exit(0);
127
171
  }
128
172
 
129
- module.exports = { getCachedBrowser, isAuthenticated, getAuthContext, gracefulShutdown };
173
+ module.exports = { getCachedBrowser, holdBrowser, releaseBrowserHold, isAuthenticated, getAuthContext, gracefulShutdown };
package/lib/cli.js CHANGED
@@ -34,12 +34,53 @@ const SERVER_JS = path.join(RUNTIME_DIR, "server.js");
34
34
 
35
35
  function readRegistry() {
36
36
  if (!fs.existsSync(REGISTRY_PATH)) return {};
37
- try { return JSON.parse(fs.readFileSync(REGISTRY_PATH, "utf8")); } catch (_) { return {}; }
37
+ try { return JSON.parse(fs.readFileSync(REGISTRY_PATH, "utf8")); }
38
+ catch (e) {
39
+ const bak = REGISTRY_PATH + ".bak";
40
+ try { fs.renameSync(REGISTRY_PATH, bak); } catch (_) {}
41
+ process.stderr.write(`[conxa] Warning: registry.json was corrupt (${e.message}) — backed up to registry.json.bak\n`);
42
+ return {};
43
+ }
44
+ }
45
+
46
+ function _atomicWrite(filePath, content) {
47
+ const tmp = filePath + `.tmp.${process.pid}`;
48
+ fs.writeFileSync(tmp, content, "utf8");
49
+ fs.renameSync(tmp, filePath);
38
50
  }
39
51
 
40
52
  function writeRegistry(reg) {
41
53
  fs.mkdirSync(CONXA_HOME, { recursive: true });
42
- fs.writeFileSync(REGISTRY_PATH, JSON.stringify(reg, null, 2));
54
+ _atomicWrite(REGISTRY_PATH, JSON.stringify(reg, null, 2));
55
+ }
56
+
57
+ // ─── Concurrency lock ─────────────────────────────────────────────────────────
58
+
59
+ const _LOCK_FILE = path.join(os.homedir(), ".conxa", "install.lock");
60
+ const _LOCK_STALE = 30 * 1000; // steal lock after 30 s
61
+
62
+ function _acquireLock() {
63
+ fs.mkdirSync(CONXA_HOME, { recursive: true });
64
+ try {
65
+ const fd = fs.openSync(_LOCK_FILE, "wx");
66
+ fs.writeSync(fd, String(process.pid));
67
+ fs.closeSync(fd);
68
+ } catch (e) {
69
+ if (e.code !== "EEXIST") throw e;
70
+ try {
71
+ const stat = fs.statSync(_LOCK_FILE);
72
+ if (Date.now() - stat.mtimeMs < _LOCK_STALE)
73
+ throw new Error("Another conxa install/uninstall is already running.");
74
+ fs.unlinkSync(_LOCK_FILE);
75
+ _acquireLock();
76
+ } catch (e2) {
77
+ if (e2.message.startsWith("Another")) throw e2;
78
+ }
79
+ }
80
+ }
81
+
82
+ function _releaseLock() {
83
+ try { fs.unlinkSync(_LOCK_FILE); } catch (_) {}
43
84
  }
44
85
 
45
86
  // ─── Claude Code integration ─────────────────────────────────────────────────
@@ -48,11 +89,11 @@ function _registerGlobalMcp() {
48
89
  let claudeJson = {};
49
90
  try { claudeJson = JSON.parse(fs.readFileSync(CLAUDE_JSON, "utf8")); } catch (_) {}
50
91
  const existing = claudeJson.mcpServers && claudeJson.mcpServers.conxa;
51
- if (existing && existing.args && existing.args[0] === SERVER_JS) return;
92
+ if (existing && existing.type === "stdio" && existing.command === "node" && existing.args && existing.args[0] === SERVER_JS) return;
52
93
  if (!claudeJson.mcpServers) claudeJson.mcpServers = {};
53
94
  claudeJson.mcpServers.conxa = { type: "stdio", command: "node", args: [SERVER_JS] };
54
95
  try {
55
- fs.writeFileSync(CLAUDE_JSON, JSON.stringify(claudeJson, null, 2) + "\n", "utf8");
96
+ _atomicWrite(CLAUDE_JSON, JSON.stringify(claudeJson, null, 2) + "\n");
56
97
  process.stderr.write(`[conxa] Registered conxa MCP server in ${CLAUDE_JSON}\n`);
57
98
  } catch (e) {
58
99
  process.stderr.write(`[conxa] Warning: could not update .claude.json: ${e.message}\n`);
@@ -80,7 +121,7 @@ function updateGlobalClaudeMd(reg) {
80
121
  const entries = Object.values(reg);
81
122
  const pluginLines = entries.length === 0
82
123
  ? "- (no plugins installed)"
83
- : entries.map(e => `- ${e.slug} → ~/.conxa/plugins/${e.slug}/CLAUDE.md`).join("\n");
124
+ : entries.map(e => `- ${e.slug} → ${path.join(CONXA_HOME, "plugins", e.slug, "CLAUDE.md")}`).join("\n");
84
125
 
85
126
  const content = [
86
127
  "# Conxa Runtime",
@@ -103,6 +144,11 @@ function updateGlobalClaudeMd(reg) {
103
144
  "2. Collect any required inputs from the user's message (or ask once if missing)",
104
145
  "3. Call execute_plan immediately — do not explain, do not confirm",
105
146
  "",
147
+ "## Installing New Plugins",
148
+ "If the user asks to install a new conxa plugin (e.g. 'install the Vercel plugin'):",
149
+ "- Call the install_plugin MCP tool with the plugin ref (e.g. 'cannonboldoff-hue/vercel')",
150
+ "- No terminal or command needed — the MCP tool handles everything",
151
+ "",
106
152
  "## Installed Plugins",
107
153
  pluginLines,
108
154
  "",
@@ -137,6 +183,7 @@ function regenerateIndex(reg) {
137
183
  function copyDirSync(src, dst) {
138
184
  fs.mkdirSync(dst, { recursive: true });
139
185
  for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
186
+ if (entry.isSymbolicLink()) continue;
140
187
  const s = path.join(src, entry.name);
141
188
  const d = path.join(dst, entry.name);
142
189
  if (entry.isDirectory()) copyDirSync(s, d);
@@ -146,39 +193,60 @@ function copyDirSync(src, dst) {
146
193
 
147
194
  // ─── init ─────────────────────────────────────────────────────────────────────
148
195
 
196
+ function _cliVersion() {
197
+ try { return require("../package.json").version; } catch (_) { return null; }
198
+ }
199
+
149
200
  function init() {
201
+ const cliVersion = _cliVersion();
150
202
  if (fs.existsSync(VERSION_JSON)) {
151
203
  const v = JSON.parse(fs.readFileSync(VERSION_JSON, "utf8"));
152
- process.stderr.write(`[conxa] Runtime already bootstrapped at ${RUNTIME_DIR} (v${v.version})\n`);
153
- return;
204
+ if (!cliVersion || v.version === cliVersion) {
205
+ process.stderr.write(`[conxa] Runtime already at v${v.version}\n`);
206
+ return;
207
+ }
208
+ process.stderr.write(`[conxa] Updating runtime v${v.version} → v${cliVersion}...\n`);
209
+ } else {
210
+ process.stderr.write(`[conxa] Bootstrapping runtime at ${RUNTIME_DIR} ...\n`);
154
211
  }
155
- process.stderr.write(`[conxa] Bootstrapping runtime at ${RUNTIME_DIR} ...\n`);
156
-
157
- // All runtime files live alongside cli.js in both layouts: the in-repo
158
- // template tree (app/storage/plugin_templates/runtime/) and the published
159
- // npm package (packages/conxa-cli/lib/). Copy the whole directory so new
160
- // files (resolver/, search.js, etc.) ship automatically without having to
161
- // maintain a hardcoded allow-list.
162
- fs.mkdirSync(RUNTIME_DIR, { recursive: true });
212
+
213
+ // Safe-swap: copy to RUNTIME_DIR.new/, run npm install there, then rename
214
+ // so a failed install never leaves a half-updated runtime.
215
+ const RUNTIME_NEW = RUNTIME_DIR + ".new";
216
+ const RUNTIME_OLD = RUNTIME_DIR + ".old";
217
+ if (fs.existsSync(RUNTIME_NEW)) fs.rmSync(RUNTIME_NEW, { recursive: true, force: true });
218
+ fs.mkdirSync(RUNTIME_NEW, { recursive: true });
219
+
163
220
  for (const entry of fs.readdirSync(__dirname, { withFileTypes: true })) {
164
- if (entry.name === "node_modules" || entry.name === ".bootstrapped") continue;
221
+ if (entry.name === "node_modules" || entry.name === ".bootstrapped" || entry.isSymbolicLink()) continue;
165
222
  const src = path.join(__dirname, entry.name);
166
- const dst = path.join(RUNTIME_DIR, entry.name);
223
+ const dst = path.join(RUNTIME_NEW, entry.name);
167
224
  if (entry.isDirectory()) copyDirSync(src, dst);
168
225
  else fs.copyFileSync(src, dst);
169
226
  }
170
227
 
171
228
  process.stderr.write("[conxa] Running npm install...\n");
172
- execSync("npm install --prefer-offline --silent", { cwd: RUNTIME_DIR, stdio: ["ignore", "pipe", "inherit"] });
229
+ try {
230
+ execSync("npm install --prefer-offline --silent", { cwd: RUNTIME_NEW, stdio: ["ignore", "pipe", "inherit"] });
231
+ } catch (e) {
232
+ // Retry once without --prefer-offline in case cache is stale
233
+ process.stderr.write("[conxa] npm install failed, retrying without cache...\n");
234
+ execSync("npm install --silent", { cwd: RUNTIME_NEW, stdio: ["ignore", "pipe", "inherit"] });
235
+ }
173
236
 
174
237
  process.stderr.write("[conxa] Installing Playwright Chromium...\n");
175
- execSync("npx playwright install chromium", { cwd: RUNTIME_DIR, stdio: ["ignore", "pipe", "inherit"] });
238
+ execSync("npx playwright install chromium", { cwd: RUNTIME_NEW, stdio: ["ignore", "pipe", "inherit"] });
239
+
240
+ // Atomic swap: old → .old, new → active
241
+ if (fs.existsSync(RUNTIME_DIR)) {
242
+ if (fs.existsSync(RUNTIME_OLD)) fs.rmSync(RUNTIME_OLD, { recursive: true, force: true });
243
+ fs.renameSync(RUNTIME_DIR, RUNTIME_OLD);
244
+ }
245
+ fs.renameSync(RUNTIME_NEW, RUNTIME_DIR);
246
+ if (fs.existsSync(RUNTIME_OLD)) fs.rmSync(RUNTIME_OLD, { recursive: true, force: true });
176
247
 
177
- const pkg = fs.existsSync(path.join(RUNTIME_DIR, "package.json"))
178
- ? JSON.parse(fs.readFileSync(path.join(RUNTIME_DIR, "package.json"), "utf8"))
179
- : {};
180
248
  fs.writeFileSync(VERSION_JSON, JSON.stringify({
181
- version: pkg.version || "1.0.0",
249
+ version: cliVersion || "1.0.0",
182
250
  installed_at: new Date().toISOString(),
183
251
  node_version: process.version,
184
252
  }, null, 2));
@@ -196,11 +264,12 @@ function init() {
196
264
  fs.writeFileSync(path.join(CONXA_HOME, ".bootstrapped"), "1", "utf8");
197
265
 
198
266
  process.stderr.write("[conxa] Bootstrap complete.\n");
267
+ if (cliVersion) process.stderr.write(`[conxa] Runtime is now v${cliVersion}. Restart Claude Code Desktop to apply.\n`);
199
268
  }
200
269
 
201
270
  // ─── install ──────────────────────────────────────────────────────────────────
202
271
 
203
- function _installFromLocalDir(pluginDir) {
272
+ function _installFromLocalDir(pluginDir, sourceRef = null) {
204
273
  if (!pluginDir) throw new Error("install: plugin directory path required");
205
274
  const absDir = path.resolve(pluginDir);
206
275
  if (!fs.existsSync(absDir)) throw new Error(`Plugin directory not found: ${absDir}`);
@@ -213,10 +282,31 @@ function _installFromLocalDir(pluginDir) {
213
282
  if (!cfg.target_url) throw new Error("plugin.json missing: target_url");
214
283
  if (!cfg.protected_url) throw new Error("plugin.json missing: protected_url");
215
284
 
285
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(cfg.slug))
286
+ throw new Error(`plugin.json slug "${cfg.slug}" contains invalid characters`);
287
+ for (const s of (cfg.skills || [])) {
288
+ const p = s.path || `skills/${s.slug}`;
289
+ if (p.includes("..") || path.isAbsolute(p))
290
+ throw new Error(`Skill path "${p}" is not allowed`);
291
+ }
292
+
216
293
  const slug = cfg.slug;
217
294
  const destDir = path.join(PLUGINS_DIR, slug);
218
295
 
219
296
  process.stderr.write(`[conxa] Installing plugin '${slug}' from ${absDir}...\n`);
297
+ if (fs.existsSync(destDir)) {
298
+ // Preserve auth/ across reinstall — save and restore
299
+ const authDir = path.join(destDir, "auth");
300
+ const authSave = fs.existsSync(authDir) ? fs.readdirSync(authDir)
301
+ .filter(f => f !== "credentials.example.json")
302
+ .reduce((m, f) => { m[f] = fs.readFileSync(path.join(authDir, f)); return m; }, {}) : {};
303
+ fs.rmSync(destDir, { recursive: true, force: true });
304
+ if (Object.keys(authSave).length) {
305
+ fs.mkdirSync(path.join(destDir, "auth"), { recursive: true, mode: 0o700 });
306
+ for (const [name, buf] of Object.entries(authSave))
307
+ fs.writeFileSync(path.join(destDir, "auth", name), buf, { mode: 0o600 });
308
+ }
309
+ }
220
310
  fs.mkdirSync(destDir, { recursive: true });
221
311
 
222
312
  // Copy plugin manifest
@@ -250,6 +340,7 @@ function _installFromLocalDir(pluginDir) {
250
340
  protected_url: cfg.protected_url,
251
341
  skills: skillsList,
252
342
  installed_at: new Date().toISOString(),
343
+ ...(sourceRef ? { source_ref: sourceRef } : {}),
253
344
  };
254
345
  const reg = readRegistry();
255
346
  reg[slug] = entry;
@@ -274,9 +365,11 @@ function _ensureInitialized() {
274
365
  // before delegating to _installFromLocalDir.
275
366
  async function install(ref) {
276
367
  if (!ref) throw new Error("install: <ref> required (local dir, owner/repo, or plugin_id)");
368
+ _acquireLock();
369
+ try {
277
370
  _ensureInitialized();
278
371
  if (fs.existsSync(ref) && fs.statSync(ref).isDirectory()) {
279
- return _installFromLocalDir(ref);
372
+ return _installFromLocalDir(ref, null);
280
373
  }
281
374
  // Look in cache first (already downloaded). cache.stagedDir() takes a
282
375
  // plugin_id+version pair; we accept either "id" or "id@version".
@@ -285,16 +378,17 @@ async function install(ref) {
285
378
  const ver = at > 0 ? ref.slice(at + 1) : null;
286
379
  const cache = require("./resolver/cache");
287
380
  const staged = cache.stagedDir(id, ver);
288
- if (staged) return _installFromLocalDir(staged);
381
+ if (staged) return _installFromLocalDir(staged, ref);
289
382
  // git resolver handles owner/repo and full https URLs. It stages into cache/
290
383
  // and returns the staged directory path.
291
384
  const git = require("./resolver/git");
292
385
  const resolved = await git.resolve(ref);
293
- if (resolved && resolved.staged_dir) return _installFromLocalDir(resolved.staged_dir);
386
+ if (resolved && resolved.staged_dir) return _installFromLocalDir(resolved.staged_dir, ref);
294
387
  // Registry resolver is contract-only today; falls through when no hosted
295
388
  // registry is configured. When implemented it would download a tarball into
296
389
  // cache/ and return the staged path.
297
390
  throw new Error(`install: could not resolve '${ref}'`);
391
+ } finally { _releaseLock(); }
298
392
  }
299
393
 
300
394
  // ─── search ───────────────────────────────────────────────────────────────────
@@ -339,21 +433,24 @@ function registryLogout(url) {
339
433
 
340
434
  function uninstall(slug) {
341
435
  if (!slug) throw new Error("uninstall: slug required");
342
- const destDir = path.join(PLUGINS_DIR, slug);
343
- if (fs.existsSync(destDir)) {
344
- fs.rmSync(destDir, { recursive: true, force: true });
345
- process.stderr.write(`[conxa] Removed plugin directory: ${destDir}\n`);
346
- }
347
- const reg = readRegistry();
348
- if (reg[slug]) {
349
- delete reg[slug];
350
- writeRegistry(reg);
351
- updateGlobalClaudeMd(reg);
352
- regenerateIndex(reg);
353
- process.stderr.write(`[conxa] Removed '${slug}' from registry\n`);
354
- } else {
355
- process.stderr.write(`[conxa] Plugin '${slug}' was not in registry\n`);
356
- }
436
+ _acquireLock();
437
+ try {
438
+ const destDir = path.join(PLUGINS_DIR, slug);
439
+ if (fs.existsSync(destDir)) {
440
+ fs.rmSync(destDir, { recursive: true, force: true });
441
+ process.stderr.write(`[conxa] Removed plugin directory: ${destDir}\n`);
442
+ }
443
+ const reg = readRegistry();
444
+ if (reg[slug]) {
445
+ delete reg[slug];
446
+ writeRegistry(reg);
447
+ updateGlobalClaudeMd(reg);
448
+ regenerateIndex(reg);
449
+ process.stderr.write(`[conxa] Removed '${slug}' from registry\n`);
450
+ } else {
451
+ process.stderr.write(`[conxa] Plugin '${slug}' was not in registry\n`);
452
+ }
453
+ } finally { _releaseLock(); }
357
454
  }
358
455
 
359
456
  // ─── list ─────────────────────────────────────────────────────────────────────
@@ -376,7 +473,11 @@ async function runCli(argv) {
376
473
  const [cmd, ...rest] = argv;
377
474
  try {
378
475
  switch (cmd) {
379
- case "init": init(); break;
476
+ case "init":
477
+ case "update":
478
+ if (cmd === "update" && fs.existsSync(VERSION_JSON)) fs.unlinkSync(VERSION_JSON);
479
+ init();
480
+ break;
380
481
  case "install": await install(rest[0]); break;
381
482
  case "uninstall": uninstall(rest[0]); break;
382
483
  case "list": list(); break;
@@ -387,7 +488,7 @@ async function runCli(argv) {
387
488
  else throw new Error("registry: login <url> <token> | logout <url>");
388
489
  break;
389
490
  default:
390
- process.stderr.write("Usage: conxa <init|install <ref>|uninstall <slug>|list|search <q>|registry login|registry logout>\n");
491
+ process.stderr.write("Usage: conxa <init|update|install <ref>|uninstall <slug>|list|search <q>|registry login|registry logout>\n");
391
492
  process.exit(1);
392
493
  }
393
494
  } catch (e) {
package/lib/config.js CHANGED
@@ -21,18 +21,31 @@ function getAuthJson(slug) {
21
21
 
22
22
  function getPluginConfig(slug) {
23
23
  const cfgPath = path.join(getPluginDir(slug), "plugin.json");
24
- return JSON.parse(fs.readFileSync(cfgPath, "utf8"));
24
+ try { return JSON.parse(fs.readFileSync(cfgPath, "utf8")); }
25
+ catch (e) { throw new Error(`Plugin "${slug}" has a malformed plugin.json: ${e.message}`); }
25
26
  }
26
27
 
27
28
  function getRegistry() {
28
29
  if (!fs.existsSync(REGISTRY_PATH)) return {};
29
- try { return JSON.parse(fs.readFileSync(REGISTRY_PATH, "utf8")); } catch (_) { return {}; }
30
+ try { return JSON.parse(fs.readFileSync(REGISTRY_PATH, "utf8")); }
31
+ catch (e) {
32
+ const bak = REGISTRY_PATH + ".bak";
33
+ try { fs.renameSync(REGISTRY_PATH, bak); } catch (_) {}
34
+ process.stderr.write(`[conxa] Warning: registry.json was corrupt (${e.message}) — backed up to registry.json.bak\n`);
35
+ return {};
36
+ }
30
37
  }
31
38
 
32
39
  function getRegistryAuth() {
33
- if (!fs.existsSync(REGISTRY_AUTH_PATH)) return { registries: [] };
34
- try { return JSON.parse(fs.readFileSync(REGISTRY_AUTH_PATH, "utf8")); }
35
- catch (_) { return { registries: [] }; }
40
+ const fromEnv = process.env.CONXA_REGISTRY_TOKEN;
41
+ let stored = { registries: [] };
42
+ if (fs.existsSync(REGISTRY_AUTH_PATH)) {
43
+ try { stored = JSON.parse(fs.readFileSync(REGISTRY_AUTH_PATH, "utf8")); }
44
+ catch (_) { stored = { registries: [] }; }
45
+ }
46
+ if (fromEnv && !stored.registries.some(r => r.token === fromEnv))
47
+ stored.registries = [{ name: "env", url: "*", token: fromEnv }, ...stored.registries];
48
+ return stored;
36
49
  }
37
50
 
38
51
  function writeRegistryAuth(payload) {
@@ -21,7 +21,10 @@ function _parseRef(ref) {
21
21
  const text = String(ref || "").trim();
22
22
  if (!text) return null;
23
23
  let m = _GH_OWNER_REPO.exec(text);
24
- if (m) return { url: `https://github.com/${m[1]}/${m[2]}.git`, version: m[3] || null, id: `${m[1]}/${m[2]}` };
24
+ if (m) {
25
+ const repo = m[2].replace(/\.git$/, "");
26
+ return { url: `https://github.com/${m[1]}/${repo}.git`, version: m[3] || null, id: `${m[1]}/${repo}` };
27
+ }
25
28
  m = _GIT_URL.exec(text);
26
29
  if (m) {
27
30
  const url = `${m[1]}.git`;
@@ -31,16 +34,31 @@ function _parseRef(ref) {
31
34
  return null;
32
35
  }
33
36
 
37
+ function _ensureGit() {
38
+ try { execFileSync("git", ["--version"], { stdio: "ignore" }); }
39
+ catch (e) {
40
+ if (e.code === "ENOENT") throw new Error("git is not installed — install it to use owner/repo plugin refs");
41
+ throw e;
42
+ }
43
+ }
44
+
34
45
  function _clone(url, version, destDir) {
35
46
  const args = ["clone", "--depth", "1"];
36
47
  if (version) args.push("--branch", version);
37
48
  args.push(url, destDir);
38
- execFileSync("git", args, { stdio: ["ignore", "pipe", "inherit"] });
49
+ try {
50
+ execFileSync("git", args, { stdio: ["ignore", "pipe", "inherit"], timeout: 60000 });
51
+ } catch (e) {
52
+ // Clean up partial clone so the next attempt starts fresh
53
+ try { require("fs").rmSync(destDir, { recursive: true, force: true }); } catch (_) {}
54
+ throw e;
55
+ }
39
56
  }
40
57
 
41
58
  async function resolve(pluginRef) {
42
59
  const parsed = _parseRef(pluginRef);
43
60
  if (!parsed) return null;
61
+ _ensureGit();
44
62
  const version = parsed.version || "main";
45
63
  const staged = cache.stagedDir(parsed.id, version);
46
64
  if (staged) return { staged_dir: staged, plugin_id: parsed.id, version, source: "git" };
@@ -50,6 +68,7 @@ async function resolve(pluginRef) {
50
68
  } catch (e) {
51
69
  // Retry without branch if version refspec is wrong (lets us still clone HEAD)
52
70
  if (parsed.version) {
71
+ console.error(`[git] Branch "${parsed.version}" not found — falling back to default branch`);
53
72
  try {
54
73
  const fallback = cache.ensureStagedDir(parsed.id, "main");
55
74
  _clone(parsed.url, null, fallback);
package/lib/run.js CHANGED
@@ -5,14 +5,17 @@ const { CONXA_HOME } = require("./config");
5
5
 
6
6
  // ─── Retry budget (L0) ────────────────────────────────────────────────────────
7
7
 
8
- const _retryBudget = new Map();
8
+ const _retryBudget = new Map(); // key → { count, ts }
9
9
  const RETRY_BUDGET_MAX = 5;
10
+ const RETRY_BUDGET_TTL = 10 * 60 * 1000; // reset after 10 min idle
10
11
 
11
12
  function checkRetryBudget(slug, stepIndex) {
12
- const key = `${slug}:${stepIndex}`;
13
- const attempts = (_retryBudget.get(key) || 0) + 1;
14
- _retryBudget.set(key, attempts);
15
- if (attempts > RETRY_BUDGET_MAX) {
13
+ const key = `${slug}:${stepIndex}`;
14
+ const now = Date.now();
15
+ const prev = _retryBudget.get(key);
16
+ const count = (prev && now - prev.ts < RETRY_BUDGET_TTL) ? prev.count + 1 : 1;
17
+ _retryBudget.set(key, { count, ts: now });
18
+ if (count > RETRY_BUDGET_MAX) {
16
19
  appendRecoveryEvent({ event: "retry_budget_exhausted", slug, step_index: stepIndex });
17
20
  console.error(`[recovery] L0 budget exhausted for ${key}`);
18
21
  return false;
@@ -32,8 +35,10 @@ const RECOVERY_LOG_MAX = 10 * 1024 * 1024;
32
35
 
33
36
  function appendRecoveryEvent(event) {
34
37
  try {
35
- if (fs.existsSync(RECOVERY_LOG) && fs.statSync(RECOVERY_LOG).size > RECOVERY_LOG_MAX)
38
+ if (fs.existsSync(RECOVERY_LOG) && fs.statSync(RECOVERY_LOG).size > RECOVERY_LOG_MAX) {
39
+ if (fs.existsSync(RECOVERY_LOG + ".1")) fs.renameSync(RECOVERY_LOG + ".1", RECOVERY_LOG + ".2");
36
40
  fs.renameSync(RECOVERY_LOG, RECOVERY_LOG + ".1");
41
+ }
37
42
  fs.appendFileSync(RECOVERY_LOG, JSON.stringify({ ts: new Date().toISOString(), ...event }) + "\n");
38
43
  } catch (_) {}
39
44
  }
@@ -224,9 +229,8 @@ async function runPlan(page, steps, inputs, startFrom, slug) {
224
229
  for (let i = startFrom; i < steps.length; i++) {
225
230
  const step = steps[i];
226
231
 
227
- // Settle: wait for DOM + network before each step
232
+ // Settle: wait for DOM before each step
228
233
  await page.waitForLoadState("domcontentloaded", { timeout: 5000 }).catch(() => {});
229
- await page.waitForLoadState("networkidle", { timeout: 3000 }).catch(() => {});
230
234
 
231
235
  // Pre-step screenshot (interactive steps only)
232
236
  const isInteractive = INTERACTIVE.has(step.type);
package/lib/server.js CHANGED
@@ -1,5 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
  "use strict";
3
+
4
+ // Require Node >=20
5
+ const _nodeMajor = parseInt(process.version.slice(1), 10);
6
+ if (_nodeMajor < 20) {
7
+ process.stderr.write(`[conxa] Node.js ${process.version} is too old — requires >=20. Update Node and run: npx -y @kiran_nandi_123/conxa init\n`);
8
+ process.exit(1);
9
+ }
10
+
3
11
  const { Server } = require("@modelcontextprotocol/sdk/server/index.js");
4
12
  const { StdioServerTransport } = require("@modelcontextprotocol/sdk/server/stdio.js");
5
13
  const { CallToolRequestSchema, ListToolsRequestSchema } = require("@modelcontextprotocol/sdk/types.js");
@@ -12,20 +20,124 @@ const _PID_FILE = path.join(os.homedir(), ".conxa", "runtime", "server.pid");
12
20
  try { fs.writeFileSync(_PID_FILE, String(process.pid)); } catch (_) {}
13
21
  const _cleanPid = () => { try { fs.unlinkSync(_PID_FILE); } catch (_) {} };
14
22
  process.on("exit", _cleanPid);
15
- process.on("SIGINT", () => process.exit(0));
16
- process.on("SIGTERM", () => process.exit(0));
17
23
 
18
24
  const { getPluginConfig, getPluginDir, getAuthJson, getRegistry } = require("./config");
19
- const { getCachedBrowser, isAuthenticated, getAuthContext, gracefulShutdown } = require("./browser");
25
+ const { getCachedBrowser, holdBrowser, releaseBrowserHold, isAuthenticated, getAuthContext, gracefulShutdown } = require("./browser");
20
26
  const {
21
27
  appendRecoveryEvent, interpolate, enrichStepsWithRecovery,
22
28
  waitForUrlState, runPlan, runSkill, checkRetryBudget, clearRetryBudget,
23
29
  } = require("./run");
24
30
 
31
+ // ─── Version helpers ──────────────────────────────────────────────────────────
32
+
33
+ const CONXA_HOME = path.join(os.homedir(), ".conxa");
34
+ const VERSION_JSON = path.join(CONXA_HOME, "runtime", "version.json");
35
+ const UPDATE_CHECK = path.join(CONXA_HOME, "last_update_check.json");
36
+ const NPM_PACKAGE = "@kiran_nandi_123/conxa";
37
+ const ONE_DAY_MS = 24 * 60 * 60 * 1000;
38
+
39
+ function _semverGte(installed, required) {
40
+ // Strip leading 'v', split on '.', ignore pre-release suffix (pre-release < release)
41
+ const parse = v => String(v || "0").replace(/^v/i, "").split(".").map((p, i) =>
42
+ i < 3 ? (parseInt(p, 10) || 0) : 0
43
+ );
44
+ const hasPre = v => /[-+]/.test(String(v || "").replace(/^v/i, ""));
45
+ const a = parse(installed), b = parse(required);
46
+ for (let i = 0; i < 3; i++) {
47
+ if (a[i] !== b[i]) return a[i] > b[i];
48
+ }
49
+ // Equal version numbers: pre-release is less than release
50
+ return !hasPre(installed) || hasPre(required);
51
+ }
52
+
53
+ function _installedVersion() {
54
+ try { return JSON.parse(fs.readFileSync(VERSION_JSON, "utf8")).version; } catch (_) { return null; }
55
+ }
56
+
57
+ function _shouldCheckToday() {
58
+ try {
59
+ const { last_check } = JSON.parse(fs.readFileSync(UPDATE_CHECK, "utf8"));
60
+ return (Date.now() - last_check) > ONE_DAY_MS;
61
+ } catch (_) { return true; }
62
+ }
63
+
64
+ let _needsRestart = false;
65
+
66
+ function _startupUpdateCheck() {
67
+ if (!_shouldCheckToday()) return;
68
+ const https = require("https");
69
+ const req = https.get(`https://registry.npmjs.org/${NPM_PACKAGE}/latest`, { timeout: 5000 }, (res) => {
70
+ let data = "";
71
+ res.on("data", chunk => { data += chunk; });
72
+ res.on("end", () => {
73
+ try {
74
+ fs.writeFileSync(UPDATE_CHECK, JSON.stringify({ last_check: Date.now() }));
75
+ const latest = JSON.parse(data).version;
76
+ const installed = _installedVersion();
77
+ if (!latest || !installed || installed === latest) return;
78
+ console.error(`[conxa] Auto-updating runtime v${installed} → v${latest}...`);
79
+ const { spawn } = require("child_process");
80
+ const proc = spawn(
81
+ process.execPath, ["-e", `require("child_process").execFileSync(process.execPath,[require.resolve("${NPM_PACKAGE}/lib/cli.js"),"init"],{stdio:"inherit"})`],
82
+ { detached: true, stdio: "ignore" }
83
+ );
84
+ proc.unref();
85
+ _needsRestart = true;
86
+ console.error(`[conxa] Update running in background. Restart Claude Code Desktop to apply v${latest}.`);
87
+ } catch (_) {}
88
+ });
89
+ });
90
+ req.on("error", () => {});
91
+ req.on("timeout", () => req.destroy());
92
+ }
93
+
94
+ _startupUpdateCheck();
95
+
96
+ async function _pluginUpdateCheck() {
97
+ if (!_shouldCheckToday()) return;
98
+ const registry = getRegistry();
99
+ const https = require("https");
100
+ for (const [slug, entry] of Object.entries(registry)) {
101
+ const ref = entry.source_ref;
102
+ if (!ref || !/^[^/]+\/[^/]+$/.test(ref.split("@")[0])) continue;
103
+ if (ref.includes("@")) continue; // version-pinned — don't auto-update
104
+ const ownerRepo = ref.split("@")[0];
105
+ const [owner, repo] = ownerRepo.split("/");
106
+ await new Promise((resolve) => {
107
+ const url = `https://raw.githubusercontent.com/${owner}/${repo}/main/plugin.json`;
108
+ const req = https.get(url, { timeout: 5000 }, (res) => {
109
+ let data = "";
110
+ res.on("data", chunk => { data += chunk; });
111
+ res.on("end", () => {
112
+ try {
113
+ const latest = JSON.parse(data).version;
114
+ if (!latest || latest === entry.version) return resolve();
115
+ console.error(`[conxa] Plugin update available: ${slug} v${entry.version} → v${latest}. Run: conxa install ${ownerRepo}`);
116
+ resolve();
117
+ } catch (_) { resolve(); }
118
+ });
119
+ });
120
+ req.on("error", () => resolve());
121
+ req.on("timeout", () => { req.destroy(); resolve(); });
122
+ });
123
+ }
124
+ }
125
+
126
+ _pluginUpdateCheck().catch(() => {});
127
+
25
128
  // ─── Registry helpers ─────────────────────────────────────────────────────────
26
129
 
27
- // Returns { slug → { pluginSlug, skill, skillDir } } for fast lookup
130
+ let _skillIndexCache = null;
131
+ let _skillIndexMtime = 0;
132
+
133
+ // Returns { slug → { pluginSlug, skill, skillDir } } — cached until registry.json changes
28
134
  function buildSkillIndex() {
135
+ const { REGISTRY_PATH } = require("./config");
136
+ try {
137
+ const mtime = fs.existsSync(REGISTRY_PATH) ? fs.statSync(REGISTRY_PATH).mtimeMs : 0;
138
+ if (_skillIndexCache && mtime === _skillIndexMtime) return _skillIndexCache;
139
+ _skillIndexMtime = mtime;
140
+ } catch (_) {}
29
141
  const registry = getRegistry();
30
142
  const index = {};
31
143
  for (const [pluginSlug, entry] of Object.entries(registry)) {
@@ -35,9 +147,17 @@ function buildSkillIndex() {
35
147
  index[key] = { pluginSlug, skill, skillDir: path.join(pluginDir, skill.path || `skills/${skill.slug}`) };
36
148
  }
37
149
  }
150
+ _skillIndexCache = index;
38
151
  return index;
39
152
  }
40
153
 
154
+ function invalidateSkillIndex() { _skillIndexCache = null; }
155
+
156
+ function _restartNotice() {
157
+ if (!_needsRestart) return null;
158
+ return "⚠️ Conxa runtime update is installing in the background. Restart Claude Code Desktop to apply it, then retry.";
159
+ }
160
+
41
161
  function resolveSkill(pluginSlug, skillSlug, index) {
42
162
  // Exact match with plugin prefix
43
163
  if (pluginSlug) {
@@ -49,12 +169,14 @@ function resolveSkill(pluginSlug, skillSlug, index) {
49
169
  return v;
50
170
  }
51
171
  }
52
- // Slug-only: match across all plugins
53
- for (const v of Object.values(index)) {
54
- if (v.skill.slug === skillSlug || v.skill.slug.replace(/-/g, "_") === skillSlug.replace(/-/g, "_"))
55
- return v;
56
- }
57
- return null;
172
+ // Slug-only: match across all plugins — warn if ambiguous
173
+ const norm = s => s.replace(/-/g, "_");
174
+ const matches = Object.values(index).filter(v =>
175
+ v.skill.slug === skillSlug || norm(v.skill.slug) === norm(skillSlug)
176
+ );
177
+ if (matches.length > 1)
178
+ console.error(`[conxa] Warning: skill "${skillSlug}" matches multiple plugins (${matches.map(v => v.pluginSlug).join(", ")}) — using first match. Specify plugin: to disambiguate.`);
179
+ return matches[0] || null;
58
180
  }
59
181
 
60
182
  // ─── MCP server ───────────────────────────────────────────────────────────────
@@ -169,10 +291,27 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
169
291
  return { tools };
170
292
  });
171
293
 
294
+
295
+ function _checkPluginRuntimeVersion(pluginSlug) {
296
+ try {
297
+ const pluginDir = getPluginDir(pluginSlug);
298
+ const manifest = JSON.parse(fs.readFileSync(path.join(pluginDir, "plugin.json"), "utf8"));
299
+ const minVer = manifest.runtime_min_version;
300
+ if (!minVer) return null;
301
+ const installed = _installedVersion();
302
+ if (!installed || _semverGte(installed, minVer)) return null;
303
+ return `Plugin "${pluginSlug}" requires conxa runtime >=${minVer} (installed: ${installed}). Run: npx -y ${NPM_PACKAGE} install ${pluginSlug}`;
304
+ } catch (_) { return null; }
305
+ }
306
+
172
307
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
173
308
  const { name, arguments: args } = request.params;
174
309
  const skillIndex = buildSkillIndex();
175
310
 
311
+ // Surface restart notice on first call after a background update completes
312
+ const restartMsg = _restartNotice();
313
+ if (restartMsg) return { content: [{ type: "text", text: restartMsg }] };
314
+
176
315
  // ── list_skills ───────────────────────────────────────────────────────────
177
316
  if (name === "list_skills") {
178
317
  const filterPlugin = args && args.plugin ? String(args.plugin) : null;
@@ -243,7 +382,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
243
382
  try {
244
383
  const installRef = version && !ref.includes("@") ? `${ref}@${version}` : ref;
245
384
  const entry = await cli.install(installRef);
246
- return { content: [{ type: "text", text: `Installed ${entry.slug} v${entry.version}. Skills: ${(entry.skills || []).map(s => s.slug).join(", ")}` }] };
385
+ invalidateSkillIndex();
386
+ return { content: [{ type: "text", text: `Installed ${entry.slug} v${entry.version}. Skills: ${(entry.skills || []).map(s => s.slug).join(", ")}. Call list_skills to see available skills.` }] };
247
387
  } catch (e) {
248
388
  return { content: [{ type: "text", text: `install_plugin failed: ${e.message}` }] };
249
389
  }
@@ -256,6 +396,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
256
396
  const cli = require("./cli");
257
397
  try {
258
398
  cli.uninstall(slug);
399
+ invalidateSkillIndex();
259
400
  return { content: [{ type: "text", text: `Uninstalled ${slug}` }] };
260
401
  } catch (e) {
261
402
  return { content: [{ type: "text", text: `uninstall_plugin failed: ${e.message}` }] };
@@ -312,6 +453,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
312
453
  const found = resolveSkill(pluginArg, slug, skillIndex);
313
454
  if (!found) return { content: [{ type: "text", text: `Skill not found: ${slug}. Call list_skills.` }] };
314
455
  const { skillDir, pluginSlug } = found;
456
+ const versionErr = _checkPluginRuntimeVersion(pluginSlug);
457
+ if (versionErr) return { content: [{ type: "text", text: versionErr }] };
315
458
  const rawExec = fs.existsSync(path.join(skillDir, "execution.json"))
316
459
  ? JSON.parse(fs.readFileSync(path.join(skillDir, "execution.json"), "utf8")) : null;
317
460
  const rawRec = fs.existsSync(path.join(skillDir, "recovery.json"))
@@ -329,6 +472,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
329
472
 
330
473
  // Determine which plugin to use for auth (first skill's plugin)
331
474
  const primaryPlugin = resolved[0].pluginSlug;
475
+ const uniquePlugins = [...new Set(resolved.map(r => r.pluginSlug))];
476
+ if (uniquePlugins.length > 1)
477
+ console.error(`[execute_plan] Warning: plan spans ${uniquePlugins.length} plugins (${uniquePlugins.join(", ")}) — only ${primaryPlugin} auth context will be used`);
332
478
 
333
479
  // ── Layer 0: Retry budget gate ────────────────────────────────────────
334
480
  if (resumeFrom > 0) {
@@ -344,6 +490,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
344
490
  } catch (authErr) {
345
491
  return { content: [{ type: "text", text: String(authErr) }] };
346
492
  }
493
+ holdBrowser(primaryPlugin);
347
494
 
348
495
  const runtimeLog = { consoleErrors: [], pageErrors: [], failedRequests: [] };
349
496
  const page = await _context.newPage();
@@ -399,8 +546,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
399
546
  // ── Success ───────────────────────────────────────────────────────────
400
547
  const authJson = getAuthJson(primaryPlugin);
401
548
  const state = await _context.storageState();
402
- fs.mkdirSync(path.dirname(authJson), { recursive: true });
403
- fs.writeFileSync(authJson, JSON.stringify(state, null, 2));
549
+ fs.mkdirSync(path.dirname(authJson), { recursive: true, mode: 0o700 });
550
+ fs.writeFileSync(authJson, JSON.stringify(state, null, 2), { encoding: "utf8", mode: 0o600 });
404
551
  const shot = await page.screenshot({ type: "png" }).catch(() => null);
405
552
  const url = page.url();
406
553
  await page.close().catch(() => {});
@@ -411,6 +558,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
411
558
  console.error(`[execute_plan] Done. URL: ${url}`);
412
559
  const content = [{ type: "text", text: `Done. URL: ${url}` }];
413
560
  if (shot) content.push({ type: "image", data: shot.toString("base64"), mimeType: "image/png" });
561
+ releaseBrowserHold(primaryPlugin);
414
562
  return { content };
415
563
 
416
564
  } catch (err) {
@@ -500,6 +648,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
500
648
  if (runtimeLog.failedRequests.length > 0) l5.push(`Failed requests:\n${JSON.stringify(runtimeLog.failedRequests,null, 2)}`);
501
649
  content.push({ type: "text", text: l5.join("\n") });
502
650
 
651
+ releaseBrowserHold(primaryPlugin);
503
652
  return { content };
504
653
  }
505
654
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kiran_nandi_123/conxa",
3
- "version": "1.0.3",
3
+ "version": "1.0.5",
4
4
  "description": "Conxa CLI — install and manage shared-runtime automation plugins",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -14,12 +14,26 @@ if ! command -v node &>/dev/null; then
14
14
  if command -v brew &>/dev/null; then
15
15
  brew install node
16
16
  else
17
- echo "[conxa] Homebrew not found. Install it first: https://brew.sh"
18
- exit 1
17
+ NODE_VER="20.18.0"
18
+ echo "[conxa] Downloading Node.js ${NODE_VER}..."
19
+ curl -fsSL "https://nodejs.org/dist/v${NODE_VER}/node-v${NODE_VER}.pkg" -o /tmp/_conxa_node.pkg
20
+ sudo installer -pkg /tmp/_conxa_node.pkg -target /
21
+ rm -f /tmp/_conxa_node.pkg
19
22
  fi
20
- else
23
+ elif command -v apt-get &>/dev/null; then
21
24
  curl -fsSL https://deb.nodesource.com/setup_lts.x | sudo -E bash -
22
25
  sudo apt-get install -y nodejs
26
+ elif command -v yum &>/dev/null; then
27
+ curl -fsSL https://rpm.nodesource.com/setup_lts.x | sudo bash -
28
+ sudo yum install -y nodejs
29
+ else
30
+ NODE_VER="20.18.0"
31
+ ARCH=$(uname -m)
32
+ [[ "$ARCH" == "x86_64" ]] && ARCH="x64"
33
+ [[ "$ARCH" == "aarch64" ]] && ARCH="arm64"
34
+ echo "[conxa] Downloading Node.js binary..."
35
+ curl -fsSL "https://nodejs.org/dist/v${NODE_VER}/node-v${NODE_VER}-linux-${ARCH}.tar.xz" \
36
+ | sudo tar -xJ -C /usr/local --strip-components=1
23
37
  fi
24
38
  fi
25
39