@velaro/cli 1.4.84 → 1.4.86

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/bin/velaro.js CHANGED
@@ -110,9 +110,15 @@ import { senderDomainHealthCommand } from '../lib/commands/sender-domain-health.
110
110
  import { contactsCommand } from '../lib/commands/contacts.js';
111
111
  import { contactListCommand } from '../lib/commands/contact-list.js';
112
112
  import { startUpdateCheck } from '../lib/update-check.js';
113
- import { printBannerIfFirstRun } from '../lib/banner.js';
113
+ import { printBanner, printBannerIfFirstRun } from '../lib/banner.js';
114
+ import { createRequire } from 'module';
114
115
 
115
- printBannerIfFirstRun();
116
+ // Banner on first run, and on the "front door" commands (bare `velaro`, help, login, update).
117
+ // Never on data commands or --version: their output is parsed by scripts.
118
+ const cliVersion = createRequire(import.meta.url)('../package.json').version;
119
+ const firstArg = process.argv[2];
120
+ const frontDoor = !firstArg || ['help', '--help', '-h', 'login', 'update'].includes(firstArg);
121
+ if (!printBannerIfFirstRun() && frontDoor) printBanner({ version: cliVersion });
116
122
 
117
123
  const printUpdateNoticeIfStale = startUpdateCheck();
118
124
 
package/lib/banner.js CHANGED
@@ -20,42 +20,52 @@ function configFileIsSafeToOverwrite() {
20
20
  }
21
21
  }
22
22
 
23
- // Pure ASCII (0-127) block-letter wordmark. Do not add box-drawing/unicode chars.
24
- const ART = [
25
- " __ __ ______ __ ______ ______ ______ ",
26
- "/\\ \\ /\\ \\/\\ ___\\/\\ \\ /\\ __ \\ /\\ == \\ /\\ __ \\ ",
27
- "\\ \\ \\___\\ \\ \\ \\ __\\\\ \\ \\____\\ \\ __ \\\\ \\ __< \\ \\ \\/\\ \\",
28
- " \\ \\_____\\ \\_\\ \\_____\\ \\_____\\\\ \\_\\ \\_\\\\ \\_\\ \\_\\\\ \\_____\\",
29
- " \\/_____/\\/_/\\/_____/\\/_____/ \\/_/\\/_/ \\/_/ /_/ \\/_____/",
30
- ];
23
+ // Pure ASCII (0-127) so it renders in every Windows/PowerShell codepage. Letters are composed from
24
+ // per-letter rows so the wordmark can never misalign; each row gets its own gradient colour.
25
+ const GLYPHS = {
26
+ V: ['__ __', '\\ \\ / /', ' \\ \\ / / ', ' \\ \\/ / ', ' \\ / ', ' \\/ '],
27
+ E: [' ______ ', '| ____|', '| |__ ', '| __| ', '| |____ ', '|______|'],
28
+ L: [' _ ', '| | ', '| | ', '| | ', '| |____ ', '|______| '],
29
+ A: [' ', ' /\\ ', ' / \\ ', ' / /\\ \\ ', ' / ____ \\ ', '/_/ \\_\\'],
30
+ R: [' _____ ', '| __ \\ ', '| |__) | ', '| _ / ', '| | \\ \\ ', '|_| \\_\\ '],
31
+ O: [' ____ ', ' / __ \\ ', '| | | | ', '| | | | ', '| |__| | ', ' \\____/ '],
32
+ };
33
+ const WORD = 'VELARO';
34
+ const ART = Array.from({ length: 6 }, (_, row) => WORD.split('').map((ch) => GLYPHS[ch][row]).join(' ').replace(/\s+$/, ''));
31
35
 
36
+ // Brand gradient, top to bottom: Velaro blue -> cyan -> green (ANSI 256 colours).
37
+ const GRADIENT = [27, 33, 39, 45, 49, 48];
32
38
  const TAGLINE = 'AI-native customer engagement -- command line';
33
39
 
