@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/src/commands.mjs CHANGED
@@ -1,29 +1,42 @@
1
1
  import { readFile, writeFile } from 'node:fs/promises';
2
- import { keyValue, parseArguments, UsageError } from './args.mjs';
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: '조회 전용', editor: '편집 가능', admin: '관리자' };
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)) throw new UsageError('--from과 --to는 함께 지정해야 합니다.');
23
- if (options.from && options.range) throw new UsageError('--range와 --from/--to는 함께 쓸 수 없습니다.');
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'), from: options.from, to: options.to,
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: '시각', value: (log) => localTime(log.timestamp), max: 19 },
39
- { header: '수준', value: (log) => log.severity, max: 9 },
40
- { header: '장비', value: (log) => log.displayName ?? log.host ?? log.agentId, max: 24 },
41
- { header: '출처', value: (log) => log.source, max: 22 },
42
- { header: '메시지', value: (log) => log.message },
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
- async function logsSearch(context, argv) {
50
- const { options, positionals } = parseArguments(argv, {
51
- ...logFilterOptions,
52
- limit: { alias: 'n', type: 'integer', choices: [50, 100, 250], default: 100 },
53
- pages: { type: 'integer', default: 1 },
54
- all: { type: 'boolean' },
55
- fields: { type: 'boolean' },
56
- follow: { alias: 'f', type: 'boolean' },
57
- interval: { type: 'integer', default: 5 },
58
- wide: { alias: 'w', type: 'boolean' },
59
- });
60
- const criteria = { ...logCriteria(options, positionals), limit: options.fields ? 50 : options.limit, includeFields: options.fields ? 1 : undefined };
61
- if (options.follow) return followLogs(context, criteria, options);
62
- const maxPages = options.all ? 200 : Math.max(1, options.pages);
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', { ...criteria, cursor });
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
- return;
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, criteria, options) {
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('새 로그를 기다립니다. Ctrl+C로 종료합니다.');
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', followCriteria);
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(`경고: ${options.interval}초 사이에 ${followCriteria.limit}건보다 많은 로그가 들어와 일부를 건너뛰었습니다. -a/-s/-l/-q로 범위를 좁히거나 --interval을 줄이세요.`);
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, argv) {
113
- const { options } = parseArguments(argv, {
114
- agent: { alias: 'a', type: 'string' }, source: { alias: 's', type: 'string' }, timestamp: { alias: 't', type: 'string' },
115
- before: { type: 'integer' }, after: { type: 'integer' }, window: { type: 'integer' }, wide: { alias: 'w', type: 'boolean' },
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, argv) {
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: '시각', value: (bucket) => localTime(bucket.at) },
133
- { header: '전체', value: (bucket) => String(bucket.total) },
134
- { header: '오류', value: (bucket) => String(bucket.errors) },
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, argv) {
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: '출처', value: (source) => source.value },
145
- { header: '건수', value: (source) => String(source.count) },
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: '에이전트 ID', value: (device) => device.agentId, max: 28 },
151
- { header: '이름', value: (device) => device.displayName ?? device.host, max: 28 },
152
- { header: '상태', value: (device) => device.status, max: 11 },
153
- { header: '사이트', value: (device) => device.site, max: 14 },
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: '메모리', value: (device) => percentText(device.memory), max: 6 },
156
- { header: '디스크', value: (device) => percentText(device.disk), max: 6 },
157
- { header: '마지막 수신', value: (device) => ageText(device.ageSeconds) },
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, argv) {
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 devices = [];
177
- let body;
178
- for (let page = options.all ? 1 : options.page; ; page += 1) {
179
- body = await context.client.get('/api/devices', { ...query, page });
180
- devices.push(...body.devices);
181
- if (!options.all || page >= body.totalPages) break;
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: body.matched, counts: body.counts, stale: body.stale, devices }, null, 2));
185
- return;
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
- async function devicesShow(context, argv) {
194
- const { options, positionals } = parseArguments(argv, { range: { alias: 'r', type: 'string', default: '6h' } });
195
- const [agentId] = positionals;
196
- if (!agentId) throw new UsageError('에이전트 ID가 필요합니다.');
197
- const body = await context.client.get(`/api/devices/${encodeURIComponent(agentId)}`, { range: options.range });
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
- ['에이전트 ID', device.agentId], ['이름', device.displayName], ['호스트', device.host],
201
- ['상태', device.observedStatus ?? device.status], ['마지막 수신', ageText(device.ageSeconds)],
202
- ['CPU', percentText(device.cpu)], ['메모리', percentText(device.memory)], ['디스크', percentText(device.disk)],
203
- ['부하', device.load], ['온도', device.temperature], ['용도', device.metadata?.role ?? device.role],
204
- ['담당자', device.metadata?.maintainer], ['운영 상태', device.metadata?.lifecycle], ['메모', device.metadata?.description],
205
- ['데이터', body.stale ? `오래됨 (${body.staleReason ?? ''})` : '최신'],
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, argv) {
210
- const { positionals } = parseArguments(argv, {});
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: '에이전트 ID', value: (device) => device.agentId },
214
- { header: '이름', value: (device) => device.displayName },
215
- { header: '호스트', value: (device) => device.host },
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, argv) {
220
- const { options, positionals } = parseArguments(argv, { range: { alias: 'r', type: 'string', default: '24h' } });
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
- ['에이전트 ID', body.agentId], ['이름', body.device?.displayName], ['메트릭', body.products?.metrics?.state],
226
- ['로그', body.products?.logs?.state], ['주의 사유', (body.attentionReasons ?? []).join(', ') || '없음'],
227
- ['관련 장비', (body.related ?? []).map((item) => item.agentId ?? item).join(', ')],
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([['전체', body.status], ['버전', `${body.application?.version} (${body.application?.commit})`],
235
- ['관측기', body.observer?.status ?? body.observer?.state]]),
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: '구성요소', value: ([name]) => name },
239
- { header: '상태', value: ([, component]) => component.status },
240
- { header: '지연', value: ([, component]) => (Number.isFinite(component.latencyMs) ? `${component.latencyMs}ms` : '-') },
241
- { header: '오류', value: ([, component]) => component.error ?? '' },
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, argv) {
247
- const { positionals } = parseArguments(argv, {});
248
- if (!positionals[0]) throw new UsageError('에이전트 ID가 필요합니다.');
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
- ['에이전트 ID', body.agentId], ['변경 버전', body.revision], ['이름', body.displayName], ['용도', body.role],
252
- ['담당자', body.maintainer], ['연락처', body.maintainerContact], ['운영 상태', body.lifecycle],
253
- ['점검 종료 예정', body.maintenanceUntil], ['점검 사유', body.maintenanceReason], ['메모', body.description],
254
- ['라벨', Object.entries(body.labels ?? {}).map(([key, value]) => `${key}=${value}`).join(', ')],
255
- ['갱신', body.updatedAt],
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, argv) {
260
- const { options, positionals } = parseArguments(argv, { limit: { alias: 'n', type: 'integer', default: 20 } });
261
- if (!positionals[0]) throw new UsageError('에이전트 ID가 필요합니다.');
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: '버전', value: (entry) => `${entry.beforeRevision}→${entry.afterRevision}` },
265
- { header: '시각', value: (entry) => localTime(entry.createdAt) },
266
- { header: '변경자', value: (entry) => entry.actor, max: 32 },
267
- { header: '변경 항목', value: (entry) => entry.changedFields.join(', ') },
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
- async function metadataSet(context, argv) {
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) throw new UsageError('바꿀 항목을 하나 이상 지정하세요. 예: --display-name "DB 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, () => `${agentId} 메타데이터를 변경 버전 ${updated.revision}으로 저장했습니다.`);
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, argv) {
309
- const { options, positionals } = parseArguments(argv, { to: { type: 'integer' } });
310
- const [agentId] = positionals;
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, () => `${agentId} 메타데이터를 버전 ${options.to} 내용으로 되돌렸습니다(새 변경 버전 ${updated.revision}).`);
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, argv) {
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
- context.err(`${body.devices?.length ?? 0}대 메타데이터를 ${options.file}에 저장했습니다.`);
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, argv) {
330
- const { options, positionals } = parseArguments(argv, {
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(`JSON 파일을 읽을 수 없습니다: ${positionals[0]}`);
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: '종류', value: (session) => (session.kind === 'cli' ? 'CLI' : '웹') },
348
- { header: '사용자', value: (session) => session.user, max: 32 },
349
- { header: '클라이언트', value: (session) => session.clientName ?? '-', max: 28 },
350
- { header: '마지막 사용', value: (session) => localTime(session.lastSeenAt) },
351
- { header: '만료', value: (session) => localTime(session.expiresAt) },
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 ? '← 현재' : '') }], { width: context.width },
418
+ body.sessions, [...sessionColumns, { header: '', value: (session) => (session.current ? '← current' : '') }], { width: context.width },
358
419
  ));
