@jacebenson/jsn 4.3.7-dev.35 → 4.3.7-dev.37

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jacebenson/jsn",
3
- "version": "4.3.7-dev.35",
3
+ "version": "4.3.7-dev.37",
4
4
  "description": "A command-line interface for ServiceNow that follows the Unix philosophy: simple, composable, and scriptable.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,12 +1,13 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
+ import process from 'node:process';
3
4
  import { input, password as passwordPrompt, select } from '@inquirer/prompts';
4
5
  import { confirmDelete, formatRecordForDisplay, getStringField, interactiveList, canPrompt, assertSafeExactMatch } from '../helpers.js';
5
6
  import { FormatAuto } from '../output.js';
6
7
  import { declareCapabilities } from '../capabilities.js';
7
8
  import { resolveRecord, unwrapSysId } from '../resolve-record.js';
8
9
  import { detectDocker, defaultRun, installDockerMidServer, midInstallDirectory, normalizeMidName, validateMidCredentials } from '../midserver-install.js';
9
- import { detectDirectPlatform, installDirectMidServer, readDirectInstallation, removeDirectInstallation, runDirectInstallation } from '../midserver-direct.js';
10
+ import { detectDirectPlatform, followDirectLog, installDirectMidServer, readDirectInstallation, removeDirectInstallation, resolveDirectLogFile, runDirectInstallation } from '../midserver-direct.js';
10
11
 
11
12
  const AGENT_TABLE = 'ecc_agent';
12
13
  const QUEUE_TABLE = 'ecc_queue';
@@ -171,6 +172,40 @@ function midDiagnosticsCommand(kind, wrap) {
171
172
  };
172
173
  }
173
174
 
175
+ // Progress for long, otherwise-silent direct-install phases. Human terminals
176
+ // only, and on stderr, so the stdout envelope and --json output stay clean.
177
+ export function directProgress(app) {
178
+ const human = app.output?.getFormat() === FormatAuto && (canPrompt() || process.stderr.isTTY === true);
179
+ if (!human) return () => {};
180
+ const mib = bytes => (Number(bytes || 0) / (1024 * 1024)).toFixed(0);
181
+ return event => {
182
+ if (event.phase === 'download-start') {
183
+ process.stderr.write(`Downloading the official MID ZIP from ServiceNow (about 532 MiB).\n ${event.archiveURL}\n`);
184
+ } else if (event.phase === 'download') {
185
+ const percent = event.total ? ` (${Math.floor((event.received / event.total) * 100)}%)` : '';
186
+ process.stderr.write(`\r ${mib(event.received)} MiB${percent} downloaded...`);
187
+ if (event.done) process.stderr.write('\n');
188
+ } else if (event.phase === 'extract-start') {
189
+ process.stderr.write(`Extracting ${event.entries} files into ${event.installDir}...\n`);
190
+ } else if (event.phase === 'extract-done') {
191
+ process.stderr.write('Extraction complete. Configuring the MID Server...\n');
192
+ }
193
+ };
194
+ }
195
+
196
+ // A registration wait is minutes of silence otherwise; report elapsed progress.
197
+ export function registrationProgress(app, timeoutMs) {
198
+ const human = app.output?.getFormat() === FormatAuto && (canPrompt() || process.stderr.isTTY === true);
199
+ if (!human) return undefined;
200
+ const seconds = Math.round(timeoutMs / 1000);
201
+ let lastReport = 0;
202
+ return elapsedMs => {
203
+ if (elapsedMs - lastReport < 15000) return;
204
+ lastReport = elapsedMs;
205
+ process.stderr.write(`Waiting for ServiceNow to register the MID Server... ${Math.round(elapsedMs / 1000)}s of ${seconds}s\n`);
206
+ };
207
+ }
208
+
174
209
  async function listAgents(app, argv) {
175
210
  const columns = argv.columns
176
211
  ? [...new Set(argv.columns.split(',').map(column => column.trim()).filter(Boolean))]
@@ -266,10 +301,11 @@ async function promptMidCredentials(app, argv) {
266
301
  }
267
302
  }
268
303
 
