@calltelemetry/cli 0.15.0 → 1.0.0

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.
@@ -0,0 +1,1586 @@
1
+ import chalk from 'chalk';
2
+ import prompts from 'prompts';
3
+ import { readFileSync, statSync } from 'node:fs';
4
+ import { basename } from 'node:path';
5
+ import { ApplianceApi, ApplianceApiError, DEFAULT_API_URL, DEFAULT_TIMEOUT_MS, isLocalBaseUrl, parsePositiveInt, readJsonOption, } from '../lib/appliance-api.js';
6
+ import { describeSchema, getOperation, listOperations, operationBodySchema, operationQueryParams, pathPlaceholders, validateAgainstSchema, } from '../lib/openapi.js';
7
+ import { addAccountContacts, buildCreateAccountPayload, buildDiscoverPayload, buildMonitoringPayload, buildUpdateAccountPayload, collectSftpStatus, getAccountActivity, getAccountContacts, getAccountFiles, getAccountMonitoring, getAccountQuota, getAccountRuns, listAccounts, listMonitoredAccounts, parseContactIds, readImportFile, removeAccountContact, requireOperation, resolveAccount, supportsOperation, } from '../lib/sftp.js';
8
+ import { checkTcpPort } from '../lib/network.js';
9
+ import { rpc } from '../lib/rpc.js';
10
+ import { redactSecrets } from '../lib/redact.js';
11
+ import { getLogLevel } from '../lib/log.js';
12
+ import * as out from '../utils/output.js';
13
+ import { formatBytes, formatDateTime, formatGb, formatPercent, formatQuota, renderTable, } from '../utils/table.js';
14
+ function collect(value, previous = []) {
15
+ return [...previous, value];
16
+ }
17
+ function parseIntegerOption(value, label, opts = {}) {
18
+ if (value === undefined || value === '')
19
+ return opts.fallback;
20
+ const parsed = Number(value);
21
+ const min = opts.min ?? 0;
22
+ const max = opts.max ?? Number.MAX_SAFE_INTEGER;
23
+ if (!Number.isInteger(parsed) || parsed < min || parsed > max) {
24
+ throw new ApplianceApiError(`${label} must be an integer between ${min} and ${max}.`);
25
+ }
26
+ return parsed;
27
+ }
28
+ function parsePair(value, label) {
29
+ const index = value.indexOf('=');
30
+ if (index <= 0) {
31
+ throw new ApplianceApiError(`${label} must use key=value syntax: ${value}`);
32
+ }
33
+ return [value.slice(0, index), value.slice(index + 1)];
34
+ }
35
+ function parsePairs(values, label) {
36
+ const result = {};
37
+ for (const value of values ?? []) {
38
+ const [key, raw] = parsePair(value, label);
39
+ result[key] = raw;
40
+ }
41
+ return result;
42
+ }
43
+ function printJson(value) {
44
+ console.log(JSON.stringify(value, null, 2));
45
+ }
46
+ function printError(err) {
47
+ const message = err instanceof Error ? err.message : String(err);
48
+ console.error(chalk.red(' ✗'), redactSecrets(message));
49
+ if (getLogLevel() === 'debug' && err instanceof Error && err.stack) {
50
+ console.error(chalk.gray(err.stack));
51
+ }
52
+ }
53
+ /** Wrap a Commander action with friendly error output and a non-zero exit code. */
54
+ function action(fn) {
55
+ return async (...args) => {
56
+ try {
57
+ await fn(...args);
58
+ }
59
+ catch (err) {
60
+ printError(err);
61
+ process.exitCode = 1;
62
+ }
63
+ };
64
+ }
65
+ function addApiOptions(command, withJson = false) {
66
+ command
67
+ .option('--url <url>', `Appliance API base URL (default: ${DEFAULT_API_URL})`)
68
+ .option('--token <token>', 'Machine API token for automation (prefer CT_API_TOKEN)')
69
+ .option('--email <email>', 'Admin email for interactive session login (or CT_API_EMAIL)')
70
+ .option('--org <id>', 'Organization ID (or CT_ORG_ID)')
71
+ .option('--timeout <ms>', 'Request timeout in milliseconds', String(DEFAULT_TIMEOUT_MS))
72
+ .option('--verify-tls', 'Verify the appliance TLS certificate');
73
+ if (withJson)
74
+ command.option('--json', 'Output JSON');
75
+ return command;
76
+ }
77
+ async function connect(options, opts = {}) {
78
+ return ApplianceApi.connect({
79
+ baseUrl: options.url,
80
+ token: options.token,
81
+ email: options.email,
82
+ orgId: parsePositiveInt(options.org, '--org'),
83
+ timeoutMs: parseIntegerOption(options.timeout, '--timeout', { min: 1000 }),
84
+ verifyTls: options.verifyTls ? true : undefined,
85
+ requireAuth: opts.requireAuth ?? true,
86
+ });
87
+ }
88
+ async function connectWithSpec(options) {
89
+ const api = await connect(options);
90
+ const spec = await api.fetchSpec();
91
+ return { api, spec };
92
+ }
93
+ function printKeyValues(rows) {
94
+ if (rows.length === 0)
95
+ return;
96
+ const width = Math.max(...rows.map(([key]) => key.length));
97
+ for (const [key, value] of rows) {
98
+ console.log(` ${chalk.gray(key.padEnd(width))} ${value}`);
99
+ }
100
+ }
101
+ function accountLabel(account) {
102
+ return `${account.username} (id ${account.id})`;
103
+ }
104
+ function actionSummary(actions) {
105
+ if (!Array.isArray(actions) || actions.length === 0)
106
+ return 'none';
107
+ return actions
108
+ .map(item => {
109
+ if (typeof item === 'object' && item !== null && 'type' in item) {
110
+ return String(item.type);
111
+ }
112
+ return String(item);
113
+ })
114
+ .join(', ');
115
+ }
116
+ function monitoringSchedule(monitoring) {
117
+ const schedule = monitoring?.schedule;
118
+ if (!schedule?.type)
119
+ return '—';
120
+ const time = schedule.hour !== null && schedule.hour !== undefined
121
+ ? ` ${String(schedule.hour).padStart(2, '0')}:${String(schedule.minute ?? 0).padStart(2, '0')}`
122
+ : '';
123
+ return `${schedule.type}${time}`;
124
+ }
125
+ function isTty() {
126
+ return Boolean(process.stdin.isTTY && process.stdout.isTTY);
127
+ }
128
+ async function promptHidden(message) {
129
+ if (!isTty())
130
+ return undefined;
131
+ const { value } = await prompts({ type: 'password', name: 'value', message });
132
+ return typeof value === 'string' && value.length > 0 ? value : undefined;
133
+ }
134
+ function readPasswordFromStdin() {
135
+ const value = readFileSync(0, 'utf-8').replace(/\r?\n$/, '');
136
+ if (!value)
137
+ throw new ApplianceApiError('No password received on stdin.');
138
+ return value;
139
+ }
140
+ async function resolveNewPassword(opts) {
141
+ if (opts.password)
142
+ return opts.password;
143
+ if (opts.passwordStdin)
144
+ return readPasswordFromStdin();
145
+ if (opts.generate)
146
+ return undefined;
147
+ const prompted = await promptHidden('SFTP password (leave blank to generate a secure password)');
148
+ return prompted;
149
+ }
150
+ function redactAccount(account) {
151
+ if (typeof account.password !== 'string')
152
+ return account;
153
+ return { ...account, password: '[REDACTED]' };
154
+ }
155
+ async function confirmDestructive(message, yes) {
156
+ if (yes)
157
+ return;
158
+ if (!isTty()) {
159
+ throw new ApplianceApiError('Refusing to continue without --yes in a non-interactive shell.');
160
+ }
161
+ const { value } = await prompts({
162
+ type: 'confirm',
163
+ name: 'value',
164
+ message,
165
+ initial: false,
166
+ });
167
+ if (!value)
168
+ throw new ApplianceApiError('Aborted.');
169
+ }
170
+ function printGeneratedPassword(password) {
171
+ if (!password)
172
+ return;
173
+ console.log();
174
+ console.log(` ${chalk.bold('Generated password:')} ${chalk.cyan(password)}`);
175
+ console.log(chalk.gray(' Save this now. It cannot be retrieved later.'));
176
+ }
177
+ // ── Status and doctor ────────────────────────────────────────────────────
178
+ async function sftpDaemonStatus(baseUrl) {
179
+ if (!isLocalBaseUrl(baseUrl)) {
180
+ return { ok: true, detail: 'remote (skipped)' };
181
+ }
182
+ try {
183
+ const output = await rpc('case Cdrcisco.CTSFTPD.status() do {:error, :down} -> IO.puts("down"); _ -> IO.puts("ok") end', 10_000);
184
+ const value = output.trim();
185
+ if (value === 'ok')
186
+ return { ok: true, detail: 'running' };
187
+ if (value === 'down')
188
+ return { ok: false, detail: 'not running' };
189
+ return { ok: false, detail: value || 'unknown' };
190
+ }
191
+ catch (err) {
192
+ return { ok: false, detail: `unavailable (${err.message})` };
193
+ }
194
+ }
195
+ function registerStatus(sftp) {
196
+ const status = addApiOptions(sftp
197
+ .command('status')
198
+ .description('Show SFTP accounts, storage, monitoring, and daemon status')
199
+ .option('--skip-daemon', 'Skip the local SFTP daemon RPC check'), true);
200
+ status.action(action(async (opts) => {
201
+ const { api, spec } = await connectWithSpec(opts);
202
+ const snapshot = await collectSftpStatus(api, spec);
203
+ const daemon = opts.skipDaemon
204
+ ? { ok: true, detail: 'skipped' }
205
+ : await sftpDaemonStatus(api.baseUrl);
206
+ if (opts.json) {
207
+ printJson({
208
+ api: api.describe(),
209
+ spec: { openapi: snapshot.operations.specOpenApi, sftp_operations: snapshot.operations.total },
210
+ daemon,
211
+ ...snapshot,
212
+ });
213
+ return;
214
+ }
215
+ out.header('SFTP Status');
216
+ out.ok(`API ${api.baseUrl} · org ${api.orgId}`);
217
+ out.ok(`OpenAPI ${snapshot.operations.specOpenApi ?? 'unknown'} · ${snapshot.operations.total} SFTP operations`);
218
+ if (daemon.detail.includes('skipped'))
219
+ out.info(`SFTP daemon check ${daemon.detail}`);
220
+ else if (daemon.ok)
221
+ out.ok(`SFTP daemon ${daemon.detail}`);
222
+ else
223
+ out.fail(`SFTP daemon ${daemon.detail}`);
224
+ out.info(`Accounts ${snapshot.accounts.total} total · ${snapshot.accounts.enabled} enabled · ` +
225
+ `${snapshot.accounts.disabled} disabled`);
226
+ if (snapshot.monitoring.available) {
227
+ const missing = snapshot.monitoring.missingUploads === null
228
+ ? ''
229
+ : ` · ${snapshot.monitoring.missingUploads} missing upload`;
230
+ out.info(`Monitoring ${snapshot.monitoring.monitoredCount} enabled${missing}`);
231
+ }
232
+ else {
233
+ out.warn('Monitoring is not available on this appliance release');
234
+ }
235
+ if (snapshot.storage) {
236
+ out.info(`Storage ${formatBytes(snapshot.storage.total_used_bytes)} used · ` +
237
+ `${formatGb(snapshot.storage.total_limit_gb)} limit · ` +
238
+ `${snapshot.storage.accounts_over_quota ?? 0} account(s) over quota`);
239
+ }
240
+ else {
241
+ out.warn('Storage status is not available on this appliance release');
242
+ }
243
+ console.log();
244
+ }));
245
+ }
246
+ function registerDoctor(sftp) {
247
+ const doctor = addApiOptions(sftp
248
+ .command('doctor')
249
+ .description('Run SFTP connectivity and API compatibility checks'), true);
250
+ doctor.action(action(async (opts) => {
251
+ const checks = [];
252
+ let api;
253
+ let spec;
254
+ try {
255
+ api = await connect(opts);
256
+ spec = await api.fetchSpec();
257
+ checks.push({
258
+ name: 'OpenAPI spec',
259
+ ok: true,
260
+ detail: `${spec.openapi ?? 'unknown'} · ${listOperations(spec, { contains: 'sftp' }).length} SFTP operations`,
261
+ });
262
+ }
263
+ catch (err) {
264
+ checks.push({
265
+ name: 'OpenAPI spec',
266
+ ok: false,
267
+ detail: err.message,
268
+ });
269
+ }
270
+ if (api) {
271
+ try {
272
+ const accounts = await listAccounts(api);
273
+ checks.push({
274
+ name: 'API authentication',
275
+ ok: true,
276
+ detail: `${accounts.length} account(s) readable`,
277
+ });
278
+ }
279
+ catch (err) {
280
+ checks.push({
281
+ name: 'API authentication',
282
+ ok: false,
283
+ detail: err.message,
284
+ });
285
+ }
286
+ if (spec && !supportsOperation(spec, 'GET sftp_storage/status')) {
287
+ checks.push({
288
+ name: 'Storage endpoint',
289
+ ok: true,
290
+ detail: 'not available on this release (skipped)',
291
+ });
292
+ }
293
+ else {
294
+ try {
295
+ await api.get('sftp_storage/status');
296
+ checks.push({ name: 'Storage endpoint', ok: true, detail: 'reachable' });
297
+ }
298
+ catch (err) {
299
+ checks.push({ name: 'Storage endpoint', ok: false, detail: err.message });
300
+ }
301
+ }
302
+ }
303
+ const daemon = await sftpDaemonStatus(api?.baseUrl ?? opts.url ?? DEFAULT_API_URL);
304
+ checks.push({ name: 'SFTP daemon', ok: daemon.ok, detail: daemon.detail });
305
+ const host = (() => {
306
+ try {
307
+ return new URL(api?.baseUrl ?? DEFAULT_API_URL).hostname;
308
+ }
309
+ catch {
310
+ return '127.0.0.1';
311
+ }
312
+ })();
313
+ const port = await checkTcpPort(host, 22, 3000);
314
+ checks.push({
315
+ name: `SFTP port 22 (${host})`,
316
+ ok: port.open,
317
+ detail: port.open ? `open in ${port.durationMs}ms` : port.error ?? 'closed',
318
+ });
319
+ if (opts.json) {
320
+ printJson({ api: api?.describe(), checks });
321
+ }
322
+ else {
323
+ out.header('SFTP Doctor');
324
+ for (const check of checks) {
325
+ const [summary, ...rest] = check.detail.split('\n');
326
+ if (check.ok) {
327
+ out.ok(`${check.name}: ${summary}`);
328
+ }
329
+ else {
330
+ out.fail(`${check.name}: ${summary}`);
331
+ for (const line of rest) {
332
+ if (line.trim())
333
+ console.log(chalk.gray(` ${line.trim()}`));
334
+ }
335
+ }
336
+ }
337
+ console.log();
338
+ }
339
+ if (checks.some(check => !check.ok))
340
+ process.exitCode = 1;
341
+ }));
342
+ }
343
+ // ── Accounts ─────────────────────────────────────────────────────────────
344
+ function registerAccounts(sftp) {
345
+ const accounts = sftp
346
+ .command('accounts')
347
+ .alias('account')
348
+ .description('Manage SFTP Action Accounts');
349
+ accounts.action(() => accounts.outputHelp());
350
+ addApiOptions(accounts
351
+ .command('list')
352
+ .description('List SFTP accounts')
353
+ .option('--enabled', 'Only enabled accounts')
354
+ .option('--disabled', 'Only disabled accounts')
355
+ .option('--search <text>', 'Filter by username or description'), true).action(action(async (opts) => {
356
+ const { api, spec } = await connectWithSpec(opts);
357
+ requireOperation(spec, 'GET sftp_action_accounts');
358
+ let list = await listAccounts(api);
359
+ if (opts.enabled)
360
+ list = list.filter(account => account.enabled);
361
+ if (opts.disabled)
362
+ list = list.filter(account => !account.enabled);
363
+ if (opts.search) {
364
+ const needle = opts.search.toLowerCase();
365
+ list = list.filter(account => account.username.toLowerCase().includes(needle) ||
366
+ (account.description ?? '').toLowerCase().includes(needle));
367
+ }
368
+ if (opts.json) {
369
+ printJson(list);
370
+ return;
371
+ }
372
+ if (list.length === 0) {
373
+ out.info('No SFTP accounts found.');
374
+ return;
375
+ }
376
+ console.log(renderTable(['USERNAME', 'STATUS', 'QUOTA', 'MONITOR', 'HOME', 'DESCRIPTION'], list.map(account => [
377
+ account.username,
378
+ account.enabled ? 'enabled' : 'disabled',
379
+ formatQuota(account.quota_used_bytes, account.quota_limit_gb),
380
+ account.upload_monitoring_enabled ? 'on' : 'off',
381
+ account.home ?? '—',
382
+ account.description ?? '—',
383
+ ]), { maxWidth: [28, 8, 22, 7, 30, 40] }));
384
+ console.log();
385
+ out.info(`${list.length} account(s)`);
386
+ }));
387
+ addApiOptions(accounts
388
+ .command('show')
389
+ .description('Show a single SFTP account')
390
+ .argument('<id|username>', 'Account ID or username')
391
+ .option('--actions', 'Include full action configuration'), true).action(action(async (ref, opts) => {
392
+ const { api, spec } = await connectWithSpec(opts);
393
+ requireOperation(spec, 'GET sftp_action_accounts/{id}');
394
+ const account = await resolveAccount(api, ref);
395
+ if (opts.json) {
396
+ printJson(account);
397
+ return;
398
+ }
399
+ out.header(`SFTP Account ${account.username}`);
400
+ printKeyValues([
401
+ ['ID', String(account.id)],
402
+ ['Status', account.enabled ? 'enabled' : 'disabled'],
403
+ ['Home', account.home ?? '—'],
404
+ ['Description', account.description ?? '—'],
405
+ ['Quota', formatQuota(account.quota_used_bytes, account.quota_limit_gb)],
406
+ [
407
+ 'Monitoring',
408
+ account.upload_monitoring_enabled
409
+ ? `${account.expected_schedule ?? 'enabled'}${account.expected_next_upload_at ? ` · next ${formatDateTime(account.expected_next_upload_at)}` : ''}`
410
+ : 'disabled',
411
+ ],
412
+ ['Last upload', formatDateTime(account.last_upload_at)],
413
+ ['On success', actionSummary(account.on_success_actions)],
414
+ ['On failure', actionSummary(account.on_failure_actions)],
415
+ ['Created', formatDateTime(account.inserted_at)],
416
+ ['Updated', formatDateTime(account.updated_at)],
417
+ ]);
418
+ if (opts.actions) {
419
+ console.log();
420
+ console.log(chalk.bold('On success actions:'));
421
+ console.log(JSON.stringify(account.on_success_actions ?? [], null, 2));
422
+ console.log(chalk.bold('On failure actions:'));
423
+ console.log(JSON.stringify(account.on_failure_actions ?? [], null, 2));
424
+ }
425
+ console.log();
426
+ }));
427
+ addApiOptions(accounts
428
+ .command('create')
429
+ .description('Create an SFTP account')
430
+ .requiredOption('--username <username>', 'SFTP username')
431
+ .option('--password <password>', 'Password (prefer --password-stdin)')
432
+ .option('--password-stdin', 'Read the password from stdin')
433
+ .option('--generate', 'Generate a random password')
434
+ .option('--description <text>', 'Description')
435
+ .option('--quota-gb <gb>', 'Per-account quota in GB, or "none" for the org default')
436
+ .option('--callmanager-id <id>', 'Link the account to a CallManager for DRF discovery')
437
+ .option('--disabled', 'Create the account disabled')
438
+ .option('--on-success <action>', 'Success action: type, JSON, or @file (repeatable)', collect, [])
439
+ .option('--on-failure <action>', 'Failure action: type, JSON, or @file (repeatable)', collect, [])
440
+ .option('--dry-run', 'Print the request without sending it'), true).action(action(async (opts) => {
441
+ const { api, spec } = await connectWithSpec(opts);
442
+ requireOperation(spec, 'POST sftp_action_accounts');
443
+ const password = await resolveNewPassword({
444
+ password: opts.password,
445
+ passwordStdin: opts.passwordStdin,
446
+ generate: opts.generate,
447
+ });
448
+ const { account, generatedPassword } = buildCreateAccountPayload({
449
+ username: opts.username,
450
+ password,
451
+ generatePassword: !password,
452
+ description: opts.description,
453
+ enabled: opts.disabled ? false : undefined,
454
+ quotaGb: opts.quotaGb,
455
+ callmanagerId: opts.callmanagerId,
456
+ onSuccess: opts.onSuccess,
457
+ onFailure: opts.onFailure,
458
+ });
459
+ if (opts.dryRun) {
460
+ console.log(`${chalk.bold('POST')} ${api.apiPath('sftp_action_accounts')}`);
461
+ printJson({ account: redactAccount(account) });
462
+ return;
463
+ }
464
+ const response = await api.post('sftp_action_accounts', { account });
465
+ const created = (response.data.account ?? response.data);
466
+ if (opts.json) {
467
+ printJson({ account: created, generated_password: generatedPassword });
468
+ return;
469
+ }
470
+ out.successBox(`Created SFTP account ${created.username ?? opts.username}`);
471
+ printKeyValues([
472
+ ['ID', String(created.id ?? '—')],
473
+ ['Home', String(created.home ?? '—')],
474
+ ['Status', created.enabled === false ? 'disabled' : 'enabled'],
475
+ ]);
476
+ printGeneratedPassword(generatedPassword);
477
+ console.log();
478
+ }));
479
+ addApiOptions(accounts
480
+ .command('update')
481
+ .description('Update an SFTP account (including password and actions)')
482
+ .argument('<id|username>', 'Account ID or username')
483
+ .option('--description <text>', 'Description')
484
+ .option('--quota-gb <gb>', 'Per-account quota in GB, or "none" for the org default')
485
+ .option('--callmanager-id <id>', 'Link the account to a CallManager for DRF discovery')
486
+ .option('--enable', 'Enable the account')
487
+ .option('--disable', 'Disable the account')
488
+ .option('--password <password>', 'New password (prefer --password-stdin)')
489
+ .option('--password-stdin', 'Read the new password from stdin')
490
+ .option('--generate', 'Generate a new random password')
491
+ .option('--on-success <action>', 'Success action: type, JSON, or @file (repeatable)', collect, [])
492
+ .option('--on-failure <action>', 'Failure action: type, JSON, or @file (repeatable)', collect, [])
493
+ .option('--clear-success-actions', 'Remove all success actions')
494
+ .option('--clear-failure-actions', 'Remove all failure actions')
495
+ .option('--dry-run', 'Print the request without sending it'), true).action(action(async (ref, opts) => {
496
+ const { api, spec } = await connectWithSpec(opts);
497
+ requireOperation(spec, 'PUT sftp_action_accounts/{id}');
498
+ const account = await resolveAccount(api, ref);
499
+ let password = opts.password;
500
+ if (!password && opts.passwordStdin)
501
+ password = readPasswordFromStdin();
502
+ const { account: payload, generatedPassword } = buildUpdateAccountPayload({
503
+ description: opts.description,
504
+ quotaGb: opts.quotaGb,
505
+ callmanagerId: opts.callmanagerId,
506
+ enable: opts.enable,
507
+ disable: opts.disable,
508
+ password,
509
+ generatePassword: opts.generate,
510
+ onSuccess: opts.onSuccess,
511
+ onFailure: opts.onFailure,
512
+ clearSuccess: opts.clearSuccessActions,
513
+ clearFailure: opts.clearFailureActions,
514
+ });
515
+ if (opts.dryRun) {
516
+ console.log(`${chalk.bold('PUT')} ${api.apiPath(`sftp_action_accounts/${account.id}`)}`);
517
+ printJson({ account: redactAccount(payload) });
518
+ return;
519
+ }
520
+ const response = await api.put(`sftp_action_accounts/${account.id}`, { account: payload });
521
+ const updated = (response.data.account ?? response.data);
522
+ if (opts.json) {
523
+ printJson({ account: updated, generated_password: generatedPassword });
524
+ return;
525
+ }
526
+ out.successBox(`Updated SFTP account ${updated.username ?? account.username}`);
527
+ printKeyValues([
528
+ ['Fields', Object.keys(payload).join(', ')],
529
+ ['Status', updated.enabled === false ? 'disabled' : 'enabled'],
530
+ ]);
531
+ printGeneratedPassword(generatedPassword);
532
+ console.log();
533
+ }));
534
+ addApiOptions(accounts
535
+ .command('delete')
536
+ .alias('rm')
537
+ .description('Delete an SFTP account')
538
+ .argument('<id|username>', 'Account ID or username')
539
+ .option('--yes', 'Skip the confirmation prompt'), true).action(action(async (ref, opts) => {
540
+ const { api, spec } = await connectWithSpec(opts);
541
+ requireOperation(spec, 'DELETE sftp_action_accounts/{id}');
542
+ const account = await resolveAccount(api, ref);
543
+ await confirmDestructive(`Delete SFTP account ${accountLabel(account)}?`, opts.yes);
544
+ await api.delete(`sftp_action_accounts/${account.id}`);
545
+ if (opts.json)
546
+ printJson({ deleted: true, id: account.id, username: account.username });
547
+ else
548
+ out.successBox(`Deleted SFTP account ${account.username}`);
549
+ }));
550
+ for (const [name, enabled] of [
551
+ ['enable', true],
552
+ ['disable', false],
553
+ ]) {
554
+ addApiOptions(accounts
555
+ .command(name)
556
+ .description(`${enabled ? 'Enable' : 'Disable'} an SFTP account`)
557
+ .argument('<id|username>', 'Account ID or username'), true).action(action(async (ref, opts) => {
558
+ const { api, spec } = await connectWithSpec(opts);
559
+ requireOperation(spec, 'PUT sftp_action_accounts/{id}');
560
+ const account = await resolveAccount(api, ref);
561
+ const response = await api.put(`sftp_action_accounts/${account.id}`, {
562
+ account: { enabled },
563
+ });
564
+ const updated = (response.data.account ?? response.data);
565
+ if (opts.json)
566
+ printJson(updated);
567
+ else
568
+ out.ok(`${account.username} ${enabled ? 'enabled' : 'disabled'}`);
569
+ }));
570
+ }
571
+ addApiOptions(accounts
572
+ .command('password')
573
+ .description('Set or generate an SFTP account password')
574
+ .argument('<id|username>', 'Account ID or username')
575
+ .option('--password <password>', 'New password (prefer --password-stdin)')
576
+ .option('--password-stdin', 'Read the new password from stdin')
577
+ .option('--generate', 'Generate a random password'), true).action(action(async (ref, opts) => {
578
+ const { api, spec } = await connectWithSpec(opts);
579
+ requireOperation(spec, 'PUT sftp_action_accounts/{id}');
580
+ const account = await resolveAccount(api, ref);
581
+ let password = opts.password;
582
+ if (!password && opts.passwordStdin)
583
+ password = readPasswordFromStdin();
584
+ if (!password && !opts.generate)
585
+ password = await promptHidden('New SFTP password');
586
+ let generatedPassword;
587
+ if (!password) {
588
+ generatedPassword = buildCreateAccountPayload({
589
+ username: account.username,
590
+ onSuccess: [],
591
+ onFailure: [],
592
+ }).account.password;
593
+ password = generatedPassword;
594
+ }
595
+ await api.put(`sftp_action_accounts/${account.id}`, { account: { password } });
596
+ if (opts.json) {
597
+ printJson({ id: account.id, username: account.username, generated_password: generatedPassword });
598
+ return;
599
+ }
600
+ out.ok(`Password updated for ${account.username}`);
601
+ printGeneratedPassword(generatedPassword);
602
+ }));
603
+ addApiOptions(accounts
604
+ .command('quota')
605
+ .description('Show (and optionally refresh) an account storage quota')
606
+ .argument('<id|username>', 'Account ID or username')
607
+ .option('--refresh', 'Recalculate the quota before showing it'), true).action(action(async (ref, opts) => {
608
+ const { api, spec } = await connectWithSpec(opts);
609
+ requireOperation(spec, 'GET sftp_action_accounts/{id}/quota');
610
+ const account = await resolveAccount(api, ref);
611
+ if (opts.refresh) {
612
+ requireOperation(spec, 'POST sftp_action_accounts/{id}/refresh_quota');
613
+ await api.post(`sftp_action_accounts/${account.id}/refresh_quota`, {});
614
+ }
615
+ const quota = await getAccountQuota(api, account);
616
+ if (opts.json) {
617
+ printJson({ account: account.username, ...quota });
618
+ return;
619
+ }
620
+ out.header(`Quota · ${account.username}`);
621
+ printKeyValues([
622
+ ['Used', formatBytes(quota.quota_used_bytes)],
623
+ ['Limit', formatGb(quota.quota_limit_gb)],
624
+ ['Usage', formatPercent(quota.quota_percentage)],
625
+ ['Over quota', quota.over_quota ? 'yes' : 'no'],
626
+ ['Calculated', formatDateTime(quota.last_calculated_at)],
627
+ ]);
628
+ console.log();
629
+ }));
630
+ addApiOptions(accounts
631
+ .command('files')
632
+ .description('List files in an account home directory')
633
+ .argument('<id|username>', 'Account ID or username')
634
+ .option('--path <path>', 'Directory path')
635
+ .option('--limit <n>', 'Maximum entries', '200'), true).action(action(async (ref, opts) => {
636
+ const { api, spec } = await connectWithSpec(opts);
637
+ requireOperation(spec, 'GET sftp_action_accounts/{account_id}/files');
638
+ const account = await resolveAccount(api, ref);
639
+ const result = await getAccountFiles(api, account, opts.path, parseIntegerOption(opts.limit, '--limit', { min: 1, max: 2000, fallback: 200 }));
640
+ if (opts.json) {
641
+ printJson({ account: account.username, ...result });
642
+ return;
643
+ }
644
+ if (result.entries.length === 0) {
645
+ out.info(`No files in ${result.path ?? account.home ?? `account ${account.username}`}`);
646
+ return;
647
+ }
648
+ console.log(renderTable(['TYPE', 'SIZE', 'MODIFIED', 'NAME'], result.entries.map(entry => [
649
+ entry.type === 'directory' ? 'dir' : 'file',
650
+ entry.type === 'directory' ? '—' : formatBytes(entry.size),
651
+ formatDateTime(entry.modified_at),
652
+ entry.name,
653
+ ]), { maxWidth: [5, 12, 20, 80] }));
654
+ console.log();
655
+ out.info(`${result.total ?? result.entries.length} entr${(result.total ?? result.entries.length) === 1 ? 'y' : 'ies'}`);
656
+ }));
657
+ addApiOptions(accounts
658
+ .command('download')
659
+ .description('Download a file from an account home directory')
660
+ .argument('<id|username>', 'Account ID or username')
661
+ .argument('<remote-path>', 'Path relative to the account home')
662
+ .option('--output <path>', 'Local destination path'), true).action(action(async (ref, remotePath, opts) => {
663
+ const { api, spec } = await connectWithSpec(opts);
664
+ requireOperation(spec, 'GET sftp_action_accounts/{account_id}/files/download');
665
+ const account = await resolveAccount(api, ref);
666
+ const outputPath = opts.output ?? basename(remotePath);
667
+ await api.get(`sftp_action_accounts/${account.id}/files/download`, {
668
+ query: { path: remotePath },
669
+ accept: 'application/octet-stream',
670
+ outputPath,
671
+ parse: 'none',
672
+ });
673
+ const size = statSync(outputPath).size;
674
+ if (opts.json)
675
+ printJson({ account: account.username, remote_path: remotePath, output: outputPath, size });
676
+ else
677
+ out.ok(`Downloaded ${remotePath} → ${outputPath} (${formatBytes(size)})`);
678
+ }));
679
+ addApiOptions(accounts
680
+ .command('runs')
681
+ .description('List action runs for an account')
682
+ .argument('<id|username>', 'Account ID or username')
683
+ .option('--limit <n>', 'Maximum runs', '50')
684
+ .option('--offset <n>', 'Offset', '0'), true).action(action(async (ref, opts) => {
685
+ const { api, spec } = await connectWithSpec(opts);
686
+ requireOperation(spec, 'GET sftp_action_accounts/{id}/runs');
687
+ const account = await resolveAccount(api, ref);
688
+ const runs = await getAccountRuns(api, account, parseIntegerOption(opts.limit, '--limit', { min: 1, max: 1000, fallback: 50 }), parseIntegerOption(opts.offset, '--offset', { min: 0, fallback: 0 }));
689
+ if (opts.json) {
690
+ printJson(runs);
691
+ return;
692
+ }
693
+ if (runs.length === 0) {
694
+ out.info(`No runs for ${account.username}.`);
695
+ return;
696
+ }
697
+ console.log(renderTable(['ID', 'STATUS', 'TYPE', 'EVENT', 'FILE', 'WHEN'], runs.map(run => [
698
+ String(run.id),
699
+ run.status ?? '—',
700
+ run.type ?? '—',
701
+ run.event ?? '—',
702
+ run.file ?? '—',
703
+ formatDateTime(run.inserted_at),
704
+ ]), { maxWidth: [8, 10, 22, 24, 50, 20] }));
705
+ console.log();
706
+ }));
707
+ addApiOptions(accounts
708
+ .command('activity')
709
+ .description('List audit activity for an account')
710
+ .argument('<id|username>', 'Account ID or username')
711
+ .option('--limit <n>', 'Maximum entries', '100')
712
+ .option('--offset <n>', 'Offset', '0'), true).action(action(async (ref, opts) => {
713
+ const { api, spec } = await connectWithSpec(opts);
714
+ requireOperation(spec, 'GET sftp_action_accounts/{id}/activity');
715
+ const account = await resolveAccount(api, ref);
716
+ const activity = await getAccountActivity(api, account, parseIntegerOption(opts.limit, '--limit', { min: 1, max: 1000, fallback: 100 }), parseIntegerOption(opts.offset, '--offset', { min: 0, fallback: 0 }));
717
+ if (opts.json) {
718
+ printJson(activity);
719
+ return;
720
+ }
721
+ printActivityTable(activity);
722
+ }));
723
+ addApiOptions(accounts
724
+ .command('catalog')
725
+ .description('Show action types supported by the appliance'), true).action(action(async (opts) => {
726
+ const { api, spec } = await connectWithSpec(opts);
727
+ requireOperation(spec, 'GET sftp_action_accounts/catalog');
728
+ const response = await api.get('sftp_action_accounts/catalog');
729
+ const catalog = (response.data.catalog ?? response.data);
730
+ if (opts.json) {
731
+ printJson(catalog);
732
+ return;
733
+ }
734
+ const types = catalog.types ?? [];
735
+ if (types.length === 0) {
736
+ out.warn('The appliance returned an empty action catalog.');
737
+ return;
738
+ }
739
+ console.log(renderTable(['TYPE', 'REQUIRED PARAMS', 'OPTIONAL PARAMS'], types.map(type => [
740
+ type.type ?? '—',
741
+ (type.required_params ?? []).join(', ') || '—',
742
+ (type.optional_params ?? []).join(', ') || '—',
743
+ ]), { maxWidth: [24, 40, 60] }));
744
+ console.log();
745
+ }));
746
+ const contacts = accounts
747
+ .command('contacts')
748
+ .description('Manage notification contacts on an SFTP account');
749
+ contacts.action(() => contacts.outputHelp());
750
+ addApiOptions(contacts
751
+ .command('list')
752
+ .description('List notification contacts')
753
+ .argument('<id|username>', 'Account ID or username'), true).action(action(async (ref, opts) => {
754
+ const { api, spec } = await connectWithSpec(opts);
755
+ requireOperation(spec, 'GET sftp_action_accounts/{id}/contacts');
756
+ const account = await resolveAccount(api, ref);
757
+ const list = await getAccountContacts(api, account);
758
+ if (opts.json) {
759
+ printJson(list);
760
+ return;
761
+ }
762
+ if (list.length === 0) {
763
+ out.info('No notification contacts.');
764
+ return;
765
+ }
766
+ console.log(renderTable(['ID', 'TYPE', 'CONTACT', 'DESCRIPTION'], list.map(contact => [
767
+ String(contact.id),
768
+ String(contact.type ?? contact.contact_type ?? '—'),
769
+ String(contact.contact_uri ?? contact.email ?? '—'),
770
+ String(contact.description ?? '—'),
771
+ ]), { maxWidth: [8, 16, 50, 40] }));
772
+ console.log();
773
+ }));
774
+ addApiOptions(contacts
775
+ .command('add')
776
+ .description('Add notification contacts')
777
+ .argument('<id|username>', 'Account ID or username')
778
+ .option('--contact <id>', 'Contact ID (repeatable, comma-separated)', collect, []), true).action(action(async (ref, opts) => {
779
+ const { api, spec } = await connectWithSpec(opts);
780
+ requireOperation(spec, 'POST sftp_action_accounts/{id}/contacts');
781
+ const account = await resolveAccount(api, ref);
782
+ const ids = parseContactIds(opts.contact);
783
+ await addAccountContacts(api, account, ids);
784
+ if (opts.json)
785
+ printJson({ account: account.username, added: ids });
786
+ else
787
+ out.ok(`Added ${ids.length} contact(s) to ${account.username}`);
788
+ }));
789
+ addApiOptions(contacts
790
+ .command('remove')
791
+ .description('Remove a notification contact')
792
+ .argument('<id|username>', 'Account ID or username')
793
+ .argument('<contact-id>', 'Contact ID'), true).action(action(async (ref, contactId, opts) => {
794
+ const { api, spec } = await connectWithSpec(opts);
795
+ requireOperation(spec, 'DELETE sftp_action_accounts/{id}/contacts/{contact_id}');
796
+ const account = await resolveAccount(api, ref);
797
+ const id = parseContactIds([contactId])[0];
798
+ await removeAccountContact(api, account, id);
799
+ if (opts.json)
800
+ printJson({ account: account.username, removed: id });
801
+ else
802
+ out.ok(`Removed contact ${id} from ${account.username}`);
803
+ }));
804
+ addApiOptions(accounts
805
+ .command('export')
806
+ .description('Export accounts to CSV')
807
+ .option('--output <path>', 'Destination file, or - for stdout')
808
+ .option('--include-actions', 'Include action configuration in the CSV')).action(action(async (opts) => {
809
+ const { api, spec } = await connectWithSpec(opts);
810
+ requireOperation(spec, 'GET sftp_action_accounts/export');
811
+ const output = opts.output ?? `sftp-accounts-${new Date().toISOString().slice(0, 10)}.csv`;
812
+ if (output === '-') {
813
+ const response = await api.get('sftp_action_accounts/export', {
814
+ query: { include_actions: opts.includeActions ? 'true' : undefined },
815
+ accept: 'text/csv',
816
+ parse: 'text',
817
+ });
818
+ process.stdout.write(response.data);
819
+ if (!response.data.endsWith('\n'))
820
+ process.stdout.write('\n');
821
+ return;
822
+ }
823
+ await api.get('sftp_action_accounts/export', {
824
+ query: { include_actions: opts.includeActions ? 'true' : undefined },
825
+ accept: 'text/csv',
826
+ outputPath: output,
827
+ parse: 'none',
828
+ });
829
+ out.ok(`Exported accounts to ${output}`);
830
+ }));
831
+ addApiOptions(accounts
832
+ .command('template')
833
+ .description('Download the CSV import template')
834
+ .option('--output <path>', 'Destination file, or - for stdout')).action(action(async (opts) => {
835
+ const { api, spec } = await connectWithSpec(opts);
836
+ requireOperation(spec, 'GET sftp_action_accounts/template');
837
+ const output = opts.output ?? 'sftp-accounts-template.csv';
838
+ if (output === '-') {
839
+ const response = await api.get('sftp_action_accounts/template', {
840
+ accept: 'text/csv',
841
+ parse: 'text',
842
+ });
843
+ process.stdout.write(response.data);
844
+ if (!response.data.endsWith('\n'))
845
+ process.stdout.write('\n');
846
+ return;
847
+ }
848
+ await api.get('sftp_action_accounts/template', {
849
+ accept: 'text/csv',
850
+ outputPath: output,
851
+ parse: 'none',
852
+ });
853
+ out.ok(`Wrote CSV template to ${output}`);
854
+ }));
855
+ addApiOptions(accounts
856
+ .command('import')
857
+ .description('Import accounts from a CSV file')
858
+ .argument('<file>', 'CSV file')
859
+ .option('--validate-only', 'Validate the CSV without importing')
860
+ .option('--skip-existing', 'Skip usernames that already exist')
861
+ .option('--on-success <action>', 'Default success action for imported accounts (repeatable)', collect, [])
862
+ .option('--on-failure <action>', 'Default failure action for imported accounts (repeatable)', collect, [])
863
+ .option('--dry-run', 'Print the request without sending it'), true).action(action(async (file, opts) => {
864
+ const { api, spec } = await connectWithSpec(opts);
865
+ const operation = opts.validateOnly
866
+ ? requireOperation(spec, 'POST sftp_action_accounts/import/validate')
867
+ : requireOperation(spec, 'POST sftp_action_accounts/import');
868
+ const upload = readImportFile(file);
869
+ const files = [{ ...upload, field: 'file' }];
870
+ if (opts.dryRun) {
871
+ console.log(`${chalk.bold('POST')} ${api.apiPath(operation.shortPath)}`);
872
+ printJson({
873
+ file: upload.filename,
874
+ bytes: upload.data.length,
875
+ skip_existing: opts.skipExisting ? 'true' : undefined,
876
+ on_success_actions: opts.onSuccess.length > 0 ? 'json' : undefined,
877
+ on_failure_actions: opts.onFailure.length > 0 ? 'json' : undefined,
878
+ });
879
+ return;
880
+ }
881
+ const fields = {};
882
+ if (!opts.validateOnly) {
883
+ fields.skip_existing = opts.skipExisting ? 'true' : undefined;
884
+ if (opts.onSuccess.length > 0) {
885
+ fields.on_success_actions = JSON.stringify(buildCreateAccountPayload({
886
+ username: 'import',
887
+ password: 'placeholder',
888
+ onSuccess: opts.onSuccess,
889
+ onFailure: [],
890
+ }).account.on_success_actions ?? []);
891
+ }
892
+ if (opts.onFailure.length > 0) {
893
+ fields.on_failure_actions = JSON.stringify(buildCreateAccountPayload({
894
+ username: 'import',
895
+ password: 'placeholder',
896
+ onSuccess: [],
897
+ onFailure: opts.onFailure,
898
+ }).account.on_failure_actions ?? []);
899
+ }
900
+ }
901
+ const response = await api.request('POST', operation.shortPath, {
902
+ form: { fields, files },
903
+ });
904
+ if (opts.json) {
905
+ printJson(response.data);
906
+ return;
907
+ }
908
+ if (opts.validateOnly) {
909
+ const analysis = (response.data.analysis ?? response.data);
910
+ if (analysis.valid === false) {
911
+ out.fail('CSV validation failed');
912
+ for (const error of analysis.errors ?? [])
913
+ console.log(` ${error}`);
914
+ process.exitCode = 1;
915
+ return;
916
+ }
917
+ out.ok(`CSV valid · ${analysis.row_count ?? '?'} row(s)`);
918
+ for (const warning of analysis.warnings ?? [])
919
+ out.warn(warning);
920
+ return;
921
+ }
922
+ const imported = (response.data.accounts ?? []);
923
+ out.ok(`Imported ${imported.length} account(s)`);
924
+ if (imported.length > 0) {
925
+ console.log(renderTable(['ID', 'USERNAME', 'STATUS', 'HOME'], imported.map(account => [
926
+ String(account.id),
927
+ account.username,
928
+ account.enabled ? 'enabled' : 'disabled',
929
+ account.home ?? '—',
930
+ ])));
931
+ }
932
+ }));
933
+ }
934
+ // ── Monitoring ───────────────────────────────────────────────────────────
935
+ function registerMonitoring(sftp) {
936
+ const monitoring = sftp
937
+ .command('monitoring')
938
+ .alias('monitor')
939
+ .description('Upload monitoring and missed-upload alerts');
940
+ monitoring.action(() => monitoring.outputHelp());
941
+ addApiOptions(monitoring.command('list').description('List monitored SFTP accounts'), true).action(action(async (opts) => {
942
+ const { api, spec } = await connectWithSpec(opts);
943
+ requireOperation(spec, 'GET sftp_monitoring');
944
+ const list = await listMonitoredAccounts(api);
945
+ if (opts.json) {
946
+ printJson(list);
947
+ return;
948
+ }
949
+ printMonitoringTable(list);
950
+ }));
951
+ addApiOptions(monitoring.command('missing').description('List accounts with missing uploads'), true).action(action(async (opts) => {
952
+ const { api, spec } = await connectWithSpec(opts);
953
+ requireOperation(spec, 'GET sftp_monitoring/missing');
954
+ const response = await api.get('sftp_monitoring/missing');
955
+ const list = (response.data.data ?? response.data.accounts ?? []);
956
+ if (opts.json) {
957
+ printJson(response.data);
958
+ return;
959
+ }
960
+ if (!Array.isArray(list) || list.length === 0) {
961
+ out.ok('No missing uploads.');
962
+ return;
963
+ }
964
+ printMonitoringTable(list);
965
+ }));
966
+ addApiOptions(monitoring
967
+ .command('check')
968
+ .description('Trigger an immediate missed-upload check'), true).action(action(async (opts) => {
969
+ const { api, spec } = await connectWithSpec(opts);
970
+ requireOperation(spec, 'POST sftp_monitoring/check');
971
+ const response = await api.post('sftp_monitoring/check', {});
972
+ if (opts.json)
973
+ printJson(response.data);
974
+ else
975
+ out.ok(String(response.data.message ?? 'Monitoring check completed'));
976
+ }));
977
+ addApiOptions(monitoring
978
+ .command('show')
979
+ .description('Show monitoring configuration for an account')
980
+ .argument('<id|username>', 'Account ID or username'), true).action(action(async (ref, opts) => {
981
+ const { api, spec } = await connectWithSpec(opts);
982
+ requireOperation(spec, 'GET sftp_action_accounts/{account_id}/monitoring');
983
+ const account = await resolveAccount(api, ref);
984
+ const config = await getAccountMonitoring(api, account);
985
+ if (opts.json) {
986
+ printJson(config);
987
+ return;
988
+ }
989
+ out.header(`Monitoring · ${account.username}`);
990
+ printKeyValues([
991
+ ['Enabled', config.enabled ? 'yes' : 'no'],
992
+ ['Schedule', monitoringSchedule(config)],
993
+ ['Next upload', formatDateTime(config.expected_next_upload_at)],
994
+ ['Last upload', formatDateTime(config.last_upload_at)],
995
+ ['Missed uploads', String(config.missed_upload_count ?? 0)],
996
+ ['Alerts', config.config?.alert_on_missed_uploads ? 'on' : 'off'],
997
+ ['Tolerance', `${config.config?.tolerance_minutes ?? '—'} min`],
998
+ ['Threshold', String(config.config?.alert_threshold ?? '—')],
999
+ ]);
1000
+ console.log();
1001
+ }));
1002
+ const addMonitoringOptions = (command) => command
1003
+ .option('--schedule <type>', 'Schedule: hourly, daily, weekly, monthly')
1004
+ .option('--hour <n>', 'Expected hour (0-23)')
1005
+ .option('--minute <n>', 'Expected minute (0-59)')
1006
+ .option('--day-of-week <n>', 'Expected day of week (1=Mon … 7=Sun)')
1007
+ .option('--day-of-month <n>', 'Expected day of month (1-28)')
1008
+ .option('--weekdays-only', 'Only expect uploads on weekdays')
1009
+ .option('--tolerance <minutes>', 'Minutes of tolerance')
1010
+ .option('--threshold <n>', 'Missed-upload alert threshold')
1011
+ .option('--alert-config <json>', 'Alert config JSON object or @file')
1012
+ .option('--no-alert-on-missed', 'Disable missed-upload alerts');
1013
+ addApiOptions(addMonitoringOptions(monitoring
1014
+ .command('enable')
1015
+ .description('Enable upload monitoring for an account')
1016
+ .argument('<id|username>', 'Account ID or username')), true).action(action(async (ref, opts) => {
1017
+ const { api, spec } = await connectWithSpec(opts);
1018
+ requireOperation(spec, 'POST sftp_action_accounts/{account_id}/monitoring/enable');
1019
+ const account = await resolveAccount(api, ref);
1020
+ const payload = buildMonitoringPayload(opts, true);
1021
+ const response = await api.post(`sftp_action_accounts/${account.id}/monitoring/enable`, payload);
1022
+ if (opts.json)
1023
+ printJson(response.data);
1024
+ else
1025
+ out.ok(`Enabled upload monitoring for ${account.username}`);
1026
+ }));
1027
+ addApiOptions(monitoring
1028
+ .command('disable')
1029
+ .description('Disable upload monitoring for an account')
1030
+ .argument('<id|username>', 'Account ID or username'), true).action(action(async (ref, opts) => {
1031
+ const { api, spec } = await connectWithSpec(opts);
1032
+ requireOperation(spec, 'POST sftp_action_accounts/{account_id}/monitoring/disable');
1033
+ const account = await resolveAccount(api, ref);
1034
+ const response = await api.post(`sftp_action_accounts/${account.id}/monitoring/disable`, {});
1035
+ if (opts.json)
1036
+ printJson(response.data);
1037
+ else
1038
+ out.ok(`Disabled upload monitoring for ${account.username}`);
1039
+ }));
1040
+ addApiOptions(addMonitoringOptions(monitoring
1041
+ .command('config')
1042
+ .description('Update monitoring configuration for an account')
1043
+ .argument('<id|username>', 'Account ID or username')), true).action(action(async (ref, opts) => {
1044
+ const { api, spec } = await connectWithSpec(opts);
1045
+ requireOperation(spec, 'PUT sftp_action_accounts/{account_id}/monitoring/config');
1046
+ const account = await resolveAccount(api, ref);
1047
+ const payload = buildMonitoringPayload(opts, false);
1048
+ if (Object.keys(payload).length === 0) {
1049
+ throw new ApplianceApiError('Nothing to update. Pass a schedule or alert option.');
1050
+ }
1051
+ const response = await api.put(`sftp_action_accounts/${account.id}/monitoring/config`, payload);
1052
+ if (opts.json)
1053
+ printJson(response.data);
1054
+ else
1055
+ out.ok(`Updated monitoring configuration for ${account.username}`);
1056
+ }));
1057
+ addApiOptions(monitoring
1058
+ .command('discover')
1059
+ .description('Discover CUCM DRF backup schedules and apply them to an account')
1060
+ .argument('<id|username>', 'Account ID or username')
1061
+ .option('--host <host>', 'CUCM host')
1062
+ .option('--username <username>', 'CUCM username')
1063
+ .option('--password <password>', 'CUCM password (prefer --password-stdin)')
1064
+ .option('--password-stdin', 'Read the CUCM password from stdin')
1065
+ .option('--schedule-name <name>', 'Schedule name filter')
1066
+ .option('--callmanager-id <id>', 'Existing CallManager ID')
1067
+ .option('--tolerance <minutes>', 'Minutes of tolerance'), true).action(action(async (ref, opts) => {
1068
+ const { api, spec } = await connectWithSpec(opts);
1069
+ requireOperation(spec, 'POST sftp_action_accounts/{account_id}/monitoring/discover');
1070
+ const account = await resolveAccount(api, ref);
1071
+ let password = opts.password;
1072
+ if (!password && opts.passwordStdin)
1073
+ password = readPasswordFromStdin();
1074
+ if (!password && opts.host && opts.username) {
1075
+ password = await promptHidden('CUCM password');
1076
+ }
1077
+ const payload = buildDiscoverPayload({ ...opts, password });
1078
+ const response = await api.post(`sftp_action_accounts/${account.id}/monitoring/discover`, payload);
1079
+ if (opts.json)
1080
+ printJson(response.data);
1081
+ else
1082
+ out.ok(`Discovered backup schedules for ${account.username}`);
1083
+ }));
1084
+ addApiOptions(monitoring
1085
+ .command('schedules')
1086
+ .description('Discover CUCM backup schedules without applying them')
1087
+ .argument('<id|username>', 'Account ID or username'), true).action(action(async (ref, opts) => {
1088
+ const { api, spec } = await connectWithSpec(opts);
1089
+ requireOperation(spec, 'GET sftp_action_accounts/{account_id}/monitoring/schedules');
1090
+ const account = await resolveAccount(api, ref);
1091
+ const response = await api.get(`sftp_action_accounts/${account.id}/monitoring/schedules`);
1092
+ if (opts.json) {
1093
+ printJson(response.data);
1094
+ return;
1095
+ }
1096
+ const schedules = (response.data.data ?? []);
1097
+ if (schedules.length === 0) {
1098
+ out.info('No CUCM backup schedules discovered.');
1099
+ return;
1100
+ }
1101
+ console.log(renderTable(['SCHEDULE', 'FREQUENCY', 'TIME', 'DAY', 'DEVICE', 'PATH'], schedules.map(schedule => [
1102
+ String(schedule.schedule_name ?? '—'),
1103
+ String(schedule.frequency ?? '—'),
1104
+ `${String(schedule.hour ?? '—')}:${String(schedule.minute ?? 0).padStart(2, '0')}`,
1105
+ String(schedule.day_of_month ??
1106
+ (Array.isArray(schedule.days_of_week) ? schedule.days_of_week.join(',') : '—')),
1107
+ String(schedule.device_name ?? '—'),
1108
+ String(schedule.device_path ?? '—'),
1109
+ ]), { maxWidth: [28, 14, 8, 10, 28, 50] }));
1110
+ console.log();
1111
+ }));
1112
+ }
1113
+ function printMonitoringTable(list) {
1114
+ if (list.length === 0) {
1115
+ out.info('No monitored accounts.');
1116
+ return;
1117
+ }
1118
+ console.log(renderTable(['USERNAME', 'STATUS', 'SCHEDULE', 'NEXT', 'LAST', 'MISSED'], list.map(account => [
1119
+ account.username,
1120
+ account.monitoring?.enabled ? 'enabled' : 'disabled',
1121
+ monitoringSchedule(account.monitoring),
1122
+ formatDateTime(account.monitoring?.expected_next_upload_at),
1123
+ formatDateTime(account.monitoring?.last_upload_at),
1124
+ String(account.monitoring?.missed_upload_count ?? 0),
1125
+ ]), { maxWidth: [28, 9, 20, 20, 20, 8] }));
1126
+ console.log();
1127
+ }
1128
+ function printActivityTable(activity) {
1129
+ if (activity.length === 0) {
1130
+ out.info('No activity found.');
1131
+ return;
1132
+ }
1133
+ console.log(renderTable(['ID', 'WHEN', 'ACTION', 'STATUS', 'ACTOR', 'TARGET', 'ERROR'], activity.map(row => [
1134
+ String(row.id),
1135
+ formatDateTime(row.inserted_at),
1136
+ row.action ?? '—',
1137
+ row.status ?? '—',
1138
+ row.actor_ip ?? '—',
1139
+ row.target_type ? `${row.target_type}:${row.target_id ?? '?'}` : '—',
1140
+ row.error_message ?? '—',
1141
+ ]), { maxWidth: [8, 20, 24, 9, 18, 24, 40] }));
1142
+ console.log();
1143
+ }
1144
+ // ── Org-wide activity and runs ───────────────────────────────────────────
1145
+ function registerActivity(sftp) {
1146
+ addApiOptions(sftp
1147
+ .command('activity')
1148
+ .description('List org-wide SFTP audit activity')
1149
+ .option('--username <username>', 'Filter by username')
1150
+ .option('--limit <n>', 'Maximum entries', '100')
1151
+ .option('--offset <n>', 'Offset', '0'), true).action(action(async (opts) => {
1152
+ const { api, spec } = await connectWithSpec(opts);
1153
+ requireOperation(spec, 'GET sftp_activity');
1154
+ const response = await api.get('sftp_activity', {
1155
+ query: {
1156
+ username: opts.username,
1157
+ limit: parseIntegerOption(opts.limit, '--limit', { min: 1, max: 1000, fallback: 100 }),
1158
+ offset: parseIntegerOption(opts.offset, '--offset', { min: 0, fallback: 0 }),
1159
+ },
1160
+ });
1161
+ if (opts.json) {
1162
+ printJson(response.data);
1163
+ return;
1164
+ }
1165
+ printActivityTable((response.data.activity ?? response.data.data ?? []));
1166
+ }));
1167
+ addApiOptions(sftp
1168
+ .command('runs')
1169
+ .description('List org-wide SFTP action runs')
1170
+ .option('--username <username>', 'Filter by username')
1171
+ .option('--status <status>', 'Filter by status')
1172
+ .option('--type <type>', 'Filter by run type')
1173
+ .option('--event <event>', 'Filter by event')
1174
+ .option('--account-id <id>', 'Filter by action account ID')
1175
+ .option('--start-date <date>', 'Filter by start date')
1176
+ .option('--end-date <date>', 'Filter by end date')
1177
+ .option('--limit <n>', 'Maximum runs', '50')
1178
+ .option('--offset <n>', 'Offset', '0'), true).action(action(async (opts) => {
1179
+ const { api, spec } = await connectWithSpec(opts);
1180
+ requireOperation(spec, 'GET sftp_action_runs');
1181
+ const response = await api.get('sftp_action_runs', {
1182
+ query: {
1183
+ username: opts.username,
1184
+ status: opts.status,
1185
+ type: opts.type,
1186
+ event: opts.event,
1187
+ action_account_id: parseIntegerOption(opts.accountId, '--account-id', { min: 1 }),
1188
+ start_date: opts.startDate,
1189
+ end_date: opts.endDate,
1190
+ limit: parseIntegerOption(opts.limit, '--limit', { min: 1, max: 1000, fallback: 50 }),
1191
+ offset: parseIntegerOption(opts.offset, '--offset', { min: 0, fallback: 0 }),
1192
+ },
1193
+ });
1194
+ if (opts.json) {
1195
+ printJson(response.data);
1196
+ return;
1197
+ }
1198
+ const runs = (response.data.runs ?? response.data.data ?? []);
1199
+ if (runs.length === 0) {
1200
+ out.info('No runs found.');
1201
+ return;
1202
+ }
1203
+ console.log(renderTable(['ID', 'ACCOUNT', 'STATUS', 'TYPE', 'EVENT', 'FILE', 'WHEN'], runs.map(run => [
1204
+ String(run.id),
1205
+ run.username ?? String(run.action_account_id ?? '—'),
1206
+ run.status ?? '—',
1207
+ run.type ?? '—',
1208
+ run.event ?? '—',
1209
+ run.file ?? '—',
1210
+ formatDateTime(run.inserted_at),
1211
+ ]), { maxWidth: [8, 20, 10, 22, 20, 40, 20] }));
1212
+ console.log();
1213
+ }));
1214
+ }
1215
+ // ── Storage ──────────────────────────────────────────────────────────────
1216
+ function registerStorage(sftp) {
1217
+ const storage = sftp.command('storage').description('SFTP storage quota and purge operations');
1218
+ storage.action(() => storage.outputHelp());
1219
+ addApiOptions(storage.command('status').description('Show org-wide SFTP storage status'), true).action(action(async (opts) => {
1220
+ const { api, spec } = await connectWithSpec(opts);
1221
+ requireOperation(spec, 'GET sftp_storage/status');
1222
+ const response = await api.get('sftp_storage/status');
1223
+ const status = (response.data.storage ?? response.data);
1224
+ if (opts.json) {
1225
+ printJson(status);
1226
+ return;
1227
+ }
1228
+ out.header('SFTP Storage');
1229
+ printKeyValues([
1230
+ ['Used', formatBytes(status.total_used_bytes)],
1231
+ ['Limit', formatGb(status.total_limit_gb)],
1232
+ ['Accounts', String(status.account_count ?? '—')],
1233
+ ['Over quota', String(status.accounts_over_quota ?? 0)],
1234
+ ]);
1235
+ console.log();
1236
+ }));
1237
+ addApiOptions(storage.command('refresh').description('Recalculate quotas for all SFTP accounts'), true).action(action(async (opts) => {
1238
+ const { api, spec } = await connectWithSpec(opts);
1239
+ requireOperation(spec, 'POST sftp_storage/refresh_quotas');
1240
+ const response = await api.post('sftp_storage/refresh_quotas', {});
1241
+ if (opts.json)
1242
+ printJson(response.data);
1243
+ else
1244
+ out.ok(String(response.data.message ?? 'Refreshed SFTP quotas'));
1245
+ }));
1246
+ addApiOptions(storage
1247
+ .command('purge')
1248
+ .description('Trigger storage purge for the organization')
1249
+ .option('--retention-days <n>', 'Retention window in days')
1250
+ .option('--yes', 'Skip the confirmation prompt'), true).action(action(async (opts) => {
1251
+ const { api, spec } = await connectWithSpec(opts);
1252
+ requireOperation(spec, 'POST sftp_storage/purge');
1253
+ const days = parseIntegerOption(opts.retentionDays, '--retention-days', { min: 1, max: 3650 });
1254
+ if (!opts.json) {
1255
+ await confirmDestructive(`Purge SFTP storage${days ? ` with a ${days}-day retention window` : ''}?`, opts.yes);
1256
+ }
1257
+ const response = await api.post('sftp_storage/purge', {}, {
1258
+ query: { retention_days: days },
1259
+ });
1260
+ if (opts.json)
1261
+ printJson(response.data);
1262
+ else
1263
+ out.ok(String(response.data.message ?? 'SFTP storage purge started'));
1264
+ }));
1265
+ }
1266
+ // ── Backup analytics ─────────────────────────────────────────────────────
1267
+ function registerAnalytics(sftp) {
1268
+ const analytics = sftp
1269
+ .command('analytics')
1270
+ .alias('backups')
1271
+ .description('CUCM backup analytics and trends');
1272
+ analytics.action(() => analytics.outputHelp());
1273
+ const analyticsOptions = (command) => command
1274
+ .option('--days <n>', 'Lookback window in days')
1275
+ .option('--start-time <iso>', 'Start time (ISO 8601)')
1276
+ .option('--end-time <iso>', 'End time (ISO 8601)')
1277
+ .option('--username <username>', 'Filter by account username')
1278
+ .option('--cluster <name>', 'CUCM cluster')
1279
+ .option('--granularity <unit>', 'Trend granularity')
1280
+ .option('--limit <n>', 'Maximum entries')
1281
+ .option('--offset <n>', 'Offset');
1282
+ const bindQuery = (opts) => ({
1283
+ days: parseIntegerOption(opts.days, '--days', { min: 1, max: 3650 }),
1284
+ start_time: opts.startTime,
1285
+ end_time: opts.endTime,
1286
+ username: opts.username,
1287
+ cluster: opts.cluster,
1288
+ granularity: opts.granularity,
1289
+ limit: parseIntegerOption(opts.limit, '--limit', { min: 1, max: 1000 }),
1290
+ offset: parseIntegerOption(opts.offset, '--offset', { min: 0 }),
1291
+ });
1292
+ const addAnalyticsCommand = (name, path, description, requiresCluster = false) => {
1293
+ addApiOptions(analyticsOptions(analytics.command(name).description(description)), true).action(action(async (opts) => {
1294
+ const { api, spec } = await connectWithSpec(opts);
1295
+ requireOperation(spec, `GET ${path}`);
1296
+ if (requiresCluster && (!opts.username || !opts.cluster)) {
1297
+ throw new ApplianceApiError('--username and --cluster are required.');
1298
+ }
1299
+ const response = await api.get(path, { query: bindQuery(opts) });
1300
+ if (opts.json) {
1301
+ printJson(response.data);
1302
+ return;
1303
+ }
1304
+ // These endpoints return nested analytics objects. Pretty JSON keeps
1305
+ // every field visible without guessing a release-specific table shape.
1306
+ printJson(response.data);
1307
+ }));
1308
+ };
1309
+ addAnalyticsCommand('dashboard', 'sftp_backup_analytics/cucm/dashboard', 'CUCM backup dashboard summary');
1310
+ addAnalyticsCommand('trends', 'sftp_backup_analytics/trends', 'Backup trend analytics');
1311
+ addAnalyticsCommand('cluster', 'sftp_backup_analytics/cucm/cluster', 'CUCM cluster backup summary', true);
1312
+ addAnalyticsCommand('validations', 'sftp_backup_analytics/cucm/cluster/validations', 'CUCM cluster validation history', true);
1313
+ }
1314
+ // ── Report schedules ─────────────────────────────────────────────────────
1315
+ function registerReports(sftp) {
1316
+ const reports = sftp.command('reports').description('SFTP report schedules and executions');
1317
+ reports.action(() => reports.outputHelp());
1318
+ const schedules = reports.command('schedules').description('Manage SFTP report schedules');
1319
+ schedules.action(() => schedules.outputHelp());
1320
+ addApiOptions(schedules.command('list').description('List report schedules'), true).action(action(async (opts) => {
1321
+ const { api, spec } = await connectWithSpec(opts);
1322
+ requireOperation(spec, 'GET sftp_reports/schedules');
1323
+ const response = await api.get('sftp_reports/schedules');
1324
+ const list = (response.data.schedules ?? response.data.data ?? []);
1325
+ if (opts.json) {
1326
+ printJson(list);
1327
+ return;
1328
+ }
1329
+ if (list.length === 0) {
1330
+ out.info('No report schedules.');
1331
+ return;
1332
+ }
1333
+ console.log(renderTable(['ID', 'TYPE', 'ENABLED', 'NEXT RUN', 'LAST RUN', 'STATUS', 'FAILURES'], list.map(schedule => [
1334
+ String(schedule.id),
1335
+ String(schedule.schedule_type ?? '—'),
1336
+ schedule.enabled ? 'yes' : 'no',
1337
+ formatDateTime(schedule.next_run_at),
1338
+ formatDateTime(schedule.last_run_at),
1339
+ schedule.last_run_status ?? '—',
1340
+ String(schedule.failure_count ?? 0),
1341
+ ]), { maxWidth: [8, 20, 8, 20, 20, 12, 9] }));
1342
+ console.log();
1343
+ }));
1344
+ addApiOptions(schedules
1345
+ .command('show')
1346
+ .description('Show a report schedule')
1347
+ .argument('<id>', 'Schedule ID'), true).action(action(async (id, opts) => {
1348
+ const { api, spec } = await connectWithSpec(opts);
1349
+ requireOperation(spec, 'GET sftp_reports/schedules/{id}');
1350
+ const scheduleId = parsePositiveInt(id, 'schedule ID');
1351
+ const response = await api.get(`sftp_reports/schedules/${scheduleId}`);
1352
+ printJson(response.data.schedule ?? response.data);
1353
+ }));
1354
+ for (const [name, method] of [
1355
+ ['create', 'POST'],
1356
+ ['update', 'PUT'],
1357
+ ]) {
1358
+ const command = addApiOptions(schedules
1359
+ .command(name)
1360
+ .description(`${name === 'create' ? 'Create' : 'Update'} a report schedule from JSON`)
1361
+ .requiredOption('--file <path>', 'JSON file with schedule fields (or a {schedule:{...}} object)'), true);
1362
+ if (name === 'update')
1363
+ command.argument('<id>', 'Schedule ID');
1364
+ command.action(action(async (...args) => {
1365
+ const opts = args[args.length - 1];
1366
+ const scheduleId = name === 'update' ? parsePositiveInt(String(args[0]), 'schedule ID') : undefined;
1367
+ const { api, spec } = await connectWithSpec(opts);
1368
+ const path = scheduleId
1369
+ ? requireOperation(spec, 'PUT sftp_reports/schedules/{id}').shortPath.replace('{id}', String(scheduleId))
1370
+ : requireOperation(spec, 'POST sftp_reports/schedules').shortPath;
1371
+ const parsed = readJsonOption(opts.file.startsWith('@') ? opts.file : `@${opts.file}`, '--file');
1372
+ const schedule = typeof parsed === 'object' && parsed !== null && 'schedule' in parsed
1373
+ ? parsed.schedule
1374
+ : parsed;
1375
+ if (typeof schedule !== 'object' || schedule === null || Array.isArray(schedule)) {
1376
+ throw new ApplianceApiError('--file must contain a JSON object.');
1377
+ }
1378
+ const response = await api.request(method, path, {
1379
+ body: { schedule },
1380
+ });
1381
+ if (opts.json)
1382
+ printJson(response.data);
1383
+ else
1384
+ out.ok(`Report schedule ${name === 'create' ? 'created' : 'updated'}`);
1385
+ }));
1386
+ }
1387
+ addApiOptions(schedules
1388
+ .command('delete')
1389
+ .description('Delete a report schedule')
1390
+ .argument('<id>', 'Schedule ID')
1391
+ .option('--yes', 'Skip the confirmation prompt'), true).action(action(async (id, opts) => {
1392
+ const { api, spec } = await connectWithSpec(opts);
1393
+ requireOperation(spec, 'DELETE sftp_reports/schedules/{id}');
1394
+ const scheduleId = parsePositiveInt(id, 'schedule ID');
1395
+ await confirmDestructive(`Delete report schedule ${scheduleId}?`, opts.yes);
1396
+ await api.delete(`sftp_reports/schedules/${scheduleId}`);
1397
+ if (opts.json)
1398
+ printJson({ deleted: true, id: scheduleId });
1399
+ else
1400
+ out.ok(`Deleted report schedule ${scheduleId}`);
1401
+ }));
1402
+ addApiOptions(schedules
1403
+ .command('run')
1404
+ .description('Run a report schedule now')
1405
+ .argument('<id>', 'Schedule ID'), true).action(action(async (id, opts) => {
1406
+ const { api, spec } = await connectWithSpec(opts);
1407
+ requireOperation(spec, 'POST sftp_reports/schedules/{id}/run');
1408
+ const scheduleId = parsePositiveInt(id, 'schedule ID');
1409
+ const response = await api.post(`sftp_reports/schedules/${scheduleId}/run`, {});
1410
+ if (opts.json)
1411
+ printJson(response.data);
1412
+ else
1413
+ out.ok(`Triggered report schedule ${scheduleId}`);
1414
+ }));
1415
+ addApiOptions(schedules
1416
+ .command('executions')
1417
+ .description('List execution history for a report schedule')
1418
+ .argument('<id>', 'Schedule ID')
1419
+ .option('--limit <n>', 'Maximum entries', '50')
1420
+ .option('--offset <n>', 'Offset', '0'), true).action(action(async (id, opts) => {
1421
+ const { api, spec } = await connectWithSpec(opts);
1422
+ requireOperation(spec, 'GET sftp_reports/schedules/{id}/executions');
1423
+ const scheduleId = parsePositiveInt(id, 'schedule ID');
1424
+ const response = await api.get(`sftp_reports/schedules/${scheduleId}/executions`, {
1425
+ query: {
1426
+ limit: parseIntegerOption(opts.limit, '--limit', { min: 1, max: 1000, fallback: 50 }),
1427
+ offset: parseIntegerOption(opts.offset, '--offset', { min: 0, fallback: 0 }),
1428
+ },
1429
+ });
1430
+ printJson(response.data);
1431
+ }));
1432
+ addApiOptions(reports
1433
+ .command('executions')
1434
+ .description('Show a single report execution')
1435
+ .argument('<id>', 'Execution ID'), true).action(action(async (id, opts) => {
1436
+ const { api, spec } = await connectWithSpec(opts);
1437
+ requireOperation(spec, 'GET sftp_reports/executions/{id}');
1438
+ const executionId = parsePositiveInt(id, 'execution ID');
1439
+ const response = await api.get(`sftp_reports/executions/${executionId}`);
1440
+ printJson(response.data);
1441
+ }));
1442
+ }
1443
+ // ── Generic OpenAPI operation ────────────────────────────────────────────
1444
+ function registerApi(sftp) {
1445
+ addApiOptions(sftp
1446
+ .command('api')
1447
+ .description('Call any SFTP operation exposed by the appliance OpenAPI spec')
1448
+ .argument('[operation]', 'Example: "GET sftp_action_accounts/{id}/quota"')
1449
+ .option('--list', 'List SFTP operations from the installed release')
1450
+ .option('--all', 'With --list, include non-SFTP operations')
1451
+ .option('--method <method>', 'HTTP method when the path supports several')
1452
+ .option('--param <key=value>', 'Path parameter (repeatable)', collect, [])
1453
+ .option('--query <key=value>', 'Query parameter (repeatable)', collect, [])
1454
+ .option('--body <json>', 'JSON body or @file')
1455
+ .option('--output <path>', 'Write the response to a file')
1456
+ .option('--raw', 'Print the response as raw text')
1457
+ .option('--dry-run', 'Print the request without sending it')
1458
+ .option('--yes', 'Allow destructive methods without confirmation'), true).action(action(async (operation, opts) => {
1459
+ let api;
1460
+ let spec;
1461
+ if (opts.list) {
1462
+ api = await connect(opts, { requireAuth: false });
1463
+ try {
1464
+ spec = await api.fetchSpec();
1465
+ }
1466
+ catch (err) {
1467
+ // A release may protect /api/openapi. Retry with local credentials
1468
+ // before surfacing an authentication error.
1469
+ if (err instanceof ApplianceApiError && (err.status === 401 || err.status === 403)) {
1470
+ api = await connect(opts, { requireAuth: true });
1471
+ spec = await api.fetchSpec();
1472
+ }
1473
+ else {
1474
+ throw err;
1475
+ }
1476
+ }
1477
+ }
1478
+ else {
1479
+ api = await connect(opts);
1480
+ spec = await api.fetchSpec();
1481
+ }
1482
+ if (opts.list) {
1483
+ const operations = listOperations(spec, opts.all ? {} : { contains: 'sftp' });
1484
+ if (opts.json) {
1485
+ printJson(operations);
1486
+ return;
1487
+ }
1488
+ if (operations.length === 0) {
1489
+ out.warn('No matching operations in the installed release spec.');
1490
+ return;
1491
+ }
1492
+ console.log(renderTable(['METHOD', 'OPERATION', 'SUMMARY'], operations.map(entry => [entry.method.toUpperCase(), entry.shortPath, entry.summary]), { maxWidth: [7, 64, 60] }));
1493
+ console.log();
1494
+ out.info(`${operations.length} operation(s) · OpenAPI ${spec.openapi ?? 'unknown'} · ${spec.info?.version ?? ''}`);
1495
+ return;
1496
+ }
1497
+ if (!operation) {
1498
+ throw new ApplianceApiError('Provide an operation, or use --list to discover them.');
1499
+ }
1500
+ const withMethod = opts.method
1501
+ ? `${opts.method.toUpperCase()} ${operation.replace(/^(GET|POST|PUT|PATCH|DELETE)\s+/i, '')}`
1502
+ : operation;
1503
+ const resolved = getOperation(spec, withMethod);
1504
+ const pathParams = parsePairs(opts.param, '--param');
1505
+ Object.assign(pathParams, resolved.extractedParams);
1506
+ // The generated spec does not always declare path parameters, so use
1507
+ // the template placeholders as the authoritative required set.
1508
+ for (const name of pathPlaceholders(resolved.path)) {
1509
+ if (name === 'org_id')
1510
+ continue;
1511
+ if (!pathParams[name]) {
1512
+ throw new ApplianceApiError(`Missing path parameter --param ${name}=<value> for ${resolved.shortPath}`);
1513
+ }
1514
+ }
1515
+ let path = resolved.path;
1516
+ for (const [key, value] of Object.entries(pathParams)) {
1517
+ path = path.split(`{${key}}`).join(encodeURIComponent(value));
1518
+ }
1519
+ path = path.replace('{org_id}', String(api.orgId));
1520
+ const query = parsePairs(opts.query, '--query');
1521
+ for (const parameter of operationQueryParams(resolved.operation)) {
1522
+ if (parameter.required && !query[parameter.name]) {
1523
+ throw new ApplianceApiError(`Missing required query parameter --query ${parameter.name}=<value> for ${resolved.shortPath}`);
1524
+ }
1525
+ }
1526
+ let body;
1527
+ const bodySchema = operationBodySchema(spec, resolved.operation);
1528
+ if (opts.body !== undefined) {
1529
+ body = readJsonOption(opts.body, '--body');
1530
+ }
1531
+ else if (resolved.operation.requestBody?.required) {
1532
+ throw new ApplianceApiError(`This operation requires a body (${describeSchema(spec, bodySchema)}). Pass --body <json|@file>.`);
1533
+ }
1534
+ if (body !== undefined && bodySchema) {
1535
+ const errors = validateAgainstSchema(spec, bodySchema, body);
1536
+ if (errors.length > 0) {
1537
+ throw new ApplianceApiError(`Invalid request body:\n - ${errors.join('\n - ')}`);
1538
+ }
1539
+ }
1540
+ const method = resolved.method.toUpperCase();
1541
+ if (opts.dryRun) {
1542
+ console.log(`${chalk.bold(method)} ${api.urlFor(path, query)}`);
1543
+ console.log(` Authorization: ${api.token ? '[REDACTED]' : '(none)'}`);
1544
+ if (body !== undefined) {
1545
+ console.log(' Content-Type: application/json');
1546
+ console.log(JSON.stringify(body, null, 2));
1547
+ }
1548
+ return;
1549
+ }
1550
+ if (method === 'DELETE') {
1551
+ await confirmDestructive(`Send DELETE ${resolved.shortPath}?`, opts.yes);
1552
+ }
1553
+ const response = await api.request(method, path, {
1554
+ query,
1555
+ body,
1556
+ outputPath: opts.output,
1557
+ parse: opts.output ? 'none' : opts.raw ? 'text' : 'json',
1558
+ accept: opts.raw ? 'text/plain' : 'application/json',
1559
+ });
1560
+ if (opts.output) {
1561
+ out.ok(`Wrote response to ${opts.output}`);
1562
+ }
1563
+ else if (opts.raw) {
1564
+ console.log(String(response.data));
1565
+ }
1566
+ else {
1567
+ printJson(response.data ?? null);
1568
+ }
1569
+ }));
1570
+ }
1571
+ export function registerSftpCommand(program) {
1572
+ const sftp = program
1573
+ .command('sftp')
1574
+ .description('SFTP Action Accounts, upload monitoring, storage, and support');
1575
+ sftp.action(() => sftp.outputHelp());
1576
+ registerStatus(sftp);
1577
+ registerDoctor(sftp);
1578
+ registerAccounts(sftp);
1579
+ registerMonitoring(sftp);
1580
+ registerActivity(sftp);
1581
+ registerStorage(sftp);
1582
+ registerAnalytics(sftp);
1583
+ registerReports(sftp);
1584
+ registerApi(sftp);
1585
+ }
1586
+ //# sourceMappingURL=sftp.js.map