@intflow/sentinelctl 0.1.0 → 0.2.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.
- package/README.md +62 -43
- package/package.json +10 -3
- package/src/args.mjs +42 -11
- package/src/client.mjs +4 -8
- package/src/commands.mjs +543 -244
- package/src/credentials.mjs +7 -8
- package/src/format.mjs +4 -6
- package/src/login.mjs +1 -2
- package/src/main.mjs +287 -67
package/src/commands.mjs
CHANGED
|
@@ -1,29 +1,42 @@
|
|
|
1
1
|
import { readFile, writeFile } from 'node:fs/promises';
|
|
2
|
-
import { keyValue,
|
|
2
|
+
import { keyValue, UsageError } from './args.mjs';
|
|
3
3
|
import { ageText, localTime, percentText, renderKeyValues, renderTable, singleLine } from './format.mjs';
|
|
4
4
|
|
|
5
|
-
const roleLabels = { viewer: '
|
|
6
|
-
const outputChoices = ['table', 'json', 'ndjson'];
|
|
5
|
+
export const roleLabels = { viewer: 'viewer (read-only)', editor: 'editor', admin: 'admin' };
|
|
6
|
+
export const outputChoices = ['table', 'json', 'ndjson'];
|
|
7
7
|
const logRanges = ['15m', '1h', '6h', '24h', '7d'];
|
|
8
|
+
const deviceRanges = ['30m', '1h', '6h', '24h', '7d', '30d'];
|
|
8
9
|
const logLevels = ['emergency', 'critical', 'error', 'warning', 'info', 'debug'];
|
|
10
|
+
const deviceStatuses = ['all', 'online', 'delayed', 'offline', 'unknown', 'maintenance', 'retired'];
|
|
11
|
+
const lifecycles = ['operational', 'all', 'active', 'maintenance', 'retired'];
|
|
12
|
+
const deviceSorts = ['attention', 'status', 'displayName', 'cpu', 'memory', 'disk', 'load', 'temperature', 'ageSeconds', 'role', 'site', 'group', 'incidentCount'];
|
|
9
13
|
|
|
10
14
|
const logFilterOptions = {
|
|
11
|
-
query: { alias: 'q', type: 'string' },
|
|
12
|
-
agent: { alias: 'a', type: 'string' },
|
|
13
|
-
level: { alias: 'l', type: 'string', choices: logLevels },
|
|
14
|
-
source: { alias: 's', type: 'string' },
|
|
15
|
-
range: { alias: 'r', type: 'string', choices: logRanges },
|
|
16
|
-
from: { type: 'string' },
|
|
17
|
-
to: { type: 'string' },
|
|
15
|
+
query: { alias: 'q', type: 'string', placeholder: 'text', description: 'Case-insensitive text in the message. Positional words are appended.' },
|
|
16
|
+
agent: { alias: 'a', type: 'string', placeholder: 'id', description: 'Exact agent ID (find it with `devices lookup`).' },
|
|
17
|
+
level: { alias: 'l', type: 'string', choices: logLevels, description: 'This severity and above (error includes emergency/alert/critical).' },
|
|
18
|
+
source: { alias: 's', type: 'string', placeholder: 'source', description: 'Exact source: systemd unit, container name, syslog identifier (see `logs sources`).' },
|
|
19
|
+
range: { alias: 'r', type: 'string', choices: logRanges, description: 'Relative window. Default 15m when --from/--to are absent.' },
|
|
20
|
+
from: { type: 'string', placeholder: 'ISO', description: 'Absolute window start, e.g. 2026-09-30T00:00:00+09:00. Requires --to; at most 30 days.' },
|
|
21
|
+
to: { type: 'string', placeholder: 'ISO', description: 'Absolute window end.' },
|
|
18
22
|
};
|
|
19
23
|
|
|
20
24
|
function logCriteria(options, positionals) {
|
|
21
25
|
const text = [options.query, ...positionals].filter(Boolean).join(' ');
|
|
22
|
-
if ((options.from && !options.to) || (!options.from && options.to))
|
|
23
|
-
|
|
26
|
+
if ((options.from && !options.to) || (!options.from && options.to)) {
|
|
27
|
+
throw new UsageError('--from and --to must be given together.', { hint: 'For a recent window use --range, e.g. -r 1h.' });
|
|
28
|
+
}
|
|
29
|
+
if (options.from && options.range) throw new UsageError('--range cannot be combined with --from/--to.');
|
|
30
|
+
for (const [name, value] of [['from', options.from], ['to', options.to]]) {
|
|
31
|
+
if (value && !Number.isFinite(Date.parse(value))) {
|
|
32
|
+
throw new UsageError(`Cannot parse --${name}: ${value}`, { hint: 'Use ISO 8601, e.g. 2026-09-30T09:00:00+09:00.' });
|
|
33
|
+
}
|
|
34
|
+
}
|
|
24
35
|
return {
|
|
25
36
|
q: text || undefined, agentId: options.agent, level: options.level, source: options.source,
|
|
26
|
-
range: options.from ? undefined : (options.range ?? '15m'),
|
|
37
|
+
range: options.from ? undefined : (options.range ?? '15m'),
|
|
38
|
+
from: options.from ? new Date(options.from).toISOString() : undefined,
|
|
39
|
+
to: options.to ? new Date(options.to).toISOString() : undefined,
|
|
27
40
|
};
|
|
28
41
|
}
|
|
29
42
|
|
|
@@ -35,63 +48,64 @@ function emit(context, value, table) {
|
|
|
35
48
|
}
|
|
36
49
|
|
|
37
50
|
const logColumns = [
|
|
38
|
-
{ header: '
|
|
39
|
-
{ header: '
|
|
40
|
-
{ header: '
|
|
41
|
-
{ header: '
|
|
42
|
-
{ header: '
|
|
51
|
+
{ header: 'TIME', value: (log) => localTime(log.timestamp), max: 19 },
|
|
52
|
+
{ header: 'LEVEL', value: (log) => log.severity, max: 9 },
|
|
53
|
+
{ header: 'DEVICE', value: (log) => log.displayName ?? log.host ?? log.agentId, max: 24 },
|
|
54
|
+
{ header: 'SOURCE', value: (log) => log.source, max: 22 },
|
|
55
|
+
{ header: 'MESSAGE', value: (log) => log.message },
|
|
43
56
|
];
|
|
44
57
|
|
|
45
58
|
function logKey(log) {
|
|
46
59
|
return `${log.timestamp}\n${log.agentId}\n${log.source}\n${log.message}`;
|
|
47
60
|
}
|
|
48
61
|
|
|
49
|
-
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
const criteria =
|
|
61
|
-
|
|
62
|
-
const
|
|
62
|
+
function emptyLogHint(criteria) {
|
|
63
|
+
const tips = [];
|
|
64
|
+
if (criteria.range && criteria.range !== '7d') tips.push('widen the window (-r 24h or -r 7d)');
|
|
65
|
+
if (criteria.q) tips.push('shorten or drop the search text');
|
|
66
|
+
if (criteria.source) tips.push('check source names with `sentinelctl logs sources -r 24h`');
|
|
67
|
+
if (criteria.agentId) tips.push('check the agent ID with `sentinelctl devices lookup <name>`');
|
|
68
|
+
if (criteria.level) tips.push('drop -l');
|
|
69
|
+
return tips.length ? `No logs matched. Try: ${tips.join('; ')}.` : 'No logs matched.';
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function logsSearch(context, { options, positionals }) {
|
|
73
|
+
const criteria = logCriteria(options, positionals);
|
|
74
|
+
const paging = options.all || options.pages > 1;
|
|
75
|
+
const limit = options.fields ? 50 : (options.limit ?? (paging ? 250 : 100));
|
|
76
|
+
const query = { ...criteria, limit, includeFields: options.fields ? 1 : undefined };
|
|
77
|
+
if (options.follow) return followLogs(context, query, options);
|
|
78
|
+
const maxPages = options.all ? 200 : Math.max(1, options.pages ?? 1);
|
|
63
79
|
const logs = [];
|
|
64
80
|
let cursor;
|
|
65
81
|
let last;
|
|
66
82
|
for (let page = 0; page < maxPages; page += 1) {
|
|
67
|
-
last = await context.client.get('/api/logs', { ...
|
|
83
|
+
last = await context.client.get('/api/logs', { ...query, cursor });
|
|
68
84
|
logs.push(...last.logs);
|
|
69
85
|
if (!last.hasMore || !last.nextCursor) break;
|
|
70
86
|
cursor = last.nextCursor;
|
|
71
87
|
}
|
|
72
88
|
if (context.global.output === 'json') {
|
|
73
89
|
context.out(JSON.stringify({ criteria: last.criteria, window: last.window, count: logs.length, hasMore: last.hasMore, logs }, null, 2));
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
emit(context, logs, () => renderTable(logs, logColumns, { width: context.width, wide: options.wide }));
|
|
77
|
-
if (context.global.output === 'table') {
|
|
78
|
-
context.err(`${logs.length}건${last.hasMore ? ' · 더 있음 (--pages N 또는 --all)' : ''} · ${localTime(last.window.start)} ~ ${localTime(last.window.end)}`);
|
|
90
|
+
} else {
|
|
91
|
+
emit(context, logs, () => renderTable(logs, logColumns, { width: context.width, wide: options.wide }));
|
|
79
92
|
}
|
|
93
|
+
if (!logs.length) context.hint(emptyLogHint(criteria));
|
|
94
|
+
else context.note(`${logs.length} logs · ${localTime(last.window.start)} → ${localTime(last.window.end)}`);
|
|
95
|
+
if (last.hasMore) context.hint('More logs match. Continue with --pages N or --all (up to 10,000), or narrow the filters.');
|
|
80
96
|
}
|
|
81
97
|
|
|
82
|
-
async function followLogs(context,
|
|
98
|
+
async function followLogs(context, query, options) {
|
|
83
99
|
const seen = new Set();
|
|
84
100
|
const intervalMs = Math.max(2, options.interval) * 1_000;
|
|
85
|
-
|
|
86
|
-
// 간격 사이에 더 많은 로그가 들어와 일부를 건너뛴 것이므로 알린다.
|
|
87
|
-
const followCriteria = { ...criteria, range: '15m', from: undefined, to: undefined, limit: criteria.includeFields ? 50 : 250 };
|
|
101
|
+
const followQuery = { ...query, range: '15m', from: undefined, to: undefined, limit: query.includeFields ? 50 : 250 };
|
|
88
102
|
let first = true;
|
|
89
|
-
context.err('
|
|
103
|
+
context.err('Following new logs. Press Ctrl+C to stop.');
|
|
90
104
|
while (!context.signal?.aborted) {
|
|
91
|
-
const body = await context.client.get('/api/logs',
|
|
105
|
+
const body = await context.client.get('/api/logs', followQuery);
|
|
92
106
|
const fresh = body.logs.filter((log) => !seen.has(logKey(log))).reverse();
|
|
93
107
|
if (!first && body.hasMore && fresh.length === body.logs.length) {
|
|
94
|
-
context.err(
|
|
108
|
+
context.err(`warning: more than ${followQuery.limit} logs arrived within ${options.interval}s and some were skipped. Narrow with -a/-s/-l/-q or lower --interval.`);
|
|
95
109
|
}
|
|
96
110
|
for (const log of body.logs) seen.add(logKey(log));
|
|
97
111
|
if (seen.size > 20_000) for (const key of [...seen].slice(0, seen.size - 10_000)) seen.delete(key);
|
|
@@ -109,62 +123,55 @@ async function followLogs(context, criteria, options) {
|
|
|
109
123
|
}
|
|
110
124
|
}
|
|
111
125
|
|
|
112
|
-
async function logsContext(context,
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
}
|
|
117
|
-
if (!options.agent || !options.source || !options.timestamp) throw new UsageError('--agent, --source, --timestamp가 필요합니다.');
|
|
126
|
+
async function logsContext(context, { options }) {
|
|
127
|
+
if (!options.agent || !options.source || !options.timestamp) {
|
|
128
|
+
throw new UsageError('--agent, --source and --timestamp are all required.',
|
|
129
|
+
{ hint: 'Copy agentId, source and timestamp from `logs search -o json` output.' });
|
|
130
|
+
}
|
|
118
131
|
const body = await context.client.get('/api/logs/context', {
|
|
119
132
|
agentId: options.agent, source: options.source, timestamp: options.timestamp,
|
|
120
133
|
before: options.before, after: options.after, windowSeconds: options.window,
|
|
121
134
|
});
|
|
122
135
|
emit(context, context.global.output === 'ndjson' ? body.logs : body,
|
|
123
136
|
() => renderTable(body.logs, logColumns, { width: context.width, wide: options.wide }));
|
|
137
|
+
if (!body.count) context.hint('No logs from that device and source around the timestamp. Try a larger --window (up to 3600).');
|
|
124
138
|
}
|
|
125
139
|
|
|
126
|
-
async function logsHistogram(context,
|
|
127
|
-
const { options, positionals } = parseArguments(argv, logFilterOptions);
|
|
140
|
+
async function logsHistogram(context, { options, positionals }) {
|
|
128
141
|
const body = await context.client.get('/api/logs/histogram', logCriteria(options, positionals));
|
|
129
142
|
emit(context, context.global.output === 'ndjson' ? body.buckets : body, () => {
|
|
130
143
|
const peak = Math.max(1, ...body.buckets.map((bucket) => bucket.total));
|
|
131
144
|
return renderTable(body.buckets, [
|
|
132
|
-
{ header: '
|
|
133
|
-
{ header: '
|
|
134
|
-
{ header: '
|
|
145
|
+
{ header: 'TIME', value: (bucket) => localTime(bucket.at) },
|
|
146
|
+
{ header: 'TOTAL', value: (bucket) => String(bucket.total) },
|
|
147
|
+
{ header: 'ERRORS', value: (bucket) => String(bucket.errors) },
|
|
135
148
|
{ header: '', value: (bucket) => '█'.repeat(Math.round((bucket.total / peak) * 30)) },
|
|
136
149
|
], { width: context.width, wide: true });
|
|
137
150
|
});
|
|
151
|
+
context.note(`${body.total} logs in ${body.buckets.length} buckets of ${body.stepSeconds}s`);
|
|
138
152
|
}
|
|
139
153
|
|
|
140
|
-
async function logsSources(context,
|
|
141
|
-
const { options } = parseArguments(argv, { range: { alias: 'r', type: 'string', choices: logRanges, default: '24h' } });
|
|
154
|
+
async function logsSources(context, { options }) {
|
|
142
155
|
const body = await context.client.get('/api/logs/sources', { range: options.range });
|
|
143
156
|
emit(context, context.global.output === 'ndjson' ? body.sources : body, () => renderTable(body.sources, [
|
|
144
|
-
{ header: '
|
|
145
|
-
{ header: '
|
|
157
|
+
{ header: 'SOURCE', value: (source) => source.value },
|
|
158
|
+
{ header: 'COUNT', value: (source) => String(source.count) },
|
|
146
159
|
], { width: context.width }));
|
|
160
|
+
context.note(`Top sources in a sample of the latest ${body.sampleSize} logs.`);
|
|
147
161
|
}
|
|
148
162
|
|
|
149
163
|
const deviceColumns = [
|
|
150
|
-
{ header: '
|
|
151
|
-
{ header: '
|
|
152
|
-
{ header: '
|
|
153
|
-
{ header: '
|
|
164
|
+
{ header: 'AGENT ID', value: (device) => device.agentId, max: 28 },
|
|
165
|
+
{ header: 'NAME', value: (device) => device.displayName ?? device.host, max: 28 },
|
|
166
|
+
{ header: 'STATUS', value: (device) => device.status, max: 11 },
|
|
167
|
+
{ header: 'SITE', value: (device) => device.site, max: 14 },
|
|
154
168
|
{ header: 'CPU', value: (device) => percentText(device.cpu), max: 5 },
|
|
155
|
-
{ header: '
|
|
156
|
-
{ header: '
|
|
157
|
-
{ header: '
|
|
169
|
+
{ header: 'MEM', value: (device) => percentText(device.memory), max: 5 },
|
|
170
|
+
{ header: 'DISK', value: (device) => percentText(device.disk), max: 5 },
|
|
171
|
+
{ header: 'LAST SEEN', value: (device) => ageText(device.ageSeconds) },
|
|
158
172
|
];
|
|
159
173
|
|
|
160
|
-
async function devicesList(context,
|
|
161
|
-
const { options, positionals } = parseArguments(argv, {
|
|
162
|
-
query: { alias: 'q', type: 'string' }, status: { type: 'string' }, lifecycle: { type: 'string' },
|
|
163
|
-
role: { type: 'string' }, site: { type: 'string' }, group: { type: 'string' }, maintainer: { type: 'string' },
|
|
164
|
-
fleet: { type: 'string' }, focus: { type: 'string' }, inventory: { type: 'string' },
|
|
165
|
-
label: { type: 'string', multiple: true }, sort: { type: 'string' }, order: { type: 'string', choices: ['asc', 'desc'] },
|
|
166
|
-
page: { type: 'integer', default: 1 }, 'page-size': { type: 'integer', default: 50 }, all: { type: 'boolean' },
|
|
167
|
-
});
|
|
174
|
+
async function devicesList(context, { options, positionals }) {
|
|
168
175
|
for (const label of options.label ?? []) keyValue(label, '--label');
|
|
169
176
|
const query = {
|
|
170
177
|
query: [options.query, ...positionals].filter(Boolean).join(' ') || undefined,
|
|
@@ -173,124 +180,134 @@ async function devicesList(context, argv) {
|
|
|
173
180
|
labels: options.label?.length ? JSON.stringify(options.label) : undefined, sort: options.sort, order: options.order,
|
|
174
181
|
pageSize: options.all ? 100 : options['page-size'],
|
|
175
182
|
};
|
|
176
|
-
const
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
183
|
+
const first = await context.client.get('/api/devices', { ...query, page: options.all ? 1 : options.page });
|
|
184
|
+
const devices = [...first.devices];
|
|
185
|
+
if (options.all && first.totalPages > 1) {
|
|
186
|
+
const pages = Array.from({ length: first.totalPages - 1 }, (_, index) => index + 2);
|
|
187
|
+
const results = new Map();
|
|
188
|
+
for (let index = 0; index < pages.length; index += 4) {
|
|
189
|
+
await Promise.all(pages.slice(index, index + 4).map(async (page) => {
|
|
190
|
+
results.set(page, (await context.client.get('/api/devices', { ...query, page })).devices);
|
|
191
|
+
}));
|
|
192
|
+
}
|
|
193
|
+
for (const page of pages) devices.push(...results.get(page));
|
|
182
194
|
}
|
|
183
195
|
if (context.global.output === 'json') {
|
|
184
|
-
context.out(JSON.stringify({ matched:
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
emit(context, devices, () => renderTable(devices, deviceColumns, { width: context.width }));
|
|
188
|
-
if (context.global.output === 'table') {
|
|
189
|
-
context.err(`${devices.length}/${body.matched}대${options.all ? '' : ` · ${body.page}/${body.totalPages} 페이지`}${body.stale ? ` · 오래된 데이터: ${body.staleReason ?? ''}` : ''}`);
|
|
196
|
+
context.out(JSON.stringify({ matched: first.matched, counts: first.counts, stale: first.stale, staleReason: first.staleReason, devices }, null, 2));
|
|
197
|
+
} else {
|
|
198
|
+
emit(context, devices, () => renderTable(devices, deviceColumns, { width: context.width }));
|
|
190
199
|
}
|
|
200
|
+
context.note(`${devices.length} of ${first.matched} devices${options.all ? '' : ` · page ${first.page}/${first.totalPages}`}`);
|
|
201
|
+
if (first.stale) context.hint(`Metrics are stale (${first.staleReason ?? 'metrics query failed'}). Check \`sentinelctl health\`.`);
|
|
202
|
+
if (!devices.length) context.hint('No devices matched. Add --lifecycle all to include maintenance/retired devices, or shorten the search text.');
|
|
203
|
+
else if (!options.all && first.totalPages > first.page) context.hint(`Next page: --page ${first.page + 1}; everything: --all`);
|
|
191
204
|
}
|
|
192
205
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
206
|
+
function requireAgentId(positionals) {
|
|
207
|
+
if (!positionals[0]) throw new UsageError('An agent ID is required.', { hint: 'Find it with `sentinelctl devices lookup <part of the name>`.' });
|
|
208
|
+
return positionals[0];
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function devicesShow(context, { options, positionals }) {
|
|
212
|
+
const agentId = requireAgentId(positionals);
|
|
213
|
+
const body = await context.client.get(`/api/devices/${encodeURIComponent(agentId)}`, { range: options.range, refresh: options.refresh ? 1 : undefined });
|
|
198
214
|
const device = body.device;
|
|
199
215
|
emit(context, body, () => renderKeyValues([
|
|
200
|
-
['
|
|
201
|
-
['
|
|
202
|
-
['CPU', percentText(device.cpu)], ['
|
|
203
|
-
['
|
|
204
|
-
['
|
|
205
|
-
['
|
|
216
|
+
['Agent ID', device.agentId], ['Name', device.displayName], ['Host', device.host],
|
|
217
|
+
['Status', device.observedStatus ?? device.status], ['Last seen', ageText(device.ageSeconds)],
|
|
218
|
+
['CPU', percentText(device.cpu)], ['Memory', percentText(device.memory)], ['Disk', percentText(device.disk)],
|
|
219
|
+
['Load', device.load], ['Temperature', device.temperature], ['Role', device.metadata?.role ?? device.role],
|
|
220
|
+
['Maintainer', device.metadata?.maintainer], ['Lifecycle', device.metadata?.lifecycle], ['Notes', device.metadata?.description],
|
|
221
|
+
['Data', body.stale ? `stale (${body.staleReason ?? ''})` : 'fresh'],
|
|
206
222
|
]));
|
|
223
|
+
if (context.global.output === 'table') context.hint(`Time series (${body.range}, ${body.stepSeconds}s step) are in \`series\` with -o json.`);
|
|
207
224
|
}
|
|
208
225
|
|
|
209
|
-
async function devicesLookup(context,
|
|
210
|
-
|
|
226
|
+
async function devicesLookup(context, { positionals }) {
|
|
227
|
+
if (!positionals.length) throw new UsageError('A search term is required.', { hint: 'Example: sentinelctl devices lookup edge-01' });
|
|
211
228
|
const body = await context.client.get('/api/devices/lookup', { query: positionals.join(' ') });
|
|
212
229
|
emit(context, context.global.output === 'ndjson' ? body.devices : body, () => renderTable(body.devices, [
|
|
213
|
-
{ header: '
|
|
214
|
-
{ header: '
|
|
215
|
-
{ header: '
|
|
230
|
+
{ header: 'AGENT ID', value: (device) => device.agentId },
|
|
231
|
+
{ header: 'NAME', value: (device) => device.displayName },
|
|
232
|
+
{ header: 'HOST', value: (device) => device.host },
|
|
216
233
|
], { width: context.width }));
|
|
234
|
+
if (!body.devices.length) context.hint('No device matched. Try a shorter part of the name, host or ID.');
|
|
217
235
|
}
|
|
218
236
|
|
|
219
|
-
async function devicesOverview(context,
|
|
220
|
-
const
|
|
221
|
-
const [agentId] = positionals;
|
|
222
|
-
if (!agentId) throw new UsageError('에이전트 ID가 필요합니다.');
|
|
237
|
+
async function devicesOverview(context, { options, positionals }) {
|
|
238
|
+
const agentId = requireAgentId(positionals);
|
|
223
239
|
const body = await context.client.get(`/api/devices/${encodeURIComponent(agentId)}/overview`, { range: options.range });
|
|
224
240
|
emit(context, body, () => renderKeyValues([
|
|
225
|
-
['
|
|
226
|
-
['
|
|
227
|
-
['
|
|
241
|
+
['Agent ID', body.agentId], ['Name', body.device?.displayName], ['Metrics', body.products?.metrics?.state],
|
|
242
|
+
['Logs', body.products?.logs?.state], ['Attention', (body.attentionReasons ?? []).join(', ') || 'none'],
|
|
243
|
+
['Related', (body.related ?? []).map((item) => item.agentId ?? item).join(', ')],
|
|
228
244
|
]));
|
|
229
245
|
}
|
|
230
246
|
|
|
231
247
|
async function health(context) {
|
|
232
248
|
const body = await context.client.get('/api/health');
|
|
233
249
|
emit(context, body, () => [
|
|
234
|
-
renderKeyValues([['
|
|
235
|
-
['
|
|
250
|
+
renderKeyValues([['Overall', body.status], ['Version', `${body.application?.version} (${body.application?.commit})`],
|
|
251
|
+
['Observer', body.observer?.status ?? body.observer?.state]]),
|
|
236
252
|
'',
|
|
237
253
|
renderTable(Object.entries(body.components ?? {}), [
|
|
238
|
-
{ header: '
|
|
239
|
-
{ header: '
|
|
240
|
-
{ header: '
|
|
241
|
-
{ header: '
|
|
254
|
+
{ header: 'COMPONENT', value: ([name]) => name },
|
|
255
|
+
{ header: 'STATUS', value: ([, component]) => component.status },
|
|
256
|
+
{ header: 'LATENCY', value: ([, component]) => (Number.isFinite(component.latencyMs) ? `${component.latencyMs}ms` : '-') },
|
|
257
|
+
{ header: 'ERROR', value: ([, component]) => component.error ?? '' },
|
|
242
258
|
], { width: context.width }),
|
|
243
259
|
].join('\n'));
|
|
260
|
+
if (body.status !== 'ok') context.hint('Some backends are unhealthy; results may be stale or empty.');
|
|
244
261
|
}
|
|
245
262
|
|
|
246
|
-
async function metadataGet(context,
|
|
247
|
-
const
|
|
248
|
-
|
|
249
|
-
const body = await context.client.get(`/api/devices/${encodeURIComponent(positionals[0])}/metadata`);
|
|
263
|
+
async function metadataGet(context, { positionals }) {
|
|
264
|
+
const agentId = requireAgentId(positionals);
|
|
265
|
+
const body = await context.client.get(`/api/devices/${encodeURIComponent(agentId)}/metadata`);
|
|
250
266
|
emit(context, body, () => renderKeyValues([
|
|
251
|
-
['
|
|
252
|
-
['
|
|
253
|
-
['
|
|
254
|
-
['
|
|
255
|
-
['
|
|
267
|
+
['Agent ID', body.agentId], ['Revision', body.revision], ['Name', body.displayName], ['Role', body.role],
|
|
268
|
+
['Maintainer', body.maintainer], ['Contact', body.maintainerContact], ['Lifecycle', body.lifecycle],
|
|
269
|
+
['Maintenance until', body.maintenanceUntil], ['Maintenance reason', body.maintenanceReason], ['Notes', body.description],
|
|
270
|
+
['Labels', Object.entries(body.labels ?? {}).map(([key, value]) => `${key}=${value}`).join(', ')],
|
|
271
|
+
['Updated', body.updatedAt],
|
|
256
272
|
]));
|
|
257
273
|
}
|
|
258
274
|
|
|
259
|
-
async function metadataHistory(context,
|
|
260
|
-
const
|
|
261
|
-
|
|
262
|
-
const body = await context.client.get(`/api/devices/${encodeURIComponent(positionals[0])}/metadata/history`, { limit: options.limit });
|
|
275
|
+
async function metadataHistory(context, { options, positionals }) {
|
|
276
|
+
const agentId = requireAgentId(positionals);
|
|
277
|
+
const body = await context.client.get(`/api/devices/${encodeURIComponent(agentId)}/metadata/history`, { limit: options.limit });
|
|
263
278
|
emit(context, context.global.output === 'ndjson' ? body.history : body, () => renderTable(body.history, [
|
|
264
|
-
{ header: '
|
|
265
|
-
{ header: '
|
|
266
|
-
{ header: '
|
|
267
|
-
{ header: '
|
|
279
|
+
{ header: 'REVISION', value: (entry) => `${entry.beforeRevision}→${entry.afterRevision}` },
|
|
280
|
+
{ header: 'TIME', value: (entry) => localTime(entry.createdAt) },
|
|
281
|
+
{ header: 'ACTOR', value: (entry) => entry.actor, max: 32 },
|
|
282
|
+
{ header: 'CHANGED', value: (entry) => entry.changedFields.join(', ') },
|
|
268
283
|
], { width: context.width }));
|
|
284
|
+
if (body.history.length) context.hint(`Restore a revision with \`sentinelctl metadata rollback ${agentId} --to <revision>\`.`);
|
|
269
285
|
}
|
|
270
286
|
|
|
271
287
|
const metadataFieldOptions = {
|
|
272
288
|
'display-name': 'displayName', role: 'role', maintainer: 'maintainer', contact: 'maintainerContact',
|
|
273
289
|
description: 'description', lifecycle: 'lifecycle', 'maintenance-until': 'maintenanceUntil', 'maintenance-reason': 'maintenanceReason',
|
|
274
290
|
};
|
|
291
|
+
const metadataEditOptions = {
|
|
292
|
+
'display-name': { type: 'string', placeholder: 'name', description: 'Display name (max 80 chars).' },
|
|
293
|
+
role: { type: 'string', placeholder: 'role', description: 'Device role (max 80 chars).' },
|
|
294
|
+
maintainer: { type: 'string', placeholder: 'name', description: 'Maintainer (max 100 chars).' },
|
|
295
|
+
contact: { type: 'string', placeholder: 'contact', description: 'Maintainer contact (max 160 chars).' },
|
|
296
|
+
description: { type: 'string', placeholder: 'text', description: 'Operational notes (max 500 chars).' },
|
|
297
|
+
lifecycle: { type: 'string', choices: ['active', 'maintenance', 'retired'], description: 'Lifecycle. Pair maintenance with --maintenance-until.' },
|
|
298
|
+
'maintenance-until': { type: 'string', placeholder: 'ISO', description: 'Planned end of maintenance; the device counts as active afterwards.' },
|
|
299
|
+
'maintenance-reason': { type: 'string', placeholder: 'text', description: 'Maintenance reason (max 200 chars).' },
|
|
300
|
+
label: { type: 'string', multiple: true, placeholder: 'k=v', description: 'Add or change a label. Other labels are kept.' },
|
|
301
|
+
'remove-label': { type: 'string', multiple: true, placeholder: 'key', description: 'Remove a label.' },
|
|
302
|
+
clear: { type: 'string', multiple: true, choices: Object.keys(metadataFieldOptions), description: 'Clear a field.' },
|
|
303
|
+
};
|
|
275
304
|
|
|
276
|
-
|
|
277
|
-
const { options, positionals } = parseArguments(argv, {
|
|
278
|
-
...Object.fromEntries(Object.keys(metadataFieldOptions).map((name) => [name, { type: 'string' }])),
|
|
279
|
-
label: { type: 'string', multiple: true }, 'remove-label': { type: 'string', multiple: true },
|
|
280
|
-
clear: { type: 'string', multiple: true },
|
|
281
|
-
});
|
|
282
|
-
const [agentId] = positionals;
|
|
283
|
-
if (!agentId) throw new UsageError('에이전트 ID가 필요합니다.');
|
|
284
|
-
const path = `/api/devices/${encodeURIComponent(agentId)}/metadata`;
|
|
285
|
-
const current = await context.client.get(path);
|
|
305
|
+
function metadataPatch(options, current) {
|
|
286
306
|
const patch = { revision: current.revision };
|
|
287
307
|
for (const [option, field] of Object.entries(metadataFieldOptions)) {
|
|
288
308
|
if (options[option] !== undefined) patch[field] = options[option];
|
|
289
309
|
}
|
|
290
|
-
for (const option of options.clear ?? [])
|
|
291
|
-
if (!metadataFieldOptions[option]) throw new UsageError(`--clear에는 ${Object.keys(metadataFieldOptions).join(', ')} 중 하나를 지정하세요.`);
|
|
292
|
-
patch[metadataFieldOptions[option]] = null;
|
|
293
|
-
}
|
|
310
|
+
for (const option of options.clear ?? []) patch[metadataFieldOptions[option]] = null;
|
|
294
311
|
if (options.label?.length || options['remove-label']?.length) {
|
|
295
312
|
const labels = { ...(current.labels ?? {}) };
|
|
296
313
|
for (const item of options.label ?? []) {
|
|
@@ -300,119 +317,192 @@ async function metadataSet(context, argv) {
|
|
|
300
317
|
for (const key of options['remove-label'] ?? []) delete labels[key];
|
|
301
318
|
patch.labels = labels;
|
|
302
319
|
}
|
|
303
|
-
if (Object.keys(patch).length === 1)
|
|
320
|
+
if (Object.keys(patch).length === 1) {
|
|
321
|
+
throw new UsageError('Nothing to change. Give at least one field option.', { hint: 'Example: --display-name "DB 1" --label site=seoul (all fields: --help)' });
|
|
322
|
+
}
|
|
323
|
+
return patch;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
async function metadataSet(context, { options, positionals }) {
|
|
327
|
+
const agentId = requireAgentId(positionals);
|
|
328
|
+
metadataPatch(options, { revision: 0, labels: {} });
|
|
329
|
+
const path = `/api/devices/${encodeURIComponent(agentId)}/metadata`;
|
|
330
|
+
const current = await context.client.get(path);
|
|
331
|
+
const patch = metadataPatch(options, current);
|
|
332
|
+
if (options['dry-run']) {
|
|
333
|
+
const preview = await context.client.post('/api/devices/metadata/bulk/preview', { updates: [{ agentId, ...patch }] });
|
|
334
|
+
emit(context, preview, () => JSON.stringify(preview.results[0]?.changes ?? {}, null, 2));
|
|
335
|
+
context.hint('Dry run only. Run again without --dry-run to apply.');
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
304
338
|
const updated = await context.client.patch(path, patch);
|
|
305
|
-
emit(context, updated, () =>
|
|
339
|
+
emit(context, updated, () => `Saved metadata for ${agentId} as revision ${updated.revision}.`);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
async function metadataBulk(context, { options, positionals }) {
|
|
343
|
+
const agentIds = [...new Set(positionals)];
|
|
344
|
+
if (!agentIds.length) throw new UsageError('Give one or more agent IDs.', { hint: 'Example: sentinelctl metadata bulk id1 id2 --role db --dry-run' });
|
|
345
|
+
if (agentIds.length > 100) throw new UsageError('At most 100 devices per bulk update.');
|
|
346
|
+
metadataPatch(options, { revision: 0, labels: {} });
|
|
347
|
+
const currents = [];
|
|
348
|
+
for (let index = 0; index < agentIds.length; index += 8) {
|
|
349
|
+
currents.push(...await Promise.all(agentIds.slice(index, index + 8)
|
|
350
|
+
.map((agentId) => context.client.get(`/api/devices/${encodeURIComponent(agentId)}/metadata`))));
|
|
351
|
+
}
|
|
352
|
+
const updates = currents.map((current) => ({ agentId: current.agentId, ...metadataPatch(options, current) }));
|
|
353
|
+
const body = options['dry-run']
|
|
354
|
+
? await context.client.post('/api/devices/metadata/bulk/preview', { updates })
|
|
355
|
+
: await context.client.patch('/api/devices/metadata/bulk', { updates });
|
|
356
|
+
emit(context, context.global.output === 'ndjson' ? body.results : body, () => renderTable(body.results, [
|
|
357
|
+
{ header: 'AGENT ID', value: (result) => result.agentId },
|
|
358
|
+
{ header: 'RESULT', value: (result) => (result.ok ? 'ok' : `failed ${result.status ?? ''}`) },
|
|
359
|
+
{ header: 'DETAIL', value: (result) => {
|
|
360
|
+
if (!result.ok) return result.error;
|
|
361
|
+
if (result.metadata) return `revision ${result.metadata.revision}`;
|
|
362
|
+
return Object.keys(result.changes ?? {}).join(', ') || 'no change';
|
|
363
|
+
} },
|
|
364
|
+
], { width: context.width }));
|
|
365
|
+
const failures = body.results.filter((result) => !result.ok).length;
|
|
366
|
+
if (options['dry-run']) context.hint(`Dry run: ${body.changeCount ?? 0} would change, ${failures} would fail. Run without --dry-run to apply.`);
|
|
367
|
+
else if (failures) context.hint(`${failures} failed. A 409 means someone changed that device meanwhile; rerun for those devices only.`);
|
|
306
368
|
}
|
|
307
369
|
|
|
308
|
-
async function metadataRollback(context,
|
|
309
|
-
const
|
|
310
|
-
|
|
311
|
-
if (!agentId || options.to === undefined) throw new UsageError('에이전트 ID와 --to <변경 버전>이 필요합니다.');
|
|
370
|
+
async function metadataRollback(context, { options, positionals }) {
|
|
371
|
+
const agentId = requireAgentId(positionals);
|
|
372
|
+
if (options.to === undefined) throw new UsageError('--to <revision> is required.', { hint: `List revisions with \`sentinelctl metadata history ${agentId}\`.` });
|
|
312
373
|
const path = `/api/devices/${encodeURIComponent(agentId)}/metadata`;
|
|
313
374
|
const current = await context.client.get(path);
|
|
314
375
|
const updated = await context.client.post(`${path}/rollback`, { revision: current.revision, targetRevision: options.to });
|
|
315
|
-
emit(context, updated, () =>
|
|
376
|
+
emit(context, updated, () => `Restored ${agentId} to the content of revision ${options.to} (new revision ${updated.revision}).`);
|
|
316
377
|
}
|
|
317
378
|
|
|
318
|
-
async function metadataExport(context,
|
|
319
|
-
const { options } = parseArguments(argv, { file: { type: 'string' } });
|
|
379
|
+
async function metadataExport(context, { options }) {
|
|
320
380
|
const body = await context.client.get('/api/metadata/export');
|
|
321
381
|
if (options.file) {
|
|
322
|
-
await writeFile(options.file, `${JSON.stringify(body, null, 2)}\n`, { mode: 0o600, flag: 'wx' })
|
|
323
|
-
|
|
382
|
+
await writeFile(options.file, `${JSON.stringify(body, null, 2)}\n`, { mode: 0o600, flag: 'wx' }).catch((error) => {
|
|
383
|
+
if (error.code === 'EEXIST') throw new UsageError(`File already exists: ${options.file}`, { hint: 'Existing files are never overwritten; choose another path.' });
|
|
384
|
+
throw error;
|
|
385
|
+
});
|
|
386
|
+
context.note(`Saved metadata for ${body.devices?.length ?? 0} devices to ${options.file}.`);
|
|
324
387
|
return;
|
|
325
388
|
}
|
|
326
389
|
context.out(JSON.stringify(body, null, 2));
|
|
327
390
|
}
|
|
328
391
|
|
|
329
|
-
async function metadataImport(context,
|
|
330
|
-
|
|
331
|
-
'dry-run': { type: 'boolean' }, conflict: { type: 'string', choices: ['error', 'skip', 'overwrite'], default: 'error' },
|
|
332
|
-
});
|
|
333
|
-
if (!positionals[0]) throw new UsageError('가져올 JSON 파일 경로가 필요합니다.');
|
|
392
|
+
async function metadataImport(context, { options, positionals }) {
|
|
393
|
+
if (!positionals[0]) throw new UsageError('A JSON file path is required.', { hint: 'Use the format produced by `sentinelctl metadata export`.' });
|
|
334
394
|
let payload;
|
|
335
395
|
try {
|
|
336
396
|
payload = JSON.parse(await readFile(positionals[0], 'utf8'));
|
|
337
397
|
} catch {
|
|
338
|
-
throw new UsageError(`
|
|
398
|
+
throw new UsageError(`Cannot read JSON file: ${positionals[0]}`);
|
|
339
399
|
}
|
|
340
400
|
const path = options['dry-run'] ? '/api/metadata/import/validate' : '/api/metadata/import';
|
|
341
401
|
const body = (await context.client.request('POST', path, { query: { conflictPolicy: options.conflict }, body: payload })).body;
|
|
342
402
|
emit(context, body, () => JSON.stringify(body, null, 2));
|
|
403
|
+
if (options['dry-run']) context.hint('Validated only. Run again without --dry-run to import.');
|
|
343
404
|
}
|
|
344
405
|
|
|
345
406
|
const sessionColumns = [
|
|
346
407
|
{ header: 'ID', value: (session) => session.id },
|
|
347
|
-
{ header: '
|
|
348
|
-
{ header: '
|
|
349
|
-
{ header: '
|
|
350
|
-
{ header: '
|
|
351
|
-
{ header: '
|
|
408
|
+
{ header: 'KIND', value: (session) => (session.kind === 'cli' ? 'cli' : 'web') },
|
|
409
|
+
{ header: 'USER', value: (session) => session.user, max: 32 },
|
|
410
|
+
{ header: 'CLIENT', value: (session) => session.clientName ?? '-', max: 28 },
|
|
411
|
+
{ header: 'LAST USED', value: (session) => localTime(session.lastSeenAt) },
|
|
412
|
+
{ header: 'EXPIRES', value: (session) => localTime(session.expiresAt) },
|
|
352
413
|
];
|
|
353
414
|
|
|
354
415
|
async function sessionsList(context) {
|
|
355
416
|
const body = await context.client.get('/api/auth/sessions');
|
|
356
417
|
emit(context, context.global.output === 'ndjson' ? body.sessions : body, () => renderTable(
|
|
357
|
-
body.sessions, [...sessionColumns, { header: '', value: (session) => (session.current ? '←
|
|
418
|
+
body.sessions, [...sessionColumns, { header: '', value: (session) => (session.current ? '← current' : '') }], { width: context.width },
|
|
358
419
|
));
|
|
359
420
|
}
|
|
360
421
|
|
|
361
|
-
async function sessionsRevoke(context,
|
|
362
|
-
|
|
363
|
-
if (!positionals[0]) throw new UsageError('폐기할 세션 ID가 필요합니다.');
|
|
422
|
+
async function sessionsRevoke(context, { positionals }) {
|
|
423
|
+
if (!positionals[0]) throw new UsageError('A session ID is required.', { hint: 'List them with `sentinelctl sessions list`.' });
|
|
364
424
|
await context.client.post(`/api/auth/sessions/${encodeURIComponent(positionals[0])}/revoke`, {});
|
|
365
|
-
context.out(
|
|
425
|
+
context.out(`Revoked session ${positionals[0]}.`);
|
|
366
426
|
}
|
|
367
427
|
|
|
368
428
|
async function adminUsers(context) {
|
|
369
429
|
const body = await context.client.get('/api/admin/users');
|
|
370
430
|
emit(context, context.global.output === 'ndjson' ? body.users : body, () => renderTable(body.users, [
|
|
371
|
-
{ header: '
|
|
372
|
-
{ header: '
|
|
373
|
-
{ header: '
|
|
374
|
-
{ header: '
|
|
375
|
-
{ header: '
|
|
431
|
+
{ header: 'EMAIL', value: (user) => user.email, max: 36 },
|
|
432
|
+
{ header: 'ROLE', value: (user) => (user.disabled ? 'disabled' : user.effectiveRole) },
|
|
433
|
+
{ header: 'SOURCE', value: (user) => ({ bootstrap: 'bootstrap admin', grant: 'granted', default: 'default', disabled: 'disabled', intflow_app_admin: 'intflow app admin' })[user.roleSource] ?? user.roleSource },
|
|
434
|
+
{ header: 'WEB/CLI', value: (user) => `${user.sessions.web}/${user.sessions.cli}` },
|
|
435
|
+
{ header: 'LAST LOGIN', value: (user) => localTime(user.lastLoginAt) },
|
|
376
436
|
], { width: context.width }));
|
|
377
437
|
}
|
|
378
438
|
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
:
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
emit(context, body, () => `${body.email}: ${body.disabled ? '접근 중지' : (roleLabels[body.effectiveRole] ?? body.effectiveRole)}`);
|
|
439
|
+
function adminGrantCommand(grant) {
|
|
440
|
+
return async (context, { options, positionals }) => {
|
|
441
|
+
const [email] = positionals;
|
|
442
|
+
if (!email) throw new UsageError('An email is required.', { hint: 'Example: sentinelctl admin grant kim@intflow.ai --role editor' });
|
|
443
|
+
const role = grant.role === undefined ? options.role : grant.role;
|
|
444
|
+
if (grant.role === undefined && !role) throw new UsageError('--role is required.', { hint: 'Allowed values: viewer, editor, admin' });
|
|
445
|
+
const body = await context.client.post('/api/admin/users/grant', { email, role, disabled: grant.disabled ?? false });
|
|
446
|
+
emit(context, body, () => `${body.email}: ${body.disabled ? 'disabled' : body.effectiveRole}`);
|
|
447
|
+
};
|
|
389
448
|
}
|
|
390
449
|
|
|
391
|
-
async function adminSessions(context,
|
|
392
|
-
const { options } = parseArguments(argv, { kind: { type: 'string', choices: ['web', 'cli'] } });
|
|
450
|
+
async function adminSessions(context, { options }) {
|
|
393
451
|
const body = await context.client.get('/api/admin/sessions', { kind: options.kind });
|
|
394
452
|
emit(context, context.global.output === 'ndjson' ? body.sessions : body, () => renderTable(body.sessions, sessionColumns, { width: context.width }));
|
|
395
453
|
}
|
|
396
454
|
|
|
397
|
-
async function adminRevokeSession(context,
|
|
398
|
-
|
|
399
|
-
if (!positionals[0]) throw new UsageError('폐기할 세션 ID가 필요합니다.');
|
|
455
|
+
async function adminRevokeSession(context, { positionals }) {
|
|
456
|
+
if (!positionals[0]) throw new UsageError('A session ID is required.', { hint: 'List them with `sentinelctl admin sessions`.' });
|
|
400
457
|
await context.client.post(`/api/admin/sessions/${encodeURIComponent(positionals[0])}/revoke`, {});
|
|
401
|
-
context.out(
|
|
458
|
+
context.out(`Revoked session ${positionals[0]}.`);
|
|
402
459
|
}
|
|
403
460
|
|
|
404
|
-
async function
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
461
|
+
async function adminAudit(context, { options }) {
|
|
462
|
+
if (options.cleanup) {
|
|
463
|
+
const result = await context.client.post('/api/metadata/audit-maintenance/cleanup', {});
|
|
464
|
+
emit(context, result, () => `Removed ${result.deletedCount} audit entries under the retention policy.`);
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
const body = await context.client.get('/api/metadata/audit-maintenance', { limit: options.limit });
|
|
468
|
+
emit(context, context.global.output === 'ndjson' ? body.history : body, () => renderTable(body.history, [
|
|
469
|
+
{ header: 'TIME', value: (entry) => localTime(entry.createdAt) },
|
|
470
|
+
{ header: 'ACTOR', value: (entry) => entry.actor, max: 32 },
|
|
471
|
+
{ header: 'DELETED', value: (entry) => String(entry.deletedCount) },
|
|
472
|
+
{ header: 'POLICY', value: (entry) => `${entry.retentionDays}d / ${Math.round(entry.maximumBytes / 1_048_576)}MiB` },
|
|
473
|
+
], { width: context.width }));
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function bytesText(value) {
|
|
477
|
+
if (!Number.isFinite(value)) return '-';
|
|
478
|
+
const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB'];
|
|
479
|
+
let size = value;
|
|
480
|
+
let index = 0;
|
|
481
|
+
while (Math.abs(size) >= 1024 && index < units.length - 1) { size /= 1024; index += 1; }
|
|
482
|
+
return `${size.toFixed(index ? 1 : 0)}${units[index]}`;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
async function adminOperations(context, { options }) {
|
|
486
|
+
const body = await context.client.get('/api/operations', { range: options.range });
|
|
487
|
+
emit(context, body, () => renderTable(Object.entries(body.components ?? {}), [
|
|
488
|
+
{ header: 'COMPONENT', value: ([name]) => name },
|
|
489
|
+
{ header: 'LATEST', value: ([, component]) => component.latestAttempt?.status ?? component.latest?.status ?? '-' },
|
|
490
|
+
{ header: 'STORED', value: ([, component]) => bytesText(component.latest?.dataBytes) },
|
|
491
|
+
{ header: 'CHANGE', value: ([, component]) => bytesText(component.storageChangeBytes) },
|
|
492
|
+
{ header: 'EST/DAY', value: ([, component]) => bytesText(component.estimatedDailyBytes) },
|
|
493
|
+
{ header: 'COVERAGE', value: ([, component]) => (Number.isFinite(component.coverage?.ratio) ? `${Math.round(component.coverage.ratio * 100)}%` : '-') },
|
|
494
|
+
], { width: context.width }));
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
async function rawApi(context, { options, positionals }) {
|
|
408
498
|
const [path] = positionals;
|
|
409
|
-
if (!path?.startsWith('/api/')) throw new UsageError('/api
|
|
499
|
+
if (!path?.startsWith('/api/')) throw new UsageError('A path starting with /api/ is required.', { hint: 'Example: sentinelctl api "/api/devices?status=offline"' });
|
|
410
500
|
let body;
|
|
411
501
|
if (options.data !== undefined) {
|
|
412
502
|
try {
|
|
413
503
|
body = JSON.parse(options.data);
|
|
414
504
|
} catch {
|
|
415
|
-
throw new UsageError('--data
|
|
505
|
+
throw new UsageError('--data must be JSON.');
|
|
416
506
|
}
|
|
417
507
|
}
|
|
418
508
|
const url = new URL(path, context.client.server);
|
|
@@ -420,59 +510,268 @@ async function rawApi(context, argv) {
|
|
|
420
510
|
context.out(JSON.stringify(result.body, null, 2));
|
|
421
511
|
}
|
|
422
512
|
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
513
|
+
const commands = [
|
|
514
|
+
{
|
|
515
|
+
group: 'logs', name: 'search', run: logsSearch, role: 'viewer', mutates: false,
|
|
516
|
+
summary: 'Search logs by window, device, severity, source and text; or follow live',
|
|
517
|
+
description: 'Newest first. Default 15m window and 100 logs. When fetching several pages, 250 logs are fetched per request.',
|
|
518
|
+
args: [{ name: 'text', optional: true, description: 'Same as -q (words are joined with spaces).' }],
|
|
519
|
+
options: {
|
|
520
|
+
...logFilterOptions,
|
|
521
|
+
limit: { alias: 'n', type: 'integer', choices: [50, 100, 250], description: 'Logs per page. Default 100 (250 when paging).' },
|
|
522
|
+
pages: { type: 'integer', placeholder: 'N', description: 'Follow the cursor for up to N pages.' },
|
|
523
|
+
all: { type: 'boolean', description: 'Fetch every page of the window (server cap: 10,000 logs).' },
|
|
524
|
+
fields: { type: 'boolean', description: 'Include raw fields (admin only, audited, 50 logs per page).' },
|
|
525
|
+
follow: { alias: 'f', type: 'boolean', description: 'Keep printing new logs (for humans; never ends, agents should not use it).' },
|
|
526
|
+
interval: { type: 'integer', default: 5, placeholder: 'seconds', description: 'Polling interval for --follow (min 2).' },
|
|
527
|
+
wide: { alias: 'w', type: 'boolean', description: 'Do not truncate messages in table output.' },
|
|
431
528
|
},
|
|
529
|
+
roleFor: (options) => (options.fields ? 'admin' : 'viewer'),
|
|
530
|
+
roleNote: '--fields requires admin',
|
|
531
|
+
examples: [
|
|
532
|
+
'sentinelctl logs search -l error -r 1h',
|
|
533
|
+
'sentinelctl logs search "connection reset" -r 24h -o ndjson',
|
|
534
|
+
'sentinelctl logs search -a <agent-id> -s ssh.service -r 6h --all -o json',
|
|
535
|
+
'sentinelctl logs search --from 2026-09-30T09:00:00+09:00 --to 2026-09-30T10:00:00+09:00 -l warning',
|
|
536
|
+
],
|
|
537
|
+
output: 'logs[]: timestamp (UTC ISO), severity, priority, agentId, host, displayName, source, message (+fields). -o json wraps them as {criteria, window, count, hasMore, logs}.',
|
|
432
538
|
},
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
539
|
+
{
|
|
540
|
+
group: 'logs', name: 'context', run: logsContext, role: 'viewer', mutates: false,
|
|
541
|
+
summary: 'Logs just before and after one log, from the same device and source',
|
|
542
|
+
options: {
|
|
543
|
+
agent: { alias: 'a', type: 'string', placeholder: 'id', description: 'Agent ID (required).' },
|
|
544
|
+
source: { alias: 's', type: 'string', placeholder: 'source', description: 'Source (required).' },
|
|
545
|
+
timestamp: { alias: 't', type: 'string', placeholder: 'ISO', description: 'The anchor log timestamp exactly as returned (required).' },
|
|
546
|
+
before: { type: 'integer', placeholder: 'N', description: 'Logs before (1-50, default 10).' },
|
|
547
|
+
after: { type: 'integer', placeholder: 'N', description: 'Logs after (1-50, default 10).' },
|
|
548
|
+
window: { type: 'integer', placeholder: 'seconds', description: 'Search distance on each side (30-3600, default 300).' },
|
|
549
|
+
wide: { alias: 'w', type: 'boolean', description: 'Do not truncate messages.' },
|
|
440
550
|
},
|
|
551
|
+
examples: ['sentinelctl logs context -a <agent-id> -s app.service -t 2026-09-30T01:02:03.456789Z -o json'],
|
|
552
|
+
output: '{criteria, count, logs[]} with logs in time order, same shape as logs search.',
|
|
553
|
+
},
|
|
554
|
+
{
|
|
555
|
+
group: 'logs', name: 'histogram', run: logsHistogram, role: 'viewer', mutates: false,
|
|
556
|
+
summary: 'Log volume and error counts over time',
|
|
557
|
+
args: [{ name: 'text', optional: true, description: 'Same as -q.' }],
|
|
558
|
+
options: logFilterOptions,
|
|
559
|
+
examples: ['sentinelctl logs histogram -r 24h', 'sentinelctl logs histogram -a <agent-id> -r 7d -o json'],
|
|
560
|
+
output: '{start, end, stepSeconds, total, buckets[]: {at (epoch seconds), total, errors}}. Cached for 60s on the server.',
|
|
441
561
|
},
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
562
|
+
{
|
|
563
|
+
group: 'logs', name: 'sources', run: logsSources, role: 'viewer', mutates: false,
|
|
564
|
+
summary: 'Log sources (systemd units, containers) seen recently',
|
|
565
|
+
options: { range: { alias: 'r', type: 'string', choices: logRanges, default: '24h', description: 'Sample window.' } },
|
|
566
|
+
examples: ['sentinelctl logs sources -r 1h'],
|
|
567
|
+
output: '{range, sampleSize, sources[]: {value, count}}: top 50 in a sample of the latest 250 logs.',
|
|
568
|
+
},
|
|
569
|
+
{
|
|
570
|
+
group: 'devices', name: 'list', run: devicesList, role: 'viewer', mutates: false,
|
|
571
|
+
summary: 'Devices with status and resource usage; search, filter and sort',
|
|
572
|
+
args: [{ name: 'text', optional: true, description: 'Substring of name, host, ID, OS, site, group, role, maintainer, notes or labels.' }],
|
|
573
|
+
options: {
|
|
574
|
+
query: { alias: 'q', type: 'string', placeholder: 'text', description: 'Same as the positional text.' },
|
|
575
|
+
status: { type: 'string', choices: deviceStatuses, description: 'Reception status.' },
|
|
576
|
+
lifecycle: { type: 'string', choices: lifecycles, description: 'Lifecycle scope (default operational = active + maintenance).' },
|
|
577
|
+
role: { type: 'string', placeholder: 'role', description: 'Device role.' },
|
|
578
|
+
site: { type: 'string', placeholder: 'site', description: 'Site.' },
|
|
579
|
+
group: { type: 'string', placeholder: 'group', description: 'Group.' },
|
|
580
|
+
maintainer: { type: 'string', placeholder: 'name', description: 'Maintainer.' },
|
|
581
|
+
fleet: { type: 'string', choices: ['edge', 'infrastructure', 'metrics-only'], description: 'Fleet type.' },
|
|
582
|
+
focus: { type: 'string', choices: ['attention', 'resources', 'collection', 'repeated', 'recovered', 'spool', 'metadata'], description: 'Only devices needing attention of this kind.' },
|
|
583
|
+
inventory: { type: 'string', choices: ['registered', 'discovered', 'history'], description: 'Inventory state.' },
|
|
584
|
+
label: { type: 'string', multiple: true, placeholder: 'k=v', description: 'Label match (up to 8).' },
|
|
585
|
+
sort: { type: 'string', choices: deviceSorts, description: 'Sort key.' },
|
|
586
|
+
order: { type: 'string', choices: ['asc', 'desc'], description: 'Sort direction.' },
|
|
587
|
+
page: { type: 'integer', default: 1, placeholder: 'N', description: 'Page number.' },
|
|
588
|
+
'page-size': { type: 'integer', default: 50, placeholder: 'N', description: 'Page size (max 100).' },
|
|
589
|
+
all: { type: 'boolean', description: 'Fetch every page (remaining pages are fetched concurrently).' },
|
|
451
590
|
},
|
|
591
|
+
examples: ['sentinelctl devices list --status offline', 'sentinelctl devices list --focus attention --all -o json',
|
|
592
|
+
'sentinelctl devices list --sort cpu --order desc --page-size 10'],
|
|
593
|
+
output: 'devices[]: agentId, displayName, host, status, cpu/memory/disk (%), ageSeconds, site, group, metadata, attentionReasons … -o json wraps them as {matched, counts, stale, devices}.',
|
|
452
594
|
},
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
595
|
+
{
|
|
596
|
+
group: 'devices', name: 'show', run: devicesShow, role: 'viewer', mutates: false,
|
|
597
|
+
summary: 'One device: current state and metric time series',
|
|
598
|
+
args: [{ name: 'agent-id' }],
|
|
599
|
+
options: {
|
|
600
|
+
range: { alias: 'r', type: 'string', choices: deviceRanges, default: '6h', description: 'Time-series window (about 120 points).' },
|
|
601
|
+
refresh: { type: 'boolean', description: 'Bypass the server cache.' },
|
|
458
602
|
},
|
|
603
|
+
examples: ['sentinelctl devices show <agent-id> -r 24h -o json'],
|
|
604
|
+
output: '{device, current, series{cpu, memory, disk, load, temperature, networkRx, networkTx, …: [[epochSeconds, value], …]}, range, stepSeconds, stale}.',
|
|
605
|
+
},
|
|
606
|
+
{
|
|
607
|
+
group: 'devices', name: 'lookup', run: devicesLookup, role: 'viewer', mutates: false,
|
|
608
|
+
summary: 'Find agent IDs by part of a name or host',
|
|
609
|
+
args: [{ name: 'text' }],
|
|
610
|
+
options: {},
|
|
611
|
+
examples: ['sentinelctl devices lookup 10357'],
|
|
612
|
+
output: '{devices[]: {agentId, host, displayName}, matched} (up to 100).',
|
|
459
613
|
},
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
614
|
+
{
|
|
615
|
+
group: 'devices', name: 'overview', run: devicesOverview, role: 'viewer', mutates: false,
|
|
616
|
+
summary: 'Metrics and log reception state, outage history and attention reasons',
|
|
617
|
+
args: [{ name: 'agent-id' }],
|
|
618
|
+
options: { range: { alias: 'r', type: 'string', default: '24h', placeholder: 'window', description: 'History window, e.g. 24h or 7d.' } },
|
|
619
|
+
examples: ['sentinelctl devices overview <agent-id> -r 7d -o json'],
|
|
620
|
+
output: '{agentId, device, products{metrics, logs}, attentionReasons[], history, related[]}.',
|
|
621
|
+
},
|
|
622
|
+
{
|
|
623
|
+
group: 'metadata', name: 'get', run: metadataGet, role: 'viewer', mutates: false,
|
|
624
|
+
summary: 'Device metadata: name, role, maintainer, labels, lifecycle',
|
|
625
|
+
args: [{ name: 'agent-id' }], options: {},
|
|
626
|
+
examples: ['sentinelctl metadata get <agent-id> -o json'],
|
|
627
|
+
output: '{agentId, revision, displayName, role, maintainer, maintainerContact, description, labels, lifecycle, maintenanceUntil, maintenanceReason, updatedAt}.',
|
|
628
|
+
},
|
|
629
|
+
{
|
|
630
|
+
group: 'metadata', name: 'history', run: metadataHistory, role: 'viewer', mutates: false,
|
|
631
|
+
summary: 'Metadata change history: who, when, what',
|
|
632
|
+
args: [{ name: 'agent-id' }],
|
|
633
|
+
options: { limit: { alias: 'n', type: 'integer', default: 20, placeholder: 'N', description: 'Maximum entries (1-100).' } },
|
|
634
|
+
examples: ['sentinelctl metadata history <agent-id>'],
|
|
635
|
+
output: '{history[]: {beforeRevision, afterRevision, changedFields[], actor, createdAt, before, after}}.',
|
|
636
|
+
},
|
|
637
|
+
{
|
|
638
|
+
group: 'metadata', name: 'set', run: metadataSet, role: 'editor', mutates: true,
|
|
639
|
+
summary: 'Edit metadata (reads the latest revision and applies on top of it)',
|
|
640
|
+
args: [{ name: 'agent-id' }],
|
|
641
|
+
options: { ...metadataEditOptions, 'dry-run': { type: 'boolean', description: 'Show what would change without saving.' } },
|
|
642
|
+
examples: ['sentinelctl metadata set <agent-id> --display-name "Edge 1" --label site=seoul --dry-run',
|
|
643
|
+
'sentinelctl metadata set <agent-id> --lifecycle maintenance --maintenance-until 2026-10-01T09:00:00+09:00 --maintenance-reason "disk swap"'],
|
|
644
|
+
output: 'Saved metadata (same shape as metadata get). With --dry-run: {results[0].changes}.',
|
|
645
|
+
},
|
|
646
|
+
{
|
|
647
|
+
group: 'metadata', name: 'bulk', run: metadataBulk, role: 'editor', mutates: true,
|
|
648
|
+
summary: 'Apply the same metadata change to up to 100 devices',
|
|
649
|
+
args: [{ name: 'agent-id…', description: 'Space separated.' }],
|
|
650
|
+
options: { ...metadataEditOptions, 'dry-run': { type: 'boolean', description: 'Preview the change per device.' } },
|
|
651
|
+
examples: ['sentinelctl metadata bulk id1 id2 id3 --role camera --label site=farm-a --dry-run'],
|
|
652
|
+
output: '{count, successCount|changeCount, failureCount, results[]: {agentId, ok, status?, error?, changes|metadata}}. Partial success per device.',
|
|
653
|
+
},
|
|
654
|
+
{
|
|
655
|
+
group: 'metadata', name: 'rollback', run: metadataRollback, role: 'editor', mutates: true,
|
|
656
|
+
summary: 'Restore metadata to the content of an earlier revision (recorded as a new revision)',
|
|
657
|
+
args: [{ name: 'agent-id' }],
|
|
658
|
+
options: { to: { type: 'integer', placeholder: 'revision', description: 'Revision to restore (required; see metadata history).' } },
|
|
659
|
+
examples: ['sentinelctl metadata rollback <agent-id> --to 3'],
|
|
660
|
+
output: 'Metadata after the rollback.',
|
|
661
|
+
},
|
|
662
|
+
{
|
|
663
|
+
group: 'metadata', name: 'export', run: metadataExport, role: 'admin', mutates: false,
|
|
664
|
+
summary: 'Export metadata for all devices as JSON',
|
|
665
|
+
options: { file: { type: 'string', placeholder: 'path', description: 'Write to a new file (0600, never overwrites). Default: stdout.' } },
|
|
666
|
+
examples: ['sentinelctl metadata export --file metadata-backup.json'],
|
|
667
|
+
output: '{schemaVersion, scope, exportedAt, devices[]}.',
|
|
668
|
+
},
|
|
669
|
+
{
|
|
670
|
+
group: 'metadata', name: 'import', run: metadataImport, role: 'admin', mutates: true,
|
|
671
|
+
summary: 'Import metadata in export format',
|
|
672
|
+
args: [{ name: 'file' }],
|
|
673
|
+
options: {
|
|
674
|
+
'dry-run': { type: 'boolean', description: 'Validate only.' },
|
|
675
|
+
conflict: { type: 'string', choices: ['error', 'skip', 'overwrite'], default: 'error', description: 'What to do with devices that already have metadata.' },
|
|
469
676
|
},
|
|
677
|
+
examples: ['sentinelctl metadata import backup.json --dry-run --conflict skip'],
|
|
678
|
+
output: '{valid, count, insertCount, overwriteCount, skipCount, conflictCount, …}.',
|
|
470
679
|
},
|
|
471
|
-
|
|
680
|
+
{
|
|
681
|
+
group: 'sessions', name: 'list', run: sessionsList, role: 'viewer', mutates: false,
|
|
682
|
+
summary: 'Your web sessions and CLI tokens', options: {},
|
|
683
|
+
examples: ['sentinelctl sessions list'], output: '{sessions[]: {id, kind, user, clientName, createdAt, lastSeenAt, expiresAt, current}}.',
|
|
684
|
+
},
|
|
685
|
+
{
|
|
686
|
+
group: 'sessions', name: 'revoke', run: sessionsRevoke, role: 'viewer', mutates: true,
|
|
687
|
+
summary: 'Revoke one of your sessions or CLI tokens', args: [{ name: 'session-id' }], options: {},
|
|
688
|
+
examples: ['sentinelctl sessions revoke <session-id>'], output: 'Confirmation message.',
|
|
689
|
+
},
|
|
690
|
+
{
|
|
691
|
+
group: 'admin', name: 'users', run: adminUsers, role: 'admin', mutates: false,
|
|
692
|
+
summary: 'Users with their role, where it comes from, and session counts', options: {},
|
|
693
|
+
examples: ['sentinelctl admin users -o json'],
|
|
694
|
+
output: '{users[]: {email, name, effectiveRole, roleSource, grantedRole, disabled, bootstrapAdmin, sessions{web, cli}, lastLoginAt}}.',
|
|
695
|
+
},
|
|
696
|
+
{
|
|
697
|
+
group: 'admin', name: 'grant', run: adminGrantCommand({}), role: 'admin', mutates: true,
|
|
698
|
+
summary: 'Grant a role (works before the user first logs in)', args: [{ name: 'email' }],
|
|
699
|
+
options: { role: { type: 'string', choices: ['viewer', 'editor', 'admin'], description: 'Role to grant (required).' } },
|
|
700
|
+
examples: ['sentinelctl admin grant kim@intflow.ai --role editor'], output: 'The updated user row.',
|
|
701
|
+
},
|
|
702
|
+
{
|
|
703
|
+
group: 'admin', name: 'reset', run: adminGrantCommand({ role: null }), role: 'admin', mutates: true,
|
|
704
|
+
summary: 'Remove a granted role (back to the default viewer)', args: [{ name: 'email' }], options: {},
|
|
705
|
+
examples: ['sentinelctl admin reset kim@intflow.ai'], output: 'The updated user row.',
|
|
706
|
+
},
|
|
707
|
+
{
|
|
708
|
+
group: 'admin', name: 'disable', run: adminGrantCommand({ role: null, disabled: true }), role: 'admin', mutates: true,
|
|
709
|
+
summary: 'Block access and end all of the user\'s sessions and CLI tokens now', args: [{ name: 'email' }], options: {},
|
|
710
|
+
examples: ['sentinelctl admin disable kim@intflow.ai'], output: 'The updated user row. Undo with admin reset.',
|
|
711
|
+
},
|
|
712
|
+
{
|
|
713
|
+
group: 'admin', name: 'sessions', run: adminSessions, role: 'admin', mutates: false,
|
|
714
|
+
summary: 'All web sessions and CLI tokens',
|
|
715
|
+
options: { kind: { type: 'string', choices: ['web', 'cli'], description: 'Filter by kind.' } },
|
|
716
|
+
examples: ['sentinelctl admin sessions --kind cli'], output: '{sessions[]}.',
|
|
717
|
+
},
|
|
718
|
+
{
|
|
719
|
+
group: 'admin', name: 'revoke', run: adminRevokeSession, role: 'admin', mutates: true,
|
|
720
|
+
summary: 'Revoke anyone\'s session or CLI token', args: [{ name: 'session-id' }], options: {},
|
|
721
|
+
examples: ['sentinelctl admin revoke <session-id>'], output: 'Confirmation message.',
|
|
722
|
+
},
|
|
723
|
+
{
|
|
724
|
+
group: 'admin', name: 'audit', run: adminAudit, role: 'admin', mutates: false,
|
|
725
|
+
summary: 'Metadata audit clean-up records; run the clean-up now',
|
|
726
|
+
options: {
|
|
727
|
+
cleanup: { type: 'boolean', description: 'Apply the retention policy now (default 365 days / 64 MiB).' },
|
|
728
|
+
limit: { alias: 'n', type: 'integer', placeholder: 'N', description: 'Records to show (1-100).' },
|
|
729
|
+
},
|
|
730
|
+
examples: ['sentinelctl admin audit', 'sentinelctl admin audit --cleanup'], output: '{history[]} or {deletedCount}.',
|
|
731
|
+
},
|
|
732
|
+
{
|
|
733
|
+
group: 'admin', name: 'operations', run: adminOperations, role: 'admin', mutates: false,
|
|
734
|
+
summary: 'Backend (metrics, logs, console) storage and ingestion trends',
|
|
735
|
+
options: { range: { alias: 'r', type: 'string', choices: ['24h', '7d'], default: '24h', description: 'Window.' } },
|
|
736
|
+
examples: ['sentinelctl admin operations -r 7d -o json'], output: '{components{metrics, logs, console}: {latest, storageChangeBytes, estimatedDailyBytes, coverage, series}}.',
|
|
737
|
+
},
|
|
738
|
+
{
|
|
739
|
+
group: null, name: 'health', run: health, role: 'viewer', mutates: false,
|
|
740
|
+
summary: 'Backend connectivity', options: {},
|
|
741
|
+
examples: ['sentinelctl health -o json'], output: '{status, application, components{metrics, logs, metadata}: {status, latencyMs}}.',
|
|
742
|
+
},
|
|
743
|
+
{
|
|
744
|
+
group: null, name: 'api', run: rawApi, role: 'viewer', mutates: false,
|
|
745
|
+
summary: 'Call a console API directly (JSON output; the server enforces roles)',
|
|
746
|
+
args: [{ name: 'path', description: 'Starts with /api/.' }],
|
|
747
|
+
options: {
|
|
748
|
+
method: { alias: 'X', type: 'string', choices: ['GET', 'POST', 'PATCH'], default: 'GET', description: 'HTTP method.' },
|
|
749
|
+
data: { alias: 'd', type: 'string', placeholder: 'JSON', description: 'Request body.' },
|
|
750
|
+
},
|
|
751
|
+
examples: ['sentinelctl api "/api/devices?status=offline&pageSize=5"'], output: 'The server response JSON as is.',
|
|
752
|
+
},
|
|
753
|
+
];
|
|
472
754
|
|
|
473
|
-
export const
|
|
474
|
-
|
|
475
|
-
|
|
755
|
+
export const catalog = commands;
|
|
756
|
+
export const groups = {
|
|
757
|
+
logs: 'Search logs, context around a log, volume trends, sources',
|
|
758
|
+
devices: 'Device list, details, lookup, reception overview',
|
|
759
|
+
metadata: 'Device metadata: read, edit (editor), import/export (admin)',
|
|
760
|
+
sessions: 'Your sessions and CLI tokens',
|
|
761
|
+
admin: 'Roles, all sessions, audit clean-up, capacity (admin)',
|
|
476
762
|
};
|
|
477
763
|
|
|
478
|
-
export
|
|
764
|
+
export function findCommand(group, name) {
|
|
765
|
+
return commands.find((command) => command.group === group && command.name === name) ?? null;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
export function commandPath(command) {
|
|
769
|
+
return command.group ? `${command.group} ${command.name}` : command.name;
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
export function usageLine(command) {
|
|
773
|
+
const args = (command.args ?? []).map((arg) => (arg.optional ? `[${arg.name}]` : `<${arg.name}>`)).join(' ');
|
|
774
|
+
return `sentinelctl ${commandPath(command)}${args ? ` ${args}` : ''}${Object.keys(command.options).length ? ' [options]' : ''}`;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
export { singleLine };
|