@mmmbuto/nexuscrew 0.8.0 → 0.8.3

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/cli/init.js CHANGED
@@ -8,6 +8,7 @@
8
8
  const fs = require('node:fs');
9
9
  const os = require('node:os');
10
10
  const path = require('node:path');
11
+ const crypto = require('node:crypto');
11
12
  const { detectPlatform, nodeBin, repoRoot, uid } = require('./platform.js');
12
13
  const { loadOrCreateToken } = require('../auth/token.js');
13
14
  const { generateService, installService, fileMode, installPath: svcInstallPath } = require('./service.js');
@@ -28,6 +29,24 @@ function nodeMajor() {
28
29
  return parseInt(String(process.versions.node).split('.')[0], 10);
29
30
  }
30
31
 
32
+ function writeConfigAtomic(configPath, value) {
33
+ try {
34
+ if (fs.lstatSync(configPath).isSymbolicLink()) throw new Error('refusing symlink config target');
35
+ } catch (e) {
36
+ if (e.code !== 'ENOENT') throw e;
37
+ }
38
+ fs.mkdirSync(path.dirname(configPath), { recursive: true });
39
+ const tmp = path.join(path.dirname(configPath), `.${path.basename(configPath)}.${crypto.randomBytes(6).toString('hex')}.tmp`);
40
+ try {
41
+ fs.writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
42
+ fs.chmodSync(tmp, 0o600);
43
+ fs.renameSync(tmp, configPath);
44
+ } catch (e) {
45
+ try { fs.unlinkSync(tmp); } catch (_) {}
46
+ throw e;
47
+ }
48
+ }
49
+
31
50
  // Migration rule (B2): se non c'è config.json, parse la porta dal service file esistente.