269
- export async function waitForMidServerUp(app, name, timeoutMs, intervalMs) {
304
+ export async function waitForMidServerUp(app, name, timeoutMs, intervalMs, onWait) {
270
305
  const started = Date.now();
271
306
  const fullName = name;
272
307
  while (Date.now() - started <= timeoutMs) {
308
+ onWait?.(Date.now() - started);
273
309
  const params = new URLSearchParams({
274
310
  sysparm_query: `name=${fullName}`,
275
311
  sysparm_limit: '1',
@@ -308,14 +344,13 @@ function removeLocalMidServer(instanceURL, name, directory) {
308
344
  return true;
309
345
  }
310
346
 
311
- function showMidServerLogs(app, identifier, argv) {
347
+ async function showMidServerLogs(app, identifier, argv) {
312
348
  const installDir = argv.directory || midInstallDirectory(app.getEffectiveInstance(), identifier);
313
349
  const direct = readDirectInstallation(app.getEffectiveInstance(), identifier, argv.directory);
314
350
  if (direct) {
315
351
  if (!Number.isInteger(argv.tail) || argv.tail < 1 || argv.tail > 10000) throw new Error('Use --tail between 1 and 10000');
316
- if (argv.follow) throw new Error('Direct log follow is not supported; read logs again or use the native host log viewer');
317
- const logfile = path.join(installDir, 'agent', 'logs', 'agent0.log');
318
- if (!fs.existsSync(logfile)) throw new Error('No MID agent log yet; the installation may only be prepared');
352
+ const logfile = resolveDirectLogFile(installDir);
353
+ if (!logfile) throw new Error('No MID agent log yet; the installation may only be prepared');
319
354
  const stat = fs.lstatSync(logfile);
320
355
  if (!stat.isFile() || stat.isSymbolicLink()) throw new Error('Untrusted MID log path');
321
356
  const fd = fs.openSync(logfile, 'r');
@@ -324,8 +359,15 @@ function showMidServerLogs(app, identifier, argv) {
324
359
  const length = Math.min(stat.size, 1024 * 1024);
325
360
  const buffer = Buffer.alloc(length);
326
361
  fs.readSync(fd, buffer, 0, length, stat.size - length);
327
- text = buffer.toString('utf8').split('\n').slice(-argv.tail).join('\n');
362
+ const all = buffer.toString('utf8').split('\n');
363
+ if (all.length && all[all.length - 1] === '') all.pop();
364
+ text = all.slice(-argv.tail).join('\n');
328
365
  } finally { fs.closeSync(fd); }
366
+ if (argv.follow) {
367
+ // Following streams new lines directly; the JSON envelope would be unreachable.
368
+ process.stdout.write(`Tailing ${logfile} (Ctrl+C to stop)\n${text}\n`);
369
+ return followDirectLog(installDir);
370
+ }
329
371
  app.ok({ installation: 'direct', logfile, text, _formatted: `${text}\n` }, { summary: 'Local MID agent log (last 1 MiB maximum)' });
330
372
  return;
331
373
  }
@@ -377,10 +419,10 @@ export function midserversCmd(wrap) {
377
419
  describe: 'Show local native or Docker Compose MID logs',
378
420
  builder: (b) => b
379
421
  .option('tail', { type: 'number', default: 100, describe: 'Number of log lines' })
380
- .option('follow', { type: 'boolean', default: false, describe: 'Follow the logs (Compose only)' })
422
+ .option('follow', { type: 'boolean', default: false, describe: 'Follow the logs' })
381
423
  .option('directory', { type: 'string', describe: 'Local installation directory override' }),
382
424
  handler: wrap(async (argv, app) => {
383
- showMidServerLogs(app, argv.identifier, argv);
425
+ await showMidServerLogs(app, argv.identifier, argv);
384
426
  }),
385
427
  })
386
428
  .command({
@@ -388,10 +430,10 @@ export function midserversCmd(wrap) {
388
430
  describe: 'Show local native or Docker Compose MID logs',
389
431
  builder: (b) => b
390
432
  .option('tail', { type: 'number', default: 100, describe: 'Number of log lines' })
391
- .option('follow', { type: 'boolean', default: false, describe: 'Follow the logs (Compose only)' })
433
+ .option('follow', { type: 'boolean', default: false, describe: 'Follow the logs' })
392
434
  .option('directory', { type: 'string', describe: 'Local installation directory override' }),
393
435
  handler: wrap(async (argv, app) => {
394
- showMidServerLogs(app, argv.identifier, argv);
436
+ await showMidServerLogs(app, argv.identifier, argv);
395
437
  }),
396
438
  })
397
439
  .command({
@@ -472,15 +514,24 @@ export function midserversCmd(wrap) {
472
514
  const existing = await app.sdk.list(AGENT_TABLE, new URLSearchParams({ sysparm_query: `name=${shortName}`, sysparm_fields: 'sys_id,name', sysparm_limit: '1' }));
473
515
  if (existing.length) throw new Error(`MID '${shortName}' already exists in ServiceNow; choose a new name. Existing runtime and record were not changed.`);
474
516
  const installation = await installDirectMidServer({
475
- instanceURL: app.getEffectiveInstance(), ...credentials, name, directory: argv.directory, runNow: decision === 'start',
517
+ instanceURL: app.getEffectiveInstance(), ...credentials, name, directory: argv.directory,
518
+ runNow: decision === 'start', onProgress: directProgress(app),
476
519
  });
477
520
  if (installation.state === 'prepared') {
478
521
  app.ok(installation, { summary: `Prepared native MID '${installation.name}' in ${installation.installDir}; not started or registered. Start with jsn midservers start ${installation.name}${argv.directory ? ' --directory ' + JSON.stringify(installation.installDir) : ''}. Windows start runs in the foreground console; no service is installed.` });
479
522
  return;
480
523
  }
481
524
  let record;
482
- try { record = await waitForMidServerUp(app, installation.name, Math.max(1, argv['wait-timeout']), Math.max(500, argv['wait-interval'])); }
525
+ const directWait = Math.max(1, argv['wait-timeout']);
526
+ const directInterval = Math.max(500, argv['wait-interval']);
527
+ const onWait = registrationProgress(app, directWait);
528
+ const started = Date.now();
529
+ try {
530
+ record = await waitForMidServerUp(app, installation.name, directWait, directInterval, onWait);
531
+ onWait?.(directWait);
532
+ }
483
533
  catch {
534
+ onWait?.(Math.max(directWait, Date.now() - started));
484
535
  app.ok({ ...installation, registration: 'pending', validated: false }, { summary: `Native MID start command completed; registration is pending. Runtime retained for diagnostics at ${installation.installDir}; no forced teardown.` });
485
536
  return;
486
537
  }
@@ -71,20 +71,28 @@ function checkParents(directory, platform) {
71
71
  }
72
72
  }
73
73
 
74
- export async function downloadMidZip(url, archive, fetchImpl = fetch) {
74
+ export async function downloadMidZip(url, archive, fetchImpl = fetch, onProgress) {
75
75
  const response = await fetchImpl(url, { redirect: 'error', signal: AbortSignal.timeout(600000) });
76
76
  if (!response.ok || !response.body) throw new Error(`MID ZIP download failed (HTTP ${response.status})`);
77
77
  const length = Number(response.headers.get('content-length'));
78
78
  if (length > MAX_ARCHIVE) throw new Error('MID ZIP exceeds 1 GiB download limit');
79
+ onProgress?.({ phase: 'download', received: 0, total: Number.isFinite(length) ? length : 0 });
79
80
  const fd = fs.openSync(archive, 'wx', 0o600);
80
81
  let size = 0;
82
+ let reported = 0;
83
+ const step = Math.max(4 * 1024 * 1024, Math.floor((Number.isFinite(length) ? length : MAX_ARCHIVE) / 100));
81
84
  try {
82
85
  for await (const chunk of response.body) {
83
86
  size += chunk.length;
84
87
  if (size > MAX_ARCHIVE) throw new Error('MID ZIP exceeds 1 GiB download limit');
85
88
  fs.writeSync(fd, chunk);
89
+ if (onProgress && size - reported >= step) {
90
+ reported = size;
91
+ onProgress({ phase: 'download', received: size, total: Number.isFinite(length) ? length : 0 });
92
+ }
86
93
  }
87
94
  if (length && size !== length) throw new Error('Incomplete MID ZIP download');
95
+ onProgress?.({ phase: 'download', received: size, total: size, done: true });
88
96
  } finally { fs.closeSync(fd); }
89
97
  }
90
98
 
@@ -145,6 +153,55 @@ export function readDirectInstallation(instanceURL, name, directory) {
145
153
  return metadata;
146
154
  }
147
155
 
156
+ // The vendor rotates agent0.log to agent0.log.0 (and upward), so the plain
157
+ // name may not exist on a healthy install. Newest real file wins; links and
158
+ // non-files are never followed.
159
+ export function resolveDirectLogFile(installDir) {
160
+ const directory = path.join(installDir, 'agent', 'logs');
161
+ let names;
162
+ try { names = fs.readdirSync(directory); } catch (error) { if (error.code === 'ENOENT') return null; throw error; }
163
+ const candidates = [];
164
+ for (const name of names) {
165
+ const agent = /^agent0\.log(\.\d+)?$/.test(name);
166
+ if (!agent && name !== 'wrapper.log') continue;
167
+ const full = path.join(directory, name);
168
+ const stat = fs.lstatSync(full);
169
+ if (!stat.isFile() || stat.isSymbolicLink()) continue;
170
+ candidates.push({ full, agent, mtime: stat.mtimeMs });
171
+ }
172
+ candidates.sort((a, b) => (b.agent - a.agent) || (b.mtime - a.mtime));
173
+ return candidates.length ? candidates[0].full : null;
174
+ }
175
+
176
+ /**
177
+ * Tail a direct install's agent log, following rotation.
178
+ *
179
+ * The vendor rotates by creating a new file, so the followed path can change
180
+ * between polls; a shrinking file is treated as truncation and re-read from
181
+ * the start. Each appended chunk is handed to `writer` unchanged.
182
+ */
183
+ export async function followDirectLog(installDir, { writer = chunk => process.stdout.write(chunk), intervalMs = 1000, iterations = Infinity, sleep } = {}) {
184
+ const pause = sleep || (ms => new Promise(resolve => setTimeout(resolve, ms)));
185
+ let file = resolveDirectLogFile(installDir);
186
+ if (!file) throw new Error('No MID agent log yet; the installation may only be prepared');
187
+ let offset = fs.statSync(file).size;
188
+ for (let seen = 0; seen < iterations; seen++) {
189
+ await pause(intervalMs);
190
+ const next = resolveDirectLogFile(installDir) || file;
191
+ if (next !== file) { file = next; offset = 0; }
192
+ const stat = fs.lstatSync(file);
193
+ if (!stat.isFile() || stat.isSymbolicLink()) return;
194
+ if (stat.size < offset) offset = 0;
195
+ if (stat.size === offset) continue;
196
+ const length = stat.size - offset;
197
+ const buffer = Buffer.alloc(length);
198
+ const fd = fs.openSync(file, 'r');
199
+ try { fs.readSync(fd, buffer, 0, length, offset); } finally { fs.closeSync(fd); }
200
+ offset = stat.size;
201
+ writer(buffer.toString('utf8'));
202
+ }
203
+ }
204
+
148
205
  function uncertainState(metadata) {
149
206
  return `MID '${metadata.name}' runtime state is uncertain after an interrupted lifecycle command. Confirm no vendor MID process from ${metadata.installDir} is running, then rerun; installed files are kept.`;
150
207
  }
@@ -189,7 +246,7 @@ export function removeDirectInstallation(metadata, run = defaultRun) {
189
246
  }
190
247
 
191
248
  export async function installDirectMidServer({ instanceURL, username, password, name, directory, releaseName: suppliedRelease,
192
- runNow = false, platform = process.platform, arch = process.arch, fetchImpl = fetch, download = downloadMidZip,
249
+ runNow = false, platform = process.platform, arch = process.arch, fetchImpl = fetch, download = downloadMidZip, onProgress,
193
250
  protect = protectDirectory, run = defaultRun }) {
194
251
  const native = detectDirectPlatform(platform, arch);
195
252
  if (native === 'linux' && process.getuid?.() === 0) throw new Error('Run direct MID installation as a non-root user');
@@ -216,9 +273,12 @@ export async function installDirectMidServer({ instanceURL, username, password,
216
273
  let prepared = false;
217
274
  try {
218
275
  protect(installDir, native);
219
- await download(archiveURL, archive, fetchImpl);
276
+ onProgress?.({ phase: 'download-start', archiveURL, platform: native, installDir, name: shortName });
277
+ await download(archiveURL, archive, fetchImpl, onProgress);
220
278
  const entries = inspectMidZip(archive);
279
+ onProgress?.({ phase: 'extract-start', entries: entries.length, installDir });
221
280
  await extractMidZip(archive, installDir, entries);
281
+ onProgress?.({ phase: 'extract-done', entries: entries.length, installDir });
222
282
  const agent = path.join(installDir, 'agent');
223
283
  for (const filename of ['config.xml', 'conf/wrapper-override.conf', native === 'linux' ? 'bin/mid.sh' : 'bin/mid.bat']) {
224
284
  if (!fs.statSync(path.join(agent, filename)).isFile()) throw new Error(`Vendor ZIP missing ${filename}`);