34
- function colorize(line) {
35
- // Velaro brand blue/cyan (ANSI 256 color 39), reset after.
36
- return `\x1b[38;5;39m${line}\x1b[0m`;
40
+ function colorize(line, row) {
41
+ return `\x1b[1;38;5;${GRADIENT[row % GRADIENT.length]}m${line}\x1b[0m`;
37
42
  }
38
43
 
39
- export function printBanner() {
40
- // Non-TTY (piped/redirected output, e.g. `velaro ... | jq`) must stay
41
- // completely silent on stdout — machine-readable command output (several
42
- // commands emit JSON via console.log) must never be prefixed with banner
43
- // text. Only decorate a real interactive terminal.
44
+ /**
45
+ * Print the banner on an interactive terminal. Non-TTY (piped/redirected output, e.g.
46
+ * `velaro ... | jq`) stays completely silent on stdout - machine-readable output must never be
47
+ * prefixed with banner text.
48
+ */
49
+ export function printBanner({ version } = {}) {
44
50
  if (!process.stdout.isTTY) return;
45
51
 
46
- console.log('');
47
- for (const line of ART) {
48
- console.log(colorize(line));
49
- }
50
- console.log(colorize(` ${TAGLINE}`));
51
- console.log('');
52
+ const out = ['', ...ART.map((line, i) => colorize(line, i)),
53
+ `\x1b[38;5;245m ${TAGLINE}${version ? ` v${version}` : ''}\x1b[0m`, ''];
54
+ console.log(out.join('\n'));
55
+ }
56
+
57
+ /** Banner as a plain string for postinstall, which writes straight to the terminal device. */
58
+ export function bannerText(version) {
59
+ return ['', ...ART.map((line, i) => colorize(line, i)),
60
+ `\x1b[38;5;245m ${TAGLINE}${version ? ` v${version}` : ''}\x1b[0m`,
61
+ '\x1b[38;5;39m Installed. Run `velaro login` to get started, `velaro --help` for everything else.\x1b[0m', ''].join('\n');
52
62
  }
53
63
 
54
64
  export function printBannerIfFirstRun() {
55
65
  const cfg = readConfig();
56
66
 
57
67
  if (cfg && cfg._bannerShown === true) {
58
- return;
68
+ return false;
59
69
  }
60
70
 
61
71
  printBanner();
@@ -73,4 +83,5 @@ export function printBannerIfFirstRun() {
73
83
  // Non-fatal — worst case the banner shows again next run.
74
84
  }
75
85
  }
86
+ return true;
76
87
  }
@@ -0,0 +1,24 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { bannerText } from './banner.js';
4
+
5
+ const strip = (s) => s.replace(/\x1b\[[0-9;]*m/g, '');
6
+
7
+ test('banner is pure ASCII and spells VELARO in six rows', () => {
8
+ const text = strip(bannerText('9.9.9'));
9
+ assert.ok(/^[\x00-\x7f]*$/.test(text), 'banner must stay ASCII so every Windows codepage renders it');
10
+ const art = text.split('\n').filter((l) => /[\\/|_]/.test(l)).slice(0, 6);
11
+ assert.equal(art.length, 6);
12
+ assert.ok(text.includes('v9.9.9'));
13
+ });
14
+
15
+ test('banner stays silent when stdout is not a TTY (piped output must be machine-readable)', async () => {
16
+ const { printBanner } = await import('./banner.js');
17
+ const lines = [];
18
+ const orig = console.log;
19
+ const tty = process.stdout.isTTY;
20
+ console.log = (...a) => lines.push(a.join(' '));
21
+ process.stdout.isTTY = false;
22
+ try { printBanner({ version: '1' }); } finally { console.log = orig; process.stdout.isTTY = tty; }
23
+ assert.deepEqual(lines, []);
24
+ });
@@ -1,4 +1,4 @@
1
- import { get, post, messagingPost, getCredentials } from '../api.js';
1
+ import { get, post, messagingGet, messagingPost, getCredentials } from '../api.js';
2
2
  import { runCommand } from '../run.js';
3
3
 
4
4
  const VALID_STATUSES = ['Available', 'Away', 'Offline'];
@@ -32,8 +32,8 @@ export const agentCommand = {
32
32
  describe: 'List agents and their current availability',
33
33
  handler: runCommand(async () => {
34
34
  const [agents, teams] = await Promise.all([
35
- get('/Users/List'),
36
- get('/Teams/List'),
35
+ messagingGet('/Users/List'),
36
+ messagingGet('/Teams/List'),
37
37
  ]);
38
38
 
39
39
  const teamMap = Object.fromEntries(teams.map(t => [t.id, t.name]));
@@ -104,7 +104,7 @@ export const agentCommand = {
104
104
  .option('id', { type: 'number', demandOption: true, describe: 'Agent user ID' })
105
105
  .option('status', { type: 'string', demandOption: true, describe: 'Available | Away | Offline', choices: VALID_STATUSES }),
106
106
  handler: runCommand(async (argv) => {
107
- await post('/UserStatus/admin', { userId: argv.id, status: argv.status });
107
+ await messagingPost('/UserStatus/admin', { userId: argv.id, status: argv.status });
108
108
  console.log(`Agent ${argv.id} set to ${argv.status}.`);
109
109
  }),
110
110
  })
@@ -29,7 +29,9 @@ export const avatarCommand = {
29
29
  console.log(` Voice ID: ${data.voiceId || '(site default)'}`);
30
30
  console.log(` Enabled: ${data.enabled ? 'yes' : 'no'}`);
31
31
  console.log(` "Call me": ${data.showCallMeOption ? 'yes' : 'no'}`);
32
- console.log(` Callback window: ${data.callbackConfirmWindowMinutes} min\n`);
32
+ console.log(` Callback window: ${data.callbackConfirmWindowMinutes} min`);
33
+ console.log(` Max minutes/mo: ${data.maxAvatarMinutesPerMonth ?? '(plan default)'}`);
34
+ console.log(` Max TTS chars/mo: ${data.maxAvatarTtsChars ?? '(plan default)'}\n`);
33
35
  }),
34
36
  })
35
37
  .command({
@@ -71,5 +73,27 @@ export const avatarCommand = {
71
73
  console.log(`\n✓ AI Video Avatar disabled for site ${argv.site}\n`);
72
74
  }),
73
75
  })