359
420
  }
360
421
 
361
- async function sessionsRevoke(context, argv) {
362
- const { positionals } = parseArguments(argv, {});
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(`세션 ${positionals[0]}을 폐기했습니다.`);
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: '이메일', value: (user) => user.email, max: 36 },
372
- { header: '권한', value: (user) => (user.disabled ? '중지됨' : roleLabels[user.effectiveRole] ?? user.effectiveRole) },
373
- { header: '근거', value: (user) => ({ bootstrap: '기본 관리자', grant: '콘솔 지정', default: '기본값', disabled: '중지' })[user.roleSource] ?? user.roleSource },
374
- { header: '웹/CLI', value: (user) => `${user.sessions.web}/${user.sessions.cli}` },
375
- { header: '마지막 로그인', value: (user) => localTime(user.lastLoginAt) },
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
- async function adminGrant(context, argv, grant) {
380
- const { options, positionals } = parseArguments(argv, grant.role === undefined
381
- ? { role: { type: 'string', choices: ['viewer', 'editor', 'admin'] } }
382
- : {});
383
- const [email] = positionals;
384
- if (!email) throw new UsageError('이메일이 필요합니다.');
385
- const role = grant.role === undefined ? options.role : grant.role;
386
- if (grant.role === undefined && !role) throw new UsageError('--role viewer|editor|admin이 필요합니다.');
387
- const body = await context.client.post('/api/admin/users/grant', { email, role, disabled: grant.disabled ?? false });
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, argv) {
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, argv) {
398
- const { positionals } = parseArguments(argv, {});
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(`세션 ${positionals[0]}을 폐기했습니다.`);
458
+ context.out(`Revoked session ${positionals[0]}.`);
402
459
  }