32
51
  function readExistingPort(platform, home, installPathOverride) {
33
52
  const p = installPathOverride || svcInstallPath(platform, home);
@@ -56,6 +75,7 @@ function runInit(opts = {}) {
56
75
  const tokenPath = opts.tokenPath || path.join(configDir, 'token');
57
76
  const filesRoot = opts.filesRoot || path.join(home, 'NexusFiles');
58
77
  const dryRun = !!opts.dryRun;
78
+ const installBoot = opts.installBoot !== false;
59
79
  const log = opts.log || (() => {});
60
80
  const tmuxOk = opts.tmuxOk !== undefined ? opts.tmuxOk : haveTmux(opts.tmuxBin || 'tmux');
61
81
 
@@ -78,24 +98,30 @@ function runInit(opts = {}) {
78
98
  fs.mkdirSync(configDir, { recursive: true });
79
99
  if (!fs.existsSync(configPath)) {
80
100
  if (!port) port = 41820;
81
- fs.writeFileSync(configPath, JSON.stringify({ port }, null, 2) + '\n', { mode: 0o600 });
101
+ writeConfigAtomic(configPath, { port });
82
102
  actions.push(`created config ${configPath} (port ${port})`);
83
103
  } else {
104
+ let current;
84
105
  try {
85
- const c = JSON.parse(fs.readFileSync(configPath, 'utf8'));
86
- if (c && c.port) port = c.port;
87
- } catch (_) {}
106
+ current = JSON.parse(fs.readFileSync(configPath, 'utf8'));
107
+ } catch (_) { current = {}; }
108
+ if (opts.port) {
109
+ current.port = opts.port;
110
+ port = opts.port;
111
+ writeConfigAtomic(configPath, current);
112
+ actions.push(`updated config ${configPath} (port ${port})`);
113
+ } else if (current && current.port) port = current.port;
88
114
  actions.push(`preserved config ${configPath} (port ${port})`);
89
115
  }
90
116
  }
91
117
 
92
- // Fleet app defaults: soltanto i due client nativi. Provider cloud/Z.AI sono
118
+ // Fleet app defaults: soltanto i tre client nativi. Provider cloud/Z.AI sono
93
119
  // disponibili nel catalogo managed ma vanno aggiunti esplicitamente.
94
120
  const fleetDefsPath = opts.fleetDefsPath || path.join(configDir, 'fleet.json');
95
121
  if (!dryRun && !fs.existsSync(fleetDefsPath)) {
96
122
  try {
97
123
  writeFleet(fleetDefsPath, defaultDefinitions());
98
- actions.push(`created fleet defaults ${fleetDefsPath} (claude.native, codex-vl.native)`);
124
+ actions.push(`created fleet defaults ${fleetDefsPath} (claude.native, codex.native, codex-vl.native)`);
99
125
  } catch (e) {
100
126
  actions.push(`WARN: fleet defaults non creati: ${e.message}`);
101
127
  }
@@ -144,7 +170,9 @@ function runInit(opts = {}) {
144
170
  };
145
171
  const content = generateService(platform, svcCtx);
146
172
 
147
- if (dryRun) {
173
+ if (!installBoot) {
174
+ actions.push(`boot opt-in: service ${platform} non installato`);
175
+ } else if (dryRun) {
148
176
  actions.push(`DRY-RUN service (${platform}) generato, NON installato`);
149
177
  } else if (!tmuxOk) {
150
178
  // tmux mancante: abort before service install (config/token gia' creati) [M8]
@@ -171,7 +199,9 @@ function runInit(opts = {}) {
171
199
  // principale: ogni errore del companion -> WARN action + return normale.
172
200
  try {
173
201
  const readonly = process.env.NEXUSCREW_READONLY === '1' || opts.readonly;
174
- if (readonly) {
202
+ if (!installBoot) {
203
+ actions.push('fleet companion: boot opt-in, non installato');
204
+ } else if (readonly) {
175
205
  actions.push('fleet companion: READONLY, non installato');
176
206
  } else {
177
207
  // provider mode: companion SOLO se builtin (§9b). selectProviderModeSync e'
@@ -226,7 +256,7 @@ function runInit(opts = {}) {
226
256
  }
227
257
 
228
258
  // Termux:boot best-effort detection (R4)
229
- if (platform === 'termux') {
259
+ if (platform === 'termux' && installBoot) {
230
260
  const bootDir = path.join(home, '.termux', 'boot');
231
261
  const bootOk = fs.existsSync(bootDir);
232
262
  actions.push(bootOk
@@ -246,4 +276,4 @@ function runInit(opts = {}) {
246
276
  return { platform, port, token, url: urlWithToken, actions, tmuxOk, dryRun };
247
277
  }
248
278
 
249
- module.exports = { runInit, readExistingPort, haveTmux, nodeMajor };
279
+ module.exports = { runInit, readExistingPort, haveTmux, nodeMajor, writeConfigAtomic };
@@ -7,8 +7,8 @@ const os = require('node:os');
7
7
  const path = require('node:path');
8
8
  const { execFileSync } = require('node:child_process');
9
9
 
10
- function defaultPidfilePath() {
11
- return process.env.NEXUSCREW_PIDFILE || path.join(os.homedir(), '.nexuscrew', 'nexuscrew.pid');
10
+ function defaultPidfilePath(home = os.homedir()) {
11
+ return process.env.NEXUSCREW_PIDFILE || path.join(home, '.nexuscrew', 'nexuscrew.pid');
12
12
  }
13
13
 
14
14
  function readPidfile(p) {
@@ -70,7 +70,6 @@ After=network-online.target
70
70
  [Service]
71
71
  Type=simple
72
72
  WorkingDirectory=${repo}
73
- Environment=NEXUSCREW_PORT=${ctx.port}
74
73
  Environment=PATH=${nodeDir}:/usr/local/bin:/usr/bin:/bin
75
74
  ExecStart=${node} ${repoBin} serve
76
75
  Restart=on-failure
@@ -86,7 +85,6 @@ function generateMac(ctx) {
86
85
  const repoBinXml = escapeXml(path.join(ctx.repoRoot, 'bin', 'nexuscrew.js'));
87
86
  const repoXml = escapeXml(ctx.repoRoot);
88
87
  const homeXml = escapeXml(ctx.home);
89
- const port = ctx.port;
90
88
  const launchPath = [...new Set([
91
89
  path.dirname(ctx.nodeBin), '/opt/homebrew/bin', '/usr/local/bin', '/usr/bin', '/bin',
92
90
  ])].join(':');
@@ -107,8 +105,6 @@ function generateMac(ctx) {
107
105
  <string>${repoXml}</string>
108
106
  <key>EnvironmentVariables</key>
109
107
  <dict>
110
- <key>NEXUSCREW_PORT</key>
111
- <string>${port}</string>
112
108
  <key>PATH</key>
113
109
  <string>${launchPathXml}</string>
114
110
  </dict>
@@ -136,7 +132,6 @@ function generateTermux(ctx) {
136
132
  # NexusCrew boot (Termux) - loopback, localhost del telefono
137
133
  export PATH=/data/data/com.termux/files/usr/bin:$PATH
138
134
  export HOME=/data/data/com.termux/files/home
139
- export NEXUSCREW_PORT=${ctx.port}
140
135
  cd -- ${repoQ}
141
136
  termux-wake-lock 2>/dev/null || true
142
137
  mkdir -p "$HOME/.nexuscrew"
@@ -8,7 +8,13 @@ const SCHEMA_VERSION = 1;
8
8
  const MAX_DECKS = 24;
9
9
  const MAX_TILES = 9;
10
10
  const NAME_RE = /^[a-z0-9-]{1,32}$/;
11
- const NODE_RE = /^[a-z0-9-]{1,32}$/;
11
+ const NODE_RE = /^[a-z0-9-]{1,32}(?:\/[a-z0-9-]{1,32}){0,3}$/;
12
+
13
+ function validNodeRoute(node) {
14
+ if (!NODE_RE.test(node)) return false;
15
+ const parts = node.split('/');
16
+ return new Set(parts).size === parts.length;
17
+ }
12
18
 
13
19
  function defaultDecksPath(home) {
14
20
  return path.join(home || os.homedir(), '.nexuscrew', 'decks.json');
@@ -41,7 +47,7 @@ function parseLayout(raw) {
41
47
  const tiles = [];
42
48
  for (const t of c.tiles) {
43
49
  if (!t || typeof t !== 'object' || Array.isArray(t) || !validText(t.session, 128)) return null;
44
- if (t.node !== undefined && (typeof t.node !== 'string' || !NODE_RE.test(t.node))) return null;
50
+ if (t.node !== undefined && (typeof t.node !== 'string' || !validNodeRoute(t.node))) return null;
45
51
  const height = Number(t.height);
46
52
  const fontSize = Number(t.fontSize);
47
53
  if (!Number.isFinite(height) || height < 0.2 || height > 100) return null;
@@ -9,7 +9,7 @@ const store = require('./store.js');
9
9
 
10
10
  // Router file-exchange. Nessuno stato: tutto deriva da cfg + filesystem.
11
11
  // notifier (opzionale, MCP bridge): emette la notify di consegna file outbox.
12
- function filesRoutes({ cfg, sessionExists, paste, notifier }) {
12
+ function filesRoutes({ cfg, sessionExists, paste, notifier, readonly = () => false }) {
13
13
  const router = Router();
14
14
  const upload = multer({
15
15
  storage: multer.memoryStorage(),
@@ -17,6 +17,7 @@ function filesRoutes({ cfg, sessionExists, paste, notifier }) {
17
17
  });
18
18
 
19
19
  router.post('/upload', (req, res) => {
20
+ if (readonly()) return res.status(403).json({ error: 'READONLY: upload bloccato' });
20
21
  upload.single('file')(req, res, async (err) => {
21
22
  if (err) {
22
23
  const status = err.code === 'LIMIT_FILE_SIZE' ? 413 : 400;
@@ -104,6 +105,7 @@ function filesRoutes({ cfg, sessionExists, paste, notifier }) {
104
105
  });
105
106
 
106
107
  router.delete('/', (req, res) => {
108
+ if (readonly()) return res.status(403).json({ error: 'READONLY: eliminazione file bloccata' });
107
109
  const ok = store.removeFile(
108
110
  cfg.filesRoot, String(req.query.session || ''), String(req.query.box || ''), String(req.query.name || ''),
109
111
  );
@@ -29,7 +29,7 @@ const {
29
29
  loadDefinitions, atomicWrite, CAPS,
30
30
  } = require('./definitions.js');
31
31
  const {
32
- CATALOG: MANAGED_CATALOG, describeManaged, resolveManagedEngine, discoverOllamaModels,
32
+ publicCatalog, describeManaged, resolveManagedEngine, discoverOllamaModels, discoverPiModels,
33
33
  } = require('./managed.js');
34
34
 
35
35
  const STATUS_TTL_MS = 2000;
@@ -273,6 +273,8 @@ async function createBuiltinFleet(cfg = {}) {
273
273
  });
274
274
  const needsOllama = cache.defs.engines.some((e) => e.managed?.provider === 'ollama-cloud');
275
275
  const ollamaModels = needsOllama ? await discoverOllamaModels({ ...cfg, home }) : [];
276
+ const needsPi = cache.defs.engines.some((e) => e.managed?.client === 'pi');
277
+ const piModels = needsPi ? await discoverPiModels({ ...cfg, home }) : {};
276
278
  const engines = cache.defs.engines.map((e) => {
277
279
  const managed = e.managed ? describeManaged(e.managed, { ...cfg, home }) : null;
278
280
  return {
@@ -280,7 +282,8 @@ async function createBuiltinFleet(cfg = {}) {
280
282
  ...(managed ? {
281
283
  kind: 'managed', client: managed.client, provider: managed.provider,
282
284
  model: e.managed.model || managed.defaultModel || '',
283
- models: managed.provider === 'ollama-cloud' ? ollamaModels : managed.models,
285
+ models: managed.provider === 'ollama-cloud' ? ollamaModels
286
+ : (managed.client === 'pi' ? (e.managed.provider === 'custom' ? [e.managed.model] : (piModels[e.managed.provider] || [])) : managed.models),
284
287
  configured: managed.configured, reason: managed.reason,
285
288
  } : { kind: 'custom', configured: true, model: e.model?.value || '', models: [] }),
286
289
  };
@@ -504,7 +507,7 @@ async function createBuiltinFleet(cfg = {}) {
504
507
  return out;
505
508
  }),
506
509
  cells: defs.cells.map((c) => ({ ...c, ...(c.models ? { models: { ...c.models } } : {}) })),
507
- managedCatalog: MANAGED_CATALOG.map((p) => ({ ...p })),
510
+ managedCatalog: publicCatalog(),
508
511
  managedConfig: { providerSecretsPath: cfg.providerSecretsPath || path.join(home, '.nexuscrew', 'providers.env') },
509
512
  };
510
513
  }
@@ -536,9 +539,16 @@ async function createBuiltinFleet(cfg = {}) {
536
539
  rc: { type: 'boolean', required: false, default: false },
537
540
  managed: {
538
541
  type: 'object', requiredFor: 'managed',
539
- client: { type: 'enum', values: ['claude', 'codex-vl'] },
540
- provider: { type: 'enum', values: ['native', 'ollama-cloud', 'zai-a', 'zai-p'] },
542
+ client: { type: 'enum', values: ['claude', 'codex', 'codex-vl', 'pi'] },
543
+ provider: { type: 'catalog', source: 'managedCatalog' },
544
+ credentialProfile: { type: 'string', required: false, max: 32 },
541
545
  model: { type: 'string', required: false, max: CAPS.MAX_MODEL_VAL_LEN },
546
+ permissionPolicy: { type: 'enum', values: ['standard', 'unsafe'], default: 'standard' },
547
+ displayName: { type: 'string', requiredFor: 'provider=custom', max: CAPS.MAX_LABEL_LEN },
548
+ protocol: { type: 'catalog', source: 'managedCatalog.protocol' },
549
+ baseUrl: { type: 'string', requiredFor: 'provider=custom', max: CAPS.MAX_COMMAND_LEN },
550
+ envKey: { type: 'string', requiredFor: 'provider=custom', pattern: '^[A-Za-z_][A-Za-z0-9_]{0,63}$' },
551
+ providerId: { type: 'string', requiredFor: 'provider=custom', pattern: '^[a-z][a-z0-9_-]{0,31}$' },
542
552
  },
543
553
  command: { type: 'string', requiredFor: 'custom', max: CAPS.MAX_COMMAND_LEN, absolute: true },
544
554
  args: { type: 'array', of: 'string', required: false, max: CAPS.MAX_ARGS, itemMax: CAPS.MAX_ARG_LEN },