74
- .demandCommand(1, 'Specify a subcommand: get | enable | disable'),
76
+ .command({
77
+ command: 'set',
78
+ describe: "Set a site's monthly avatar caps (finite only, audited)",
79
+ builder: y => y
80
+ .option('site', { type: 'number', demandOption: true, describe: 'Site ID' })
81
+ .option('max-minutes', { type: 'number', describe: 'Monthly avatar video minutes cap (whole number 1-100000)' })
82
+ .option('max-tts-chars', { type: 'number', describe: 'Monthly avatar TTS character cap (whole number 1-100000000)' }),
83
+ handler: runCommand(async argv => {
84
+ const body = {};
85
+ for (const [opt, key, max] of [['max-minutes', 'maxAvatarMinutesPerMonth', 100000], ['max-tts-chars', 'maxAvatarTtsChars', 100000000]]) {
86
+ if (argv[opt] === undefined) continue;
87
+ if (!Number.isInteger(argv[opt]) || argv[opt] < 1 || argv[opt] > max) throw new Error(`--${opt} must be a whole number between 1 and ${max} (never unlimited)`);
88
+ body[key] = argv[opt];
89
+ }
90
+ if (!Object.keys(body).length) throw new Error('Provide --max-minutes and/or --max-tts-chars');
91
+ const data = await request('POST', `/Avatar/superadmin/config?siteId=${argv.site}`, body);
92
+ if (!data.success) throw new Error(data.error || 'Set avatar caps failed');
93
+ console.log(`\n✓ Avatar caps updated for site ${argv.site}`);
94
+ console.log(` Max minutes/mo: ${data.maxAvatarMinutesPerMonth}`);
95
+ console.log(` Max TTS chars/mo: ${data.maxAvatarTtsChars}\n`);
96
+ }),
97
+ })
98
+ .demandCommand(1, 'Specify a subcommand: get | enable | disable | set'),
75
99
  };
@@ -21,7 +21,7 @@ export const checkCommand = {
21
21
  handler: runCommand(async () => {
22
22
  const [sub, apiStatus] = await Promise.all([
23
23
  getSubscription().catch(() => null),
24
- get('/Status').catch(() => null),
24
+ messagingGet('/api/health').catch(() => null),
25
25
  ]);
26
26
 
27
27
  if (!sub) {
@@ -37,13 +37,13 @@ export const checkCommand = {
37
37
  if (apiStatus) {
38
38
  ok(`API reachable`);
39
39
  } else {
40
- warn('API did not respond to /Status');
40
+ warn('Messaging API did not respond to /api/health');
41
41
  }
42
42
 
43
43
  // ── Deployments ─────────────────────────────────────────────────────────
44
44
  console.log('\n── Deployments ───────────────────────────────────────────────');
45
45
  try {
46
- const [deps, teams] = await Promise.all([get('/Deployment'), get('/Teams/List')]);
46
+ const [deps, teams] = await Promise.all([messagingGet('/Deployment'), messagingGet('/Teams/List')]);
47
47
  const teamMap = Object.fromEntries((teams ?? []).map(t => [t.id, t.name]));
48
48
  if (deps?.length) {
49
49
  for (const d of deps) {
@@ -75,7 +75,7 @@ export const checkCommand = {
75
75
  try {
76
76
  const [qna, overrides] = await Promise.all([
77
77
  messagingGet('/BotQnA').catch(() => []),
78
- get('/Ticket/knowledge-overrides').catch(() => []),
78
+ messagingGet('/Ticket/knowledge-overrides').catch(() => []),
79
79
  ]);
80
80
  ok(`${qna?.length ?? 0} Q&A pair(s) · ${overrides?.length ?? 0} override(s)`);
81
81
  } catch { warn('Could not load knowledge base'); }
@@ -87,7 +87,7 @@ export const checkCommand = {
87
87
  console.log('\n── Workflows ─────────────────────────────────────────────────');
88
88
  if (sub.enableAutomation) {
89
89
  try {
90
- const workflows = (await get('/Workflows/List') ?? []).filter(w => !w.isTemplate);
90
+ const workflows = (await messagingGet('/Workflows/List') ?? []).filter(w => !w.isTemplate);
91
91
  const enabled = workflows.filter(w => w.enabled).length;
92
92
  const disabled = workflows.length - enabled;
93
93
  if (workflows.length) {
@@ -108,7 +108,7 @@ export const checkCommand = {
108
108
  console.log('\n── Routing Rules ─────────────────────────────────────────────');
109
109
  if (sub.enableWorkflowRules) {
110
110
  try {
111
- const rules = await get('/Rules/List');
111
+ const rules = await messagingGet('/Rules/List');
112
112
  if (rules?.length) {
113
113
  ok(`${rules.length} routing rule(s) configured`);
114
114
  } else {
@@ -138,7 +138,7 @@ export const checkCommand = {
138
138
  // ── IVR detail ───────────────────────────────────────────────────────────
139
139
  if (sub.enableIvr) {
140
140
  try {
141
- const ivr = await get('/api/ivr/config');
141
+ const ivr = await messagingGet('/api/ivr/config');
142
142
  if (ivr?.phoneNumber) {
143
143
  console.log(`\n── IVR Config ────────────────────────────────────────────────`);
144
144
  ok(`Phone: ${ivr.phoneNumber}`);
@@ -151,7 +151,7 @@ export const checkCommand = {
151
151
  // ── Agents ───────────────────────────────────────────────────────────────
152
152
  console.log('\n── Agents ────────────────────────────────────────────────────');
153
153
  try {
154
- const agents = await get('/Users/List');
154
+ const agents = await messagingGet('/Users/List');
155
155
  const online = (agents ?? []).filter(a => a.status === 'Available').length;
156
156
  const away = (agents ?? []).filter(a => a.status === 'Away').length;
157
157
  const offline = (agents ?? []).length - online - away;
@@ -1,4 +1,4 @@
1
- import { get, post } from '../api.js';
1
+ import { messagingGet as get, messagingPost as post } from '../api.js';
2
2
  import { runCommand } from '../run.js';
3
3
 
4
4
  export const deploymentCommand = {
@@ -294,7 +294,7 @@ const overrideCommand = {
294
294
  describe: 'List knowledge overrides',
295
295
  handler: runCommand(async () => {
296
296
  await requireFeature('enableKnowledgeBase', 'Knowledge Base');
297
- const items = await get('/Ticket/knowledge-overrides');
297
+ const items = await messagingGet('/Ticket/knowledge-overrides');
298
298
  if (!items?.length) { console.log('No overrides found.'); return; }
299
299
 
300
300
  console.log(`\nFound ${items.length} override(s):\n`);
@@ -317,7 +317,7 @@ const overrideCommand = {
317
317
  await requireFeature('enableKnowledgeBase', 'Knowledge Base');
318
318
  const payload = { title: argv.title, content: argv.content, isActive: true };
319
319
  if (argv.expires) payload.expiresAt = new Date(argv.expires).toISOString();
320
- const result = await post('/Ticket/knowledge-overrides', payload);
320
+ const result = await messagingPost('/Ticket/knowledge-overrides', payload);
321
321
  console.log(`Override added: [${result.id}] "${argv.title}"`);
322
322
  }),
323
323
  })
@@ -327,7 +327,7 @@ const overrideCommand = {
327
327
  builder: (y) => y.positional('id', { type: 'number', describe: 'Override ID' }),
328
328
  handler: runCommand(async (argv) => {
329
329
  await requireFeature('enableKnowledgeBase', 'Knowledge Base');
330
- await del(`/Ticket/knowledge-overrides/${argv.id}`);
330
+ await messagingDel(`/Ticket/knowledge-overrides/${argv.id}`);
331
331
  console.log(`Override ${argv.id} deleted.`);
332
332
  }),
333
333
  })
@@ -10,7 +10,7 @@
10
10
  * velaro magento diagnose Live connection diagnostic (token, /rest/V1, scopes)
11
11
  */
12
12
 
13
- import { request } from '../api.js';
13
+ import { request, messagingRequest } from '../api.js';
14
14
 
15
15
  const GRN = '\x1b[32m';
16
16
  const RED = '\x1b[31m';
@@ -164,7 +164,7 @@ async function diagnoseHandler(argv) {
164
164
  }
165
165
  try {
166
166
  // Messaging DiagController: GET diagnostics/test-magento?siteId=<id>
167
- const r = await request('GET', `/diagnostics/test-magento?siteId=${encodeURIComponent(siteId)}`);
167
+ const r = await messagingRequest('GET', `/diagnostics/test-magento?siteId=${encodeURIComponent(siteId)}`);
168
168
  console.log(`\n${BOLD}Magento diagnostic (site ${siteId})${RST}\n`);
169
169
  console.log(JSON.stringify(r, null, 2));
170
170
  console.log('');
@@ -1,4 +1,4 @@
1
- import { get, post, del } from '../api.js';
1
+ import { messagingGet as get, messagingPost as post, messagingDel as del } from '../api.js';
2
2
  import { requireFeature } from '../subscription.js';
3
3
  import { runCommand } from '../run.js';
4
4
 
@@ -32,7 +32,7 @@ export const siteCommand = {
32
32
  handler: runCommand(async () => {
33
33
  const [sub, agents] = await Promise.all([
34
34
  messagingGet('/Subscription'),
35
- get('/Users/List'),
35
+ messagingGet('/Users/List'),
36
36
  ]);
37
37
 
38
38
  // Active features — friendly names only, no internal flag names
@@ -1,4 +1,4 @@
1
- import { get, post } from '../api.js';
1
+ import { messagingGet as get, messagingPost as post } from '../api.js';
2
2
  import { runCommand } from '../run.js';
3
3
 
4
4
  export const teamCommand = {
@@ -10,7 +10,7 @@
10
10
  * velaro woocommerce logs View integration activity logs
11
11
  */
12
12
 
13
- import { request } from '../api.js';
13
+ import { request, messagingRequest } from '../api.js';
14
14
 
15
15
  const GRN = '\x1b[32m';
16
16
  const RED = '\x1b[31m';
@@ -25,7 +25,7 @@ const RST = '\x1b[0m';
25
25
  async function statusHandler(_argv) {
26
26
  // /WooCommerce/billing/status resolves site from the JWT — no siteId param needed.
27
27
  // Returns: billingStatus, planName, oauthConnected, hasActiveSubscription.
28
- const r = await request('GET', `/WooCommerce/billing/status`);
28
+ const r = await messagingRequest('GET', `/WooCommerce/billing/status`);
29
29
  if (!r) {
30
30
  console.log('\nNo WooCommerce connection found for this site.\n');
31
31
  return;
@@ -49,7 +49,7 @@ async function connectHandler(argv) {
49
49
  return;
50
50
  }
51
51
  try {
52
- const r = await request('GET', `/WooCommerce/auth/start?storeUrl=${encodeURIComponent(argv.storeUrl)}`);
52
+ const r = await messagingRequest('GET', `/WooCommerce/auth/start?storeUrl=${encodeURIComponent(argv.storeUrl)}`);
53
53
  if (!r?.authUrl) {
54
54
  console.error(`${RED}✗ Server did not return an authUrl — check that the store URL is correct${RST}`);
55
55
  process.exitCode = 1;
@@ -70,7 +70,7 @@ async function connectHandler(argv) {
70
70
  async function disconnectHandler(argv) {
71
71
  const params = argv.site ? `?siteId=${argv.site}` : '';
72
72
  try {
73
- await request('POST', `/WooCommerce/auth/disconnect${params}`, {});
73
+ await messagingRequest('POST', `/WooCommerce/auth/disconnect${params}`, {});
74
74
  console.log(`\n${GRN}✓ WooCommerce store disconnected${RST}\n`);
75
75
  } catch (e) {
76
76
  console.error(`\n${RED}✗ Disconnect failed: ${e.message}${RST}\n`);
@@ -110,7 +110,7 @@ async function ordersHandler(argv) {
110
110
  }
111
111
  const q = argv.id ?? argv.email;
112
112
  const limit = Math.min(50, argv.limit ?? 10);
113
- const orders = await request('GET', `/WooCommerce/data/orders/search?q=${encodeURIComponent(q)}&limit=${limit}`);
113
+ const orders = await messagingRequest('GET', `/WooCommerce/data/orders?email=${encodeURIComponent(q)}&limit=${limit}`);
114
114
  if (!Array.isArray(orders) || orders.length === 0) {
115
115
  console.log(`\nNo orders found for "${q}".\n`);
116
116
  return;
@@ -135,7 +135,7 @@ async function productsHandler(argv) {
135
135
  return;
136
136
  }
137
137
  const limit = Math.min(50, argv.limit ?? 10);
138
- const products = await request('GET', `/WooCommerce/data/products?search=${encodeURIComponent(argv.query)}&limit=${limit}`);
138
+ const products = await messagingRequest('GET', `/WooCommerce/data/products?query=${encodeURIComponent(argv.query)}&limit=${limit}`);
139
139
  if (!Array.isArray(products) || products.length === 0) {
140
140
  console.log(`\nNo products found for "${argv.query}".\n`);
141
141
  return;
@@ -0,0 +1,28 @@
1
+ // Regression (2026-10-11): `velaro team list` / `deployment list` / `rule list` / `agent list` etc.
2
+ // 404'd on every environment because they called the ADMIN api for routes that only exist on the
3
+ // MESSAGING api. Admin has no controller for these prefixes; any command using admin get/post/del
4
+ // (or request()) for one of them is broken.
5
+ import test from 'node:test';
6
+ import assert from 'node:assert/strict';
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+ import { fileURLToPath } from 'node:url';
10
+
11
+ const dir = path.join(path.dirname(fileURLToPath(import.meta.url)), 'commands');
12
+ const MESSAGING_ONLY = ['/Teams/List', '/Deployment', '/Users/List', '/Workflows/List', '/Rules', '/Ticket/knowledge-overrides', '/UserStatus/admin', '/api/ivr/config', '/WooCommerce/billing', '/WooCommerce/auth', '/diagnostics/test-magento'];
13
+
14
+ test('messaging-only routes are never called through the admin api helpers', () => {
15
+ const offenders = [];
16
+ for (const f of fs.readdirSync(dir).filter(n => n.endsWith('.js'))) {
17
+ const src = fs.readFileSync(path.join(dir, f), 'utf8');
18
+ // `get`/`post`/`del` imported unaliased from api.js are the ADMIN helpers.
19
+ const imp = /import\s*\{([^}]*)\}\s*from\s*'\.\.\/api\.js'/.exec(src);
20
+ const specifiers = imp ? imp[1].split(',').map(s => s.trim()) : [];
21
+ const importsAdminHelpers = specifiers.some(s => ['get', 'post', 'del'].includes(s));
22
+ for (const route of MESSAGING_ONLY) {
23
+ const admin = new RegExp(String.raw`(?<![A-Za-z])(${importsAdminHelpers ? 'get|post|del|' : ''}request\('(?:GET|POST|PUT|DELETE)',)\s*\(?\s*['"\`]${route.replace(/[/.]/g, '\\$&')}`);
24
+ if (admin.test(src)) offenders.push(`${f}: ${route}`);
25
+ }
26
+ }
27
+ assert.deepEqual(offenders, [], `messaging-only routes called via the admin api:\n${offenders.join('\n')}`);
28
+ });
@@ -6,6 +6,9 @@
6
6
 
7
7
  import { readConfig, writeConfig } from './config.js';
8
8
  import { createRequire } from 'module';
9
+ import { spawn } from 'child_process';
10
+ import { realpathSync } from 'fs';
11
+ import { fileURLToPath } from 'url';
9
12
 
10
13
  const require = createRequire(import.meta.url);
11
14
  const { version: currentVersion } = require('../package.json');
@@ -43,16 +46,41 @@ export function startUpdateCheck() {
43
46
  };
44
47
  }
45
48
 
49
+ // A registry install lives under node_modules; a dev checkout (npm link) resolves to the repo.
50
+ export function isRegistryInstall() {
51
+ try {
52
+ return /[\\/]node_modules[\\/]@velaro[\\/]cli[\\/]/.test(realpathSync(fileURLToPath(import.meta.url)));
53
+ } catch {
54
+ return false;
55
+ }
56
+ }
57
+
46
58
  function printNoticeIfStale(latest) {
47
59
  if (!latest || latest === currentVersion) return;
48
60
  if (!isNewer(latest, currentVersion)) return;
49
61
 
62
+ // Keep registry installs current automatically (opt out: VELARO_NO_AUTO_UPDATE=1). Detached and
63
+ // silent so it never slows or breaks the command that just ran; the next run is the new version.
64
+ if (isRegistryInstall() && !process.env.VELARO_NO_AUTO_UPDATE && !process.env.CI) {
65
+ try {
66
+ const child = spawn(process.platform === 'win32' ? 'npm.cmd' : 'npm', ['install', '-g', '@velaro/cli@latest'],
67
+ { detached: true, stdio: 'ignore', shell: process.platform === 'win32' });
68
+ child.on('error', () => {});
69
+ child.unref();
70
+ process.stderr.write(`\n Velaro CLI ${currentVersion} → ${latest}: updating in the background (VELARO_NO_AUTO_UPDATE=1 to disable).\n\n`);
71
+ return;
72
+ } catch {
73
+ // fall through to the manual notice
74
+ }
75
+ }
76
+
50
77
  process.stderr.write(
51
78
  `\n Update available: ${currentVersion} → ${latest}\n` +
52
- ` Run: npm install -g @velaro/cli@latest\n\n`
79
+ (isRegistryInstall()
80
+ ? ` Run: npm install -g @velaro/cli@latest\n\n`
81
+ : ` This is a dev checkout (npm link): git pull in velaro-admin/cli, or switch to the registry install: npm install -g @velaro/cli@latest\n\n`)
53
82
  );
54
83
  }
55
-
56
84
  function isNewer(a, b) {
57
85
  const pa = a.split('.').map(Number);
58
86
  const pb = b.split('.').map(Number);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@velaro/cli",
3
- "version": "1.4.84",
3
+ "version": "1.4.86",
4
4
  "description": "Velaro Workspace v20 — command-line interface for managing bots, knowledge base ingestion, MCP API keys, search indexes, and ops health.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,12 +1,25 @@
1
- // Best-effort banner on install. Must NEVER throw — a postinstall failure
2
- // can fail the entire `npm install` for the end user.
1
+ // Best-effort banner on install/upgrade. Must NEVER throw - a postinstall failure can fail the
2
+ // whole `npm install`. npm hides a lifecycle script's stdout, so write straight to the terminal
3
+ // device (the same trick other CLIs use) and fall back to stderr.
4
+ import { openSync, writeSync, closeSync } from 'node:fs';
5
+ import { createRequire } from 'node:module';
6
+
3
7
  try {
4
- const { printBannerIfFirstRun } = await import('../lib/banner.js');
5
- printBannerIfFirstRun();
8
+ const { bannerText } = await import('../lib/banner.js');
9
+ const { version } = createRequire(import.meta.url)('../package.json');
10
+ const text = bannerText(version) + '\n';
11
+ let wrote = false;
12
+ try {
13
+ const fd = openSync(process.platform === 'win32' ? '\\\\.\\CON' : '/dev/tty', 'w');
14
+ try { writeSync(fd, text); wrote = true; } finally { closeSync(fd); }
15
+ } catch {
16
+ // no controlling terminal (CI, piped install) - fall through
17
+ }
18
+ if (!wrote && !process.env.CI) process.stderr.write(text);
6
19
  } catch (err) {
7
20
  try {
8
21
  process.stderr.write(`[velaro] postinstall banner skipped: ${err && err.message ? err.message : err}\n`);
9
22
  } catch {
10
- // swallow — nothing more we can do
23
+ // swallow - nothing more we can do
11
24
  }
12
25
  }