403
460
 
404
- async function rawApi(context, argv) {
405
- const { options, positionals } = parseArguments(argv, {
406
- method: { alias: 'X', type: 'string', choices: ['GET', 'POST', 'PATCH'], default: 'GET' }, data: { alias: 'd', type: 'string' },
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는 JSON이어야 합니다.');
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
- export const commandTable = {
424
- logs: {
425
- summary: '로그 검색·주변 로그·히스토그램·출처',
426
- commands: {
427
- search: { run: logsSearch, usage: 'logs search [검색어] [-q 텍스트] [-a 에이전트] [-l 수준] [-s 출처] [-r 15m|1h|6h|24h|7d | --from ISO --to ISO] [-n 50|100|250] [--pages N | --all] [--fields] [-f]' },
428
- context: { run: logsContext, usage: 'logs context -a 에이전트 -s 출처 -t 2026-09-30T01:02:03.000Z [--before N] [--after N]' },
429
- histogram: { run: logsHistogram, usage: 'logs histogram [로그 검색과 같은 필터]' },
430
- sources: { run: logsSources, usage: 'logs sources [-r 24h]' },
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
- devices: {
434
- summary: '장비 목록·상세·찾기·개요',
435
- commands: {
436
- list: { run: devicesList, usage: 'devices list [검색어] [--status online|delayed|offline|…] [--site S] [--group G] [--role R] [--label k=v] [--sort 필드] [--order asc|desc] [--page N] [--page-size N | --all]' },
437
- show: { run: devicesShow, usage: 'devices show <에이전트 ID> [-r 30m|1h|6h|24h|7d|30d]' },
438
- lookup: { run: devicesLookup, usage: 'devices lookup <검색어>' },
439
- overview: { run: devicesOverview, usage: 'devices overview <에이전트 ID> [-r 24h]' },
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
- metadata: {
443
- summary: '장비 메타데이터 조회·변경(편집 권한)·가져오기/내보내기(관리자)',
444
- commands: {
445
- get: { run: metadataGet, usage: 'metadata get <에이전트 ID>' },
446
- history: { run: metadataHistory, usage: 'metadata history <에이전트 ID> [-n 20]' },
447
- set: { run: metadataSet, usage: 'metadata set <에이전트 ID> [--display-name ..] [--role ..] [--maintainer ..] [--contact ..] [--description ..] [--lifecycle active|maintenance|retired] [--label k=v] [--remove-label k] [--clear 필드]' },
448
- rollback: { run: metadataRollback, usage: 'metadata rollback <에이전트 ID> --to <변경 버전>' },
449
- export: { run: metadataExport, usage: 'metadata export [--file 경로]' },
450
- import: { run: metadataImport, usage: 'metadata import <파일> [--dry-run] [--conflict error|skip|overwrite]' },
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
- sessions: {
454
- summary: '내 웹 세션·CLI 토큰 조회와 폐기',
455
- commands: {
456
- list: { run: sessionsList, usage: 'sessions list' },
457
- revoke: { run: sessionsRevoke, usage: 'sessions revoke <세션 ID>' },
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
- admin: {
461
- summary: '사용자 권한 지정·접근 중지, 전체 세션 관리(관리자)',
462
- commands: {
463
- users: { run: adminUsers, usage: 'admin users' },
464
- grant: { run: (context, argv) => adminGrant(context, argv, {}), usage: 'admin grant <이메일> --role viewer|editor|admin' },
465
- reset: { run: (context, argv) => adminGrant(context, argv, { role: null }), usage: 'admin reset <이메일> (콘솔 지정 권한을 지우고 기본값으로)' },
466
- disable: { run: (context, argv) => adminGrant(context, argv, { role: null, disabled: true }), usage: 'admin disable <이메일> (접근 중지·세션 즉시 종료)' },
467
- sessions: { run: adminSessions, usage: 'admin sessions [--kind web|cli]' },
468
- revoke: { run: adminRevokeSession, usage: 'admin revoke <세션 ID>' },
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 standaloneCommands = {
474
- health: { run: health, usage: 'health', summary: '백엔드 연결 상태' },
475
- api: { run: rawApi, usage: 'api <경로> [-X GET|POST|PATCH] [-d JSON]', summary: '콘솔 API 직접 호출(JSON 출력)' },
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 { outputChoices, singleLine };
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 };