@intflow/sentinelctl 0.1.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 ADDED
@@ -0,0 +1,91 @@
1
+ # sentinelctl
2
+
3
+ Sentinel Fleet Console(`https://monitor.intflow.dev`)을 터미널에서 쓰는 명령행 도구입니다. 로그 검색, 장비 조회, 메타데이터 변경, 사용자 권한 관리를 지원합니다. 웹 콘솔과 같은 Intflow(Google Workspace) 로그인과 같은 역할 검사를 사용합니다.
4
+
5
+ - Node.js 20 이상, 외부 의존성 없음
6
+ - 권한: `viewer`는 조회만, `editor`는 메타데이터 변경까지, `admin`은 원본 로그 필드·가져오기/내보내기·사용자 관리까지 가능합니다. 권한은 서버가 판정하며 CLI는 서버 결과를 그대로 보여 줍니다.
7
+
8
+ ## 설치
9
+
10
+ ```bash
11
+ npm install -g @intflow/sentinelctl
12
+ ```
13
+
14
+ ## 로그인
15
+
16
+ ```bash
17
+ sentinelctl login
18
+ ```
19
+
20
+ 1. 터미널에 승인 주소와 확인 코드(예: `BCDF-GHJK`)가 나타나고 브라우저가 열립니다. 원격 셸이면 `--no-browser`를 주고 주소를 직접 엽니다.
21
+ 2. 브라우저에서 Intflow 계정으로 로그인한 뒤, 화면의 확인 코드가 터미널과 같은지 보고 **승인**합니다.
22
+ 3. CLI가 토큰(기본 30일)을 받아 자격 증명 파일에 0600 권한으로 저장합니다.
23
+
24
+ ```bash
25
+ sentinelctl whoami
26
+ sentinelctl sessions list # 내 웹 세션·CLI 토큰
27
+ sentinelctl logout # 서버에서 토큰 폐기 + 로컬 삭제
28
+ ```
29
+
30
+ 자격 증명 파일 위치는 Linux/macOS `~/.config/sentinelctl/credentials.json`, Windows `%APPDATA%\sentinelctl\credentials.json`이며 `SENTINELCTL_CONFIG`로 바꿀 수 있습니다. 서버는 `--server`, `SENTINEL_URL`, 저장된 프로필, 기본값 순서로 정합니다. 자동화에서는 파일 대신 `SENTINEL_TOKEN` 환경 변수를 쓸 수 있습니다.
31
+
32
+ ## 로그
33
+
34
+ ```bash
35
+ sentinelctl logs search timeout -r 1h # 최근 1시간 "timeout" 검색
36
+ sentinelctl logs search -a agent-123 -l error -r 24h --all # 장비·수준 필터, 모든 페이지
37
+ sentinelctl logs search -s sshd.service --from 2026-09-30T00:00:00Z --to 2026-09-30T06:00:00Z
38
+ sentinelctl logs search -l error -f # 새 오류 로그 따라가기(Ctrl+C 종료)
39
+ sentinelctl logs search -q boom -o ndjson | jq .message # 기계 처리용 출력
40
+ sentinelctl logs context -a agent-123 -s app.service -t 2026-09-30T01:02:03.456Z
41
+ sentinelctl logs histogram -l error -r 24h
42
+ sentinelctl logs sources -r 24h
43
+ ```
44
+
45
+ 검색 범위는 `15m`·`1h`·`6h`·`24h`·`7d` 또는 최대 30일의 `--from`/`--to`입니다. 한 페이지는 `-n 50|100|250`건이고 서버 상한 때문에 최대 10,000건까지 이어서 가져옵니다. `--fields`(원본 필드)는 관리자 전용이며 감사 로그에 남습니다.
46
+
47
+ ## 장비와 메타데이터
48
+
49
+ ```bash
50
+ sentinelctl devices list --status offline
51
+ sentinelctl devices list 서울 --site seoul --all -o json
52
+ sentinelctl devices show agent-123 -r 24h
53
+ sentinelctl devices lookup edge-01
54
+ sentinelctl health
55
+
56
+ sentinelctl metadata get agent-123
57
+ sentinelctl metadata history agent-123
58
+ sentinelctl metadata set agent-123 --display-name "엣지 1" --maintainer 홍길동 --label site=seoul # editor 이상
59
+ sentinelctl metadata set agent-123 --lifecycle maintenance --maintenance-until 2026-10-01T09:00:00+09:00
60
+ sentinelctl metadata rollback agent-123 --to 3
61
+ sentinelctl metadata export --file backup.json # admin
62
+ sentinelctl metadata import backup.json --dry-run # admin
63
+ ```
64
+
65
+ `metadata set`은 현재 변경 버전을 읽은 뒤 그 버전으로 수정합니다. 그 사이 다른 사람이 바꿨으면 서버가 `409`로 거부하므로 다시 실행하면 됩니다.
66
+
67
+ ## 관리자
68
+
69
+ ```bash
70
+ sentinelctl admin users
71
+ sentinelctl admin grant kim@intflow.ai --role editor # 첫 로그인 전에도 지정 가능
72
+ sentinelctl admin reset kim@intflow.ai # 콘솔 지정 권한 삭제(기본 viewer)
73
+ sentinelctl admin disable kim@intflow.ai # 접근 중지 + 모든 세션·CLI 토큰 즉시 종료
74
+ sentinelctl admin sessions --kind cli
75
+ sentinelctl admin revoke <세션 ID>
76
+ ```
77
+
78
+ 그 밖의 API는 `sentinelctl api /api/operations?range=7d`처럼 직접 호출할 수 있습니다.
79
+
80
+ ## 출력과 종료 코드
81
+
82
+ - `-o table`(기본), `-o json`(`--json`), `-o ndjson`
83
+ - 종료 코드: `0` 성공, `1` 서버·네트워크 오류, `2` 사용법 오류, `3` 인증 필요 또는 권한 부족
84
+
85
+ ## 배포
86
+
87
+ ```bash
88
+ cd cli
89
+ npm test
90
+ npm publish --access public
91
+ ```
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env node
2
+ import { main } from '../src/main.mjs';
3
+
4
+ process.exitCode = await main(process.argv.slice(2));
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "@intflow/sentinelctl",
3
+ "version": "0.1.0",
4
+ "description": "Sentinel Fleet Console 명령행 도구: 로그 검색, 장비 조회, 메타데이터·권한 관리",
5
+ "type": "module",
6
+ "bin": {
7
+ "sentinelctl": "bin/sentinelctl.mjs"
8
+ },
9
+ "exports": {
10
+ ".": "./src/main.mjs"
11
+ },
12
+ "files": [
13
+ "bin",
14
+ "src",
15
+ "README.md"
16
+ ],
17
+ "engines": {
18
+ "node": ">=20"
19
+ },
20
+ "scripts": {
21
+ "test": "node --test test/*.test.mjs",
22
+ "check": "node --check bin/sentinelctl.mjs && node --check src/main.mjs && node --check src/args.mjs && node --check src/credentials.mjs && node --check src/client.mjs && node --check src/login.mjs && node --check src/format.mjs && node --check src/commands.mjs"
23
+ },
24
+ "license": "UNLICENSED",
25
+ "publishConfig": {
26
+ "access": "public"
27
+ }
28
+ }
package/src/args.mjs ADDED
@@ -0,0 +1,72 @@
1
+ export class UsageError extends Error {
2
+ constructor(message) {
3
+ super(message);
4
+ this.name = 'UsageError';
5
+ }
6
+ }
7
+
8
+ // 옵션 정의: { name: { alias, type: 'string'|'boolean'|'integer', multiple } }
9
+ // "--name value", "--name=value", "-a value", "--flag", "--no-flag"를 지원한다.
10
+ export function parseArguments(argv, definitions) {
11
+ const options = {};
12
+ const positionals = [];
13
+ const aliases = new Map(Object.entries(definitions).filter(([, spec]) => spec.alias).map(([name, spec]) => [spec.alias, name]));
14
+ for (let index = 0; index < argv.length; index += 1) {
15
+ const argument = argv[index];
16
+ if (argument === '--') {
17
+ positionals.push(...argv.slice(index + 1));
18
+ break;
19
+ }
20
+ if (!argument.startsWith('-') || argument === '-') {
21
+ positionals.push(argument);
22
+ continue;
23
+ }
24
+ let name;
25
+ let value;
26
+ if (argument.startsWith('--')) {
27
+ const body = argument.slice(2);
28
+ const separator = body.indexOf('=');
29
+ name = separator >= 0 ? body.slice(0, separator) : body;
30
+ value = separator >= 0 ? body.slice(separator + 1) : undefined;
31
+ if (!definitions[name] && name.startsWith('no-') && definitions[name.slice(3)]?.type === 'boolean' && value === undefined) {
32
+ options[name.slice(3)] = false;
33
+ continue;
34
+ }
35
+ } else {
36
+ name = aliases.get(argument.slice(1));
37
+ if (!name) throw new UsageError(`알 수 없는 옵션입니다: ${argument}`);
38
+ }
39
+ const spec = definitions[name];
40
+ if (!spec) throw new UsageError(`알 수 없는 옵션입니다: --${name}`);
41
+ if (spec.type === 'boolean') {
42
+ if (value !== undefined) throw new UsageError(`--${name}에는 값을 줄 수 없습니다.`);
43
+ options[name] = true;
44
+ continue;
45
+ }
46
+ if (value === undefined) {
47
+ value = argv[index + 1];
48
+ if (value === undefined) throw new UsageError(`--${name}에 값이 필요합니다.`);
49
+ index += 1;
50
+ }
51
+ let parsed = value;
52
+ if (spec.type === 'integer') {
53
+ parsed = Number(value);
54
+ if (!Number.isInteger(parsed)) throw new UsageError(`--${name}은 정수여야 합니다.`);
55
+ }
56
+ if (spec.choices && !spec.choices.includes(parsed)) {
57
+ throw new UsageError(`--${name}은 ${spec.choices.join(', ')} 중 하나여야 합니다.`);
58
+ }
59
+ if (spec.multiple) (options[name] ??= []).push(parsed);
60
+ else options[name] = parsed;
61
+ }
62
+ for (const [name, spec] of Object.entries(definitions)) {
63
+ if (options[name] === undefined && spec.default !== undefined) options[name] = spec.default;
64
+ }
65
+ return { options, positionals };
66
+ }
67
+
68
+ export function keyValue(value, label) {
69
+ const separator = String(value).indexOf('=');
70
+ if (separator < 1) throw new UsageError(`${label}은 key=value 형식이어야 합니다: ${value}`);
71
+ return [value.slice(0, separator), value.slice(separator + 1)];
72
+ }
package/src/client.mjs ADDED
@@ -0,0 +1,66 @@
1
+ export class ApiError extends Error {
2
+ constructor(message, { status, code, retryAfterSeconds, body } = {}) {
3
+ super(message);
4
+ this.name = 'ApiError';
5
+ this.status = status;
6
+ this.code = code;
7
+ this.retryAfterSeconds = retryAfterSeconds;
8
+ this.body = body;
9
+ }
10
+ }
11
+
12
+ const userAgent = 'sentinelctl';
13
+
14
+ export function createClient({ server, token = null, fetch: fetchImplementation = globalThis.fetch, timeoutMs = 60_000, maxRetries = 2, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) }) {
15
+ async function request(method, path, { query, body, auth = true, allowStatuses = [] } = {}) {
16
+ const url = new URL(path, server);
17
+ for (const [key, value] of Object.entries(query ?? {})) {
18
+ if (value === undefined || value === null || value === '') continue;
19
+ if (Array.isArray(value)) for (const item of value) url.searchParams.append(key, String(item));
20
+ else url.searchParams.set(key, String(value));
21
+ }
22
+ const headers = { accept: 'application/json', 'user-agent': userAgent };
23
+ if (auth) {
24
+ if (!token) throw new ApiError('로그인이 필요합니다. sentinelctl login을 먼저 실행하세요.', { status: 401, code: 'not_logged_in' });
25
+ headers.authorization = `Bearer ${token}`;
26
+ }
27
+ if (body !== undefined) headers['content-type'] = 'application/json';
28
+ for (let attempt = 0; ; attempt += 1) {
29
+ let response;
30
+ try {
31
+ response = await fetchImplementation(url, {
32
+ method, headers, body: body === undefined ? undefined : JSON.stringify(body),
33
+ redirect: 'error', signal: AbortSignal.timeout(timeoutMs),
34
+ });
35
+ } catch (error) {
36
+ const reason = error?.name === 'TimeoutError' ? '응답 시간이 초과되었습니다' : '연결하지 못했습니다';
37
+ throw new ApiError(`${url.origin}에 ${reason}.`, { code: 'network_error' });
38
+ }
39
+ const text = await response.text();
40
+ let payload = {};
41
+ try {
42
+ payload = text ? JSON.parse(text) : {};
43
+ } catch {
44
+ payload = { error: text.slice(0, 200) };
45
+ }
46
+ const retryAfterSeconds = Number(response.headers.get('retry-after')) || null;
47
+ // 429는 서버가 알려 준 시간만큼 기다렸다가 GET만 다시 시도한다. 변경 요청은 중복 실행을 막기 위해 재시도하지 않는다.
48
+ if (response.status === 429 && method === 'GET' && attempt < maxRetries) {
49
+ await sleep(Math.min(retryAfterSeconds ?? 1, 30) * 1_000);
50
+ continue;
51
+ }
52
+ if (response.ok || allowStatuses.includes(response.status)) return { status: response.status, body: payload };
53
+ let message = payload.error || `HTTP ${response.status}`;
54
+ if (response.status === 401 && auth) message = `${message} (sentinelctl login으로 다시 로그인하세요)`;
55
+ if (retryAfterSeconds) message += ` · ${retryAfterSeconds}초 뒤 다시 시도해 주세요.`;
56
+ throw new ApiError(message, { status: response.status, code: payload.code, retryAfterSeconds, body: payload });
57
+ }
58
+ }
59
+ return {
60
+ server,
61
+ request,
62
+ get: async (path, query, options) => (await request('GET', path, { ...options, query })).body,
63
+ post: async (path, body, options) => (await request('POST', path, { ...options, body })).body,
64
+ patch: async (path, body, options) => (await request('PATCH', path, { ...options, body })).body,
65
+ };
66
+ }
@@ -0,0 +1,478 @@
1
+ import { readFile, writeFile } from 'node:fs/promises';
2
+ import { keyValue, parseArguments, UsageError } from './args.mjs';
3
+ import { ageText, localTime, percentText, renderKeyValues, renderTable, singleLine } from './format.mjs';
4
+
5
+ const roleLabels = { viewer: '조회 전용', editor: '편집 가능', admin: '관리자' };
6
+ const outputChoices = ['table', 'json', 'ndjson'];
7
+ const logRanges = ['15m', '1h', '6h', '24h', '7d'];
8
+ const logLevels = ['emergency', 'critical', 'error', 'warning', 'info', 'debug'];
9
+
10
+ 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' },
18
+ };
19
+
20
+ function logCriteria(options, positionals) {
21
+ 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는 함께 쓸 수 없습니다.');
24
+ return {
25
+ 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,
27
+ };
28
+ }
29
+
30
+ function emit(context, value, table) {
31
+ const { output } = context.global;
32
+ if (output === 'json') context.out(JSON.stringify(value, null, 2));
33
+ else if (output === 'ndjson') for (const item of Array.isArray(value) ? value : [value]) context.out(JSON.stringify(item));
34
+ else context.out(table());
35
+ }
36
+
37
+ 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 },
43
+ ];
44
+
45
+ function logKey(log) {
46
+ return `${log.timestamp}\n${log.agentId}\n${log.source}\n${log.message}`;
47
+ }
48
+
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);
63
+ const logs = [];
64
+ let cursor;
65
+ let last;
66
+ for (let page = 0; page < maxPages; page += 1) {
67
+ last = await context.client.get('/api/logs', { ...criteria, cursor });
68
+ logs.push(...last.logs);
69
+ if (!last.hasMore || !last.nextCursor) break;
70
+ cursor = last.nextCursor;
71
+ }
72
+ if (context.global.output === 'json') {
73
+ 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)}`);
79
+ }
80
+ }
81
+
82
+ async function followLogs(context, criteria, options) {
83
+ const seen = new Set();
84
+ 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 };
88
+ let first = true;
89
+ context.err('새 로그를 기다립니다. Ctrl+C로 종료합니다.');
90
+ while (!context.signal?.aborted) {
91
+ const body = await context.client.get('/api/logs', followCriteria);
92
+ const fresh = body.logs.filter((log) => !seen.has(logKey(log))).reverse();
93
+ if (!first && body.hasMore && fresh.length === body.logs.length) {
94
+ context.err(`경고: ${options.interval}초 사이에 ${followCriteria.limit}건보다 많은 로그가 들어와 일부를 건너뛰었습니다. -a/-s/-l/-q로 범위를 좁히거나 --interval을 줄이세요.`);
95
+ }
96
+ for (const log of body.logs) seen.add(logKey(log));
97
+ if (seen.size > 20_000) for (const key of [...seen].slice(0, seen.size - 10_000)) seen.delete(key);
98
+ const visible = first ? fresh.slice(-20) : fresh;
99
+ if (visible.length) {
100
+ if (context.global.output === 'table') {
101
+ const rendered = renderTable(visible, logColumns, { width: context.width, wide: options.wide }).split('\n');
102
+ context.out((first ? rendered : rendered.slice(1)).join('\n'));
103
+ } else {
104
+ for (const log of visible) context.out(JSON.stringify(log));
105
+ }
106
+ }
107
+ first = false;
108
+ await context.sleep(intervalMs);
109
+ }
110
+ }
111
+
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가 필요합니다.');
118
+ const body = await context.client.get('/api/logs/context', {
119
+ agentId: options.agent, source: options.source, timestamp: options.timestamp,
120
+ before: options.before, after: options.after, windowSeconds: options.window,
121
+ });
122
+ emit(context, context.global.output === 'ndjson' ? body.logs : body,
123
+ () => renderTable(body.logs, logColumns, { width: context.width, wide: options.wide }));
124
+ }
125
+
126
+ async function logsHistogram(context, argv) {
127
+ const { options, positionals } = parseArguments(argv, logFilterOptions);
128
+ const body = await context.client.get('/api/logs/histogram', logCriteria(options, positionals));
129
+ emit(context, context.global.output === 'ndjson' ? body.buckets : body, () => {
130
+ const peak = Math.max(1, ...body.buckets.map((bucket) => bucket.total));
131
+ 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) },
135
+ { header: '', value: (bucket) => '█'.repeat(Math.round((bucket.total / peak) * 30)) },
136
+ ], { width: context.width, wide: true });
137
+ });
138
+ }
139
+
140
+ async function logsSources(context, argv) {
141
+ const { options } = parseArguments(argv, { range: { alias: 'r', type: 'string', choices: logRanges, default: '24h' } });
142
+ const body = await context.client.get('/api/logs/sources', { range: options.range });
143
+ 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) },
146
+ ], { width: context.width }));
147
+ }
148
+
149
+ 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 },
154
+ { 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) },
158
+ ];
159
+
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
+ });
168
+ for (const label of options.label ?? []) keyValue(label, '--label');
169
+ const query = {
170
+ query: [options.query, ...positionals].filter(Boolean).join(' ') || undefined,
171
+ status: options.status, lifecycle: options.lifecycle, role: options.role, site: options.site, group: options.group,
172
+ maintainer: options.maintainer, fleet: options.fleet, focus: options.focus, inventory: options.inventory,
173
+ labels: options.label?.length ? JSON.stringify(options.label) : undefined, sort: options.sort, order: options.order,
174
+ pageSize: options.all ? 100 : options['page-size'],
175
+ };
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;
182
+ }
183
+ 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 ?? ''}` : ''}`);
190
+ }
191
+ }
192
+
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 });
198
+ const device = body.device;
199
+ 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 ?? ''})` : '최신'],
206
+ ]));
207
+ }
208
+
209
+ async function devicesLookup(context, argv) {
210
+ const { positionals } = parseArguments(argv, {});
211
+ const body = await context.client.get('/api/devices/lookup', { query: positionals.join(' ') });
212
+ 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 },
216
+ ], { width: context.width }));
217
+ }
218
+
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가 필요합니다.');
223
+ const body = await context.client.get(`/api/devices/${encodeURIComponent(agentId)}/overview`, { range: options.range });
224
+ 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(', ')],
228
+ ]));
229
+ }
230
+
231
+ async function health(context) {
232
+ const body = await context.client.get('/api/health');
233
+ emit(context, body, () => [
234
+ renderKeyValues([['전체', body.status], ['버전', `${body.application?.version} (${body.application?.commit})`],
235
+ ['관측기', body.observer?.status ?? body.observer?.state]]),
236
+ '',
237
+ 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 ?? '' },
242
+ ], { width: context.width }),
243
+ ].join('\n'));
244
+ }
245
+
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`);
250
+ 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],
256
+ ]));
257
+ }
258
+
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 });
263
+ 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(', ') },
268
+ ], { width: context.width }));
269
+ }
270
+
271
+ const metadataFieldOptions = {
272
+ 'display-name': 'displayName', role: 'role', maintainer: 'maintainer', contact: 'maintainerContact',
273
+ description: 'description', lifecycle: 'lifecycle', 'maintenance-until': 'maintenanceUntil', 'maintenance-reason': 'maintenanceReason',
274
+ };
275
+
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);
286
+ const patch = { revision: current.revision };
287
+ for (const [option, field] of Object.entries(metadataFieldOptions)) {
288
+ if (options[option] !== undefined) patch[field] = options[option];
289
+ }
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
+ }
294
+ if (options.label?.length || options['remove-label']?.length) {
295
+ const labels = { ...(current.labels ?? {}) };
296
+ for (const item of options.label ?? []) {
297
+ const [key, value] = keyValue(item, '--label');
298
+ labels[key] = value;
299
+ }
300
+ for (const key of options['remove-label'] ?? []) delete labels[key];
301
+ patch.labels = labels;
302
+ }
303
+ if (Object.keys(patch).length === 1) throw new UsageError('바꿀 항목을 하나 이상 지정하세요. 예: --display-name "DB 1"');
304
+ const updated = await context.client.patch(path, patch);
305
+ emit(context, updated, () => `${agentId} 메타데이터를 변경 버전 ${updated.revision}으로 저장했습니다.`);
306
+ }
307
+
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 <변경 버전>이 필요합니다.');
312
+ const path = `/api/devices/${encodeURIComponent(agentId)}/metadata`;
313
+ const current = await context.client.get(path);
314
+ const updated = await context.client.post(`${path}/rollback`, { revision: current.revision, targetRevision: options.to });
315
+ emit(context, updated, () => `${agentId} 메타데이터를 버전 ${options.to} 내용으로 되돌렸습니다(새 변경 버전 ${updated.revision}).`);
316
+ }
317
+
318
+ async function metadataExport(context, argv) {
319
+ const { options } = parseArguments(argv, { file: { type: 'string' } });
320
+ const body = await context.client.get('/api/metadata/export');
321
+ 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}에 저장했습니다.`);
324
+ return;
325
+ }
326
+ context.out(JSON.stringify(body, null, 2));
327
+ }
328
+
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 파일 경로가 필요합니다.');
334
+ let payload;
335
+ try {
336
+ payload = JSON.parse(await readFile(positionals[0], 'utf8'));
337
+ } catch {
338
+ throw new UsageError(`JSON 파일을 읽을 수 없습니다: ${positionals[0]}`);
339
+ }
340
+ const path = options['dry-run'] ? '/api/metadata/import/validate' : '/api/metadata/import';
341
+ const body = (await context.client.request('POST', path, { query: { conflictPolicy: options.conflict }, body: payload })).body;
342
+ emit(context, body, () => JSON.stringify(body, null, 2));
343
+ }
344
+
345
+ const sessionColumns = [
346
+ { 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) },
352
+ ];
353
+
354
+ async function sessionsList(context) {
355
+ const body = await context.client.get('/api/auth/sessions');
356
+ emit(context, context.global.output === 'ndjson' ? body.sessions : body, () => renderTable(
357
+ body.sessions, [...sessionColumns, { header: '', value: (session) => (session.current ? '← 현재' : '') }], { width: context.width },
358
+ ));
359
+ }
360
+
361
+ async function sessionsRevoke(context, argv) {
362
+ const { positionals } = parseArguments(argv, {});
363
+ if (!positionals[0]) throw new UsageError('폐기할 세션 ID가 필요합니다.');
364
+ await context.client.post(`/api/auth/sessions/${encodeURIComponent(positionals[0])}/revoke`, {});
365
+ context.out(`세션 ${positionals[0]}을 폐기했습니다.`);
366
+ }
367
+
368
+ async function adminUsers(context) {
369
+ const body = await context.client.get('/api/admin/users');
370
+ 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) },
376
+ ], { width: context.width }));
377
+ }
378
+
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)}`);
389
+ }
390
+
391
+ async function adminSessions(context, argv) {
392
+ const { options } = parseArguments(argv, { kind: { type: 'string', choices: ['web', 'cli'] } });
393
+ const body = await context.client.get('/api/admin/sessions', { kind: options.kind });
394
+ emit(context, context.global.output === 'ndjson' ? body.sessions : body, () => renderTable(body.sessions, sessionColumns, { width: context.width }));
395
+ }
396
+
397
+ async function adminRevokeSession(context, argv) {
398
+ const { positionals } = parseArguments(argv, {});
399
+ if (!positionals[0]) throw new UsageError('폐기할 세션 ID가 필요합니다.');
400
+ await context.client.post(`/api/admin/sessions/${encodeURIComponent(positionals[0])}/revoke`, {});
401
+ context.out(`세션 ${positionals[0]}을 폐기했습니다.`);
402
+ }
403
+
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
+ });
408
+ const [path] = positionals;
409
+ if (!path?.startsWith('/api/')) throw new UsageError('/api/로 시작하는 경로가 필요합니다.');
410
+ let body;
411
+ if (options.data !== undefined) {
412
+ try {
413
+ body = JSON.parse(options.data);
414
+ } catch {
415
+ throw new UsageError('--data는 JSON이어야 합니다.');
416
+ }
417
+ }
418
+ const url = new URL(path, context.client.server);
419
+ const result = await context.client.request(options.method, url.pathname, { query: Object.fromEntries(url.searchParams), body });
420
+ context.out(JSON.stringify(result.body, null, 2));
421
+ }
422
+
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]' },
431
+ },
432
+ },
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]' },
440
+ },
441
+ },
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]' },
451
+ },
452
+ },
453
+ sessions: {
454
+ summary: '내 웹 세션·CLI 토큰 조회와 폐기',
455
+ commands: {
456
+ list: { run: sessionsList, usage: 'sessions list' },
457
+ revoke: { run: sessionsRevoke, usage: 'sessions revoke <세션 ID>' },
458
+ },
459
+ },
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>' },
469
+ },
470
+ },
471
+ };
472
+
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 출력)' },
476
+ };
477
+
478
+ export { outputChoices, singleLine };
@@ -0,0 +1,76 @@
1
+ import { chmod, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
2
+ import { homedir } from 'node:os';
3
+ import { dirname, join } from 'node:path';
4
+
5
+ export const defaultServer = 'https://monitor.intflow.dev';
6
+
7
+ export function credentialsPath(environment = process.env, platform = process.platform) {
8
+ if (environment.SENTINELCTL_CONFIG) return environment.SENTINELCTL_CONFIG;
9
+ if (platform === 'win32' && environment.APPDATA) return join(environment.APPDATA, 'sentinelctl', 'credentials.json');
10
+ const base = environment.XDG_CONFIG_HOME || join(homedir(), '.config');
11
+ return join(base, 'sentinelctl', 'credentials.json');
12
+ }
13
+
14
+ export function normalizeServer(value) {
15
+ let url;
16
+ try {
17
+ url = new URL(String(value ?? ''));
18
+ } catch {
19
+ throw new Error(`서버 주소가 올바르지 않습니다: ${value}`);
20
+ }
21
+ const loopback = ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname);
22
+ if (url.username || url.password || url.search || url.hash || (url.pathname !== '/' && url.pathname !== '')) {
23
+ throw new Error('서버 주소는 경로가 없는 origin이어야 합니다. 예: https://monitor.intflow.dev');
24
+ }
25
+ if (url.protocol !== 'https:' && !(url.protocol === 'http:' && loopback)) {
26
+ throw new Error('서버 주소는 HTTPS여야 합니다(localhost만 HTTP 허용).');
27
+ }
28
+ return url.origin;
29
+ }
30
+
31
+ export async function readCredentials(path) {
32
+ try {
33
+ const parsed = JSON.parse(await readFile(path, 'utf8'));
34
+ if (parsed?.version !== 1 || typeof parsed.profiles !== 'object' || parsed.profiles === null) {
35
+ throw new Error('형식');
36
+ }
37
+ return parsed;
38
+ } catch (error) {
39
+ if (error.code === 'ENOENT') return { version: 1, profiles: {} };
40
+ throw new Error(`자격 증명 파일을 읽을 수 없습니다: ${path}`);
41
+ }
42
+ }
43
+
44
+ // 토큰 파일은 소유자만 읽을 수 있게 0600으로 쓰고, 임시 파일을 거쳐 원자적으로 교체한다.
45
+ export async function writeCredentials(path, credentials) {
46
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
47
+ const temporary = `${path}.${process.pid}.tmp`;
48
+ await writeFile(temporary, `${JSON.stringify(credentials, null, 2)}\n`, { mode: 0o600 });
49
+ await chmod(temporary, 0o600).catch(() => {});
50
+ await rename(temporary, path);
51
+ }
52
+
53
+ export async function saveProfile(path, profileName, profile) {
54
+ const credentials = await readCredentials(path);
55
+ credentials.profiles[profileName] = profile;
56
+ await writeCredentials(path, credentials);
57
+ }
58
+
59
+ export async function removeProfile(path, profileName) {
60
+ const credentials = await readCredentials(path);
61
+ if (!credentials.profiles[profileName]) return false;
62
+ delete credentials.profiles[profileName];
63
+ if (Object.keys(credentials.profiles).length) await writeCredentials(path, credentials);
64
+ else await rm(path, { force: true });
65
+ return true;
66
+ }
67
+
68
+ // 우선순위: 명령행 --server > SENTINEL_URL > 프로필 > 기본값. 토큰은 SENTINEL_TOKEN > 프로필.
69
+ export async function resolveConnection({ server, profile = 'default', environment = process.env, path = credentialsPath(environment) }) {
70
+ const credentials = await readCredentials(path);
71
+ const saved = credentials.profiles[profile] ?? null;
72
+ const resolvedServer = normalizeServer(server ?? environment.SENTINEL_URL ?? saved?.server ?? defaultServer);
73
+ const token = environment.SENTINEL_TOKEN
74
+ ?? (saved && normalizeServer(saved.server) === resolvedServer ? saved.token : null);
75
+ return { server: resolvedServer, token: token ?? null, profile: saved, profileName: profile, path };
76
+ }
package/src/format.mjs ADDED
@@ -0,0 +1,73 @@
1
+ // 한글·CJK 전각 문자는 터미널에서 두 칸을 차지한다.
2
+ const wideCharacter = /[ᄀ-ᅟ⺀-꓏가-힣豈-﫿︰-﹏＀-⦆¢-₩]/u;
3
+
4
+ export function displayWidth(value) {
5
+ let width = 0;
6
+ for (const character of String(value)) width += wideCharacter.test(character) ? 2 : 1;
7
+ return width;
8
+ }
9
+
10
+ export function truncate(value, maximum) {
11
+ const text = String(value ?? '');
12
+ if (maximum <= 0) return '';
13
+ if (displayWidth(text) <= maximum) return text;
14
+ let result = '';
15
+ let width = 0;
16
+ for (const character of text) {
17
+ const characterWidth = wideCharacter.test(character) ? 2 : 1;
18
+ if (width + characterWidth > maximum - 1) break;
19
+ result += character;
20
+ width += characterWidth;
21
+ }
22
+ return `${result}…`;
23
+ }
24
+
25
+ function pad(value, width) {
26
+ return `${value}${' '.repeat(Math.max(0, width - displayWidth(value)))}`;
27
+ }
28
+
29
+ export function singleLine(value) {
30
+ return String(value ?? '').replace(/[\r\n\t]+/g, ' ').replace(/[\u0000-\u001f\u007f]/g, '');
31
+ }
32
+
33
+ // columns: [{ header, value(row), max? }]. 마지막 열은 남은 터미널 폭에 맞춰 자른다.
34
+ export function renderTable(rows, columns, { width = 120, wide = false } = {}) {
35
+ const cells = rows.map((row) => columns.map((column) => singleLine(column.value(row) ?? '-')));
36
+ const widths = columns.map((column, index) => Math.min(column.max ?? Infinity,
37
+ Math.max(displayWidth(column.header), ...cells.map((row) => displayWidth(row[index])))));
38
+ if (!wide && columns.length > 1) {
39
+ const fixed = widths.slice(0, -1).reduce((sum, value) => sum + value + 2, 0);
40
+ widths[widths.length - 1] = Math.max(10, Math.min(widths.at(-1), width - fixed));
41
+ }
42
+ const line = (values) => values.map((value, index) => (index === values.length - 1
43
+ ? (wide ? value : truncate(value, widths[index]))
44
+ : pad(truncate(value, widths[index]), widths[index]))).join(' ').trimEnd();
45
+ return [line(columns.map((column) => column.header)), ...cells.map(line)].join('\n');
46
+ }
47
+
48
+ export function renderKeyValues(entries) {
49
+ const visible = entries.filter(([, value]) => value !== undefined && value !== null && value !== '');
50
+ const width = Math.max(0, ...visible.map(([key]) => displayWidth(key)));
51
+ return visible.map(([key, value]) => `${pad(key, width)} ${singleLine(value)}`).join('\n');
52
+ }
53
+
54
+ export function localTime(value) {
55
+ if (!value) return '-';
56
+ const date = typeof value === 'number' ? new Date(value * 1000) : new Date(value);
57
+ if (!Number.isFinite(date.getTime())) return String(value);
58
+ const offset = -date.getTimezoneOffset();
59
+ const local = new Date(date.getTime() + offset * 60_000).toISOString().slice(0, 19).replace('T', ' ');
60
+ return local;
61
+ }
62
+
63
+ export function percentText(value) {
64
+ return Number.isFinite(value) ? `${Math.round(value)}%` : '-';
65
+ }
66
+
67
+ export function ageText(seconds) {
68
+ if (!Number.isFinite(seconds)) return '-';
69
+ if (seconds < 60) return `${Math.max(0, Math.round(seconds))}초 전`;
70
+ if (seconds < 3_600) return `${Math.round(seconds / 60)}분 전`;
71
+ if (seconds < 86_400) return `${Math.round(seconds / 3_600)}시간 전`;
72
+ return `${Math.round(seconds / 86_400)}일 전`;
73
+ }
package/src/login.mjs ADDED
@@ -0,0 +1,54 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { createHash, randomBytes } from 'node:crypto';
3
+ import { hostname } from 'node:os';
4
+ import { ApiError } from './client.mjs';
5
+
6
+ export function openBrowser(url, platform = process.platform) {
7
+ const [command, args] = platform === 'win32'
8
+ ? ['rundll32', ['url.dll,FileProtocolHandler', url]]
9
+ : [platform === 'darwin' ? 'open' : 'xdg-open', [url]];
10
+ try {
11
+ const child = spawn(command, args, { stdio: 'ignore', detached: true });
12
+ child.on('error', () => {});
13
+ child.unref();
14
+ return true;
15
+ } catch {
16
+ return false;
17
+ }
18
+ }
19
+
20
+ export function defaultClientName() {
21
+ return `sentinelctl@${hostname()}`.replace(/[\u0000-\u001f\u007f]/g, '').slice(0, 80);
22
+ }
23
+
24
+ // 서버가 발급하는 토큰은 브라우저 승인 뒤 폴링으로 한 번만 받는다. poll secret은 이 프로세스 밖으로 나가지 않는다.
25
+ export async function browserLogin(client, {
26
+ clientName = defaultClientName(), launchBrowser = true, notify, open = openBrowser,
27
+ sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), now = () => Date.now(),
28
+ }) {
29
+ const pollSecret = randomBytes(32).toString('base64url');
30
+ const pollChallenge = createHash('sha256').update(pollSecret).digest('base64url');
31
+ const started = await client.post('/api/cli/login', { pollChallenge, clientName }, { auth: false });
32
+ notify({ verificationUrl: started.verificationUrl, userCode: started.userCode, expiresIn: started.expiresIn });
33
+ if (launchBrowser) open(started.verificationUrl);
34
+ const deadline = now() + started.expiresIn * 1_000;
35
+ const intervalMs = Math.max(1, Number(started.interval) || 2) * 1_000;
36
+ while (now() < deadline) {
37
+ await sleep(intervalMs);
38
+ let result;
39
+ try {
40
+ result = await client.request('POST', '/api/cli/login/redeem', {
41
+ auth: false, body: { requestId: started.requestId, pollSecret }, allowStatuses: [202],
42
+ });
43
+ } catch (error) {
44
+ if (error instanceof ApiError && error.status === 429) {
45
+ await sleep(Math.min(error.retryAfterSeconds ?? 5, 30) * 1_000);
46
+ continue;
47
+ }
48
+ throw error;
49
+ }
50
+ if (result.status === 202) continue;
51
+ return result.body;
52
+ }
53
+ throw new ApiError('브라우저 승인 시간이 지났습니다. sentinelctl login을 다시 실행하세요.', { code: 'expired_token' });
54
+ }
package/src/main.mjs ADDED
@@ -0,0 +1,177 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { parseArguments, UsageError } from './args.mjs';
3
+ import { ApiError, createClient } from './client.mjs';
4
+ import { commandTable, outputChoices, standaloneCommands } from './commands.mjs';
5
+ import { credentialsPath, normalizeServer, removeProfile, resolveConnection, saveProfile } from './credentials.mjs';
6
+ import { renderKeyValues } from './format.mjs';
7
+ import { browserLogin, openBrowser } from './login.mjs';
8
+
9
+ const version = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
10
+ const roleLabels = { viewer: '조회 전용', editor: '편집 가능', admin: '관리자' };
11
+ const globalDefinitions = {
12
+ server: { type: 'string' },
13
+ profile: { type: 'string' },
14
+ output: { alias: 'o', type: 'string' },
15
+ json: { type: 'boolean' },
16
+ help: { alias: 'h', type: 'boolean' },
17
+ version: { alias: 'V', type: 'boolean' },
18
+ };
19
+
20
+ // 전역 옵션은 명령 앞뒤 어디에 와도 되게 먼저 떼어 낸다.
21
+ export function splitGlobalOptions(argv) {
22
+ const global = [];
23
+ const rest = [];
24
+ for (let index = 0; index < argv.length; index += 1) {
25
+ const argument = argv[index];
26
+ if (argument === '--') {
27
+ rest.push(...argv.slice(index));
28
+ break;
29
+ }
30
+ const name = argument.startsWith('--') ? argument.slice(2).split('=', 1)[0] : null;
31
+ const alias = argument.length === 2 && argument.startsWith('-') ? argument.slice(1) : null;
32
+ const spec = globalDefinitions[name] ?? Object.values(globalDefinitions).find((item) => item.alias && item.alias === alias);
33
+ if (!spec) {
34
+ rest.push(argument);
35
+ continue;
36
+ }
37
+ global.push(argument);
38
+ if (spec.type !== 'boolean' && !argument.includes('=') && index + 1 < argv.length) global.push(argv[++index]);
39
+ }
40
+ return { global: parseArguments(global, globalDefinitions).options, rest };
41
+ }
42
+
43
+ function helpText() {
44
+ const lines = [
45
+ `sentinelctl ${version} — Sentinel Fleet Console 명령행 도구`,
46
+ '',
47
+ '사용법: sentinelctl [--server URL] [--profile 이름] [-o table|json|ndjson] <명령> …',
48
+ '',
49
+ '인증:',
50
+ ' login [--no-browser] 브라우저에서 Intflow 계정으로 승인하고 CLI 토큰을 저장',
51
+ ' logout 서버의 CLI 토큰을 폐기하고 로컬 자격 증명을 삭제',
52
+ ' whoami 현재 사용자·권한 확인',
53
+ '',
54
+ '명령:',
55
+ ];
56
+ for (const [group, definition] of Object.entries(commandTable)) {
57
+ lines.push(` ${group.padEnd(10)} ${definition.summary}`);
58
+ for (const command of Object.values(definition.commands)) lines.push(` sentinelctl ${command.usage}`);
59
+ }
60
+ for (const [name, command] of Object.entries(standaloneCommands)) lines.push(` ${name.padEnd(10)} ${command.summary} — sentinelctl ${command.usage}`);
61
+ lines.push('', '환경 변수: SENTINEL_URL(서버), SENTINEL_TOKEN(토큰, 자동화용), SENTINELCTL_CONFIG(자격 증명 파일 경로)',
62
+ `기본 서버: https://monitor.intflow.dev · 자격 증명: ${credentialsPath()}`);
63
+ return lines.join('\n');
64
+ }
65
+
66
+ async function login(context, argv) {
67
+ const { options } = parseArguments(argv, { 'no-browser': { type: 'boolean' }, name: { type: 'string' } });
68
+ const client = createClient({ server: context.connection.server, fetch: context.fetch });
69
+ const result = await browserLogin(client, {
70
+ clientName: options.name,
71
+ launchBrowser: !options['no-browser'],
72
+ open: context.openBrowser,
73
+ sleep: context.sleep,
74
+ notify: ({ verificationUrl, userCode, expiresIn }) => {
75
+ context.err('아래 주소를 브라우저에서 열고 Intflow 계정으로 로그인한 뒤 승인하세요.');
76
+ context.err(` ${verificationUrl}`);
77
+ context.err(`확인 코드: ${userCode} (브라우저에 표시된 코드와 같은지 확인하세요 · ${Math.round(expiresIn / 60)}분 안에 승인)`);
78
+ },
79
+ });
80
+ await saveProfile(context.connection.path, context.global.profile ?? 'default', {
81
+ server: context.connection.server, token: result.token, tokenId: result.tokenId,
82
+ user: result.identity.user, role: result.identity.role, expiresAt: result.expiresAt,
83
+ });
84
+ context.out(`로그인했습니다: ${result.identity.user} (${roleLabels[result.identity.role] ?? result.identity.role}) · 토큰 만료 ${result.expiresAt}`);
85
+ }
86
+
87
+ async function logout(context) {
88
+ const profileName = context.global.profile ?? 'default';
89
+ if (context.connection.token) {
90
+ try {
91
+ await context.client.post('/api/logout', {});
92
+ } catch (error) {
93
+ if (!(error instanceof ApiError) || error.status !== 401) {
94
+ context.err(`서버에서 토큰을 폐기하지 못했습니다: ${error.message}. 로컬 자격 증명은 삭제합니다.`);
95
+ }
96
+ }
97
+ }
98
+ const removed = await removeProfile(context.connection.path, profileName);
99
+ context.out(removed ? '로그아웃했습니다.' : '저장된 로그인 정보가 없습니다.');
100
+ }
101
+
102
+ async function whoami(context) {
103
+ const identity = await context.client.get('/api/session');
104
+ if (context.global.output === 'json' || context.global.output === 'ndjson') {
105
+ context.out(JSON.stringify({ server: context.connection.server, ...identity, tokenExpiresAt: context.connection.profile?.expiresAt ?? null }));
106
+ return;
107
+ }
108
+ context.out(renderKeyValues([
109
+ ['서버', context.connection.server], ['사용자', identity.user], ['이름', identity.name],
110
+ ['권한', roleLabels[identity.role] ?? identity.role], ['토큰 ID', identity.sessionId],
111
+ ['토큰 만료', context.connection.profile?.expiresAt],
112
+ ]));
113
+ }
114
+
115
+ export async function main(argv, {
116
+ stdout = process.stdout, stderr = process.stderr, environment = process.env, fetch = globalThis.fetch,
117
+ sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), openBrowser: open = openBrowser, signal,
118
+ } = {}) {
119
+ const out = (text) => stdout.write(`${text}\n`);
120
+ const err = (text) => stderr.write(`${text}\n`);
121
+ try {
122
+ const { global, rest } = splitGlobalOptions(argv);
123
+ if (global.version) {
124
+ out(version);
125
+ return 0;
126
+ }
127
+ global.output = global.json ? 'json' : (global.output ?? 'table');
128
+ if (!outputChoices.includes(global.output)) throw new UsageError('-o는 table, json, ndjson 중 하나여야 합니다.');
129
+ const [name, subcommand, ...commandArguments] = rest;
130
+ if (!name || global.help || name === 'help') {
131
+ out(helpText());
132
+ return name || global.help || name === 'help' ? 0 : 2;
133
+ }
134
+ if (global.server) global.server = normalizeServer(global.server);
135
+ const connection = await resolveConnection({ server: global.server, profile: global.profile ?? 'default', environment,
136
+ path: credentialsPath(environment) });
137
+ const context = {
138
+ global, connection, out, err, fetch, sleep, signal, openBrowser: open, width: stdout.columns ?? 120,
139
+ client: createClient({ server: connection.server, token: connection.token, fetch, sleep }),
140
+ };
141
+ if (name === 'login') {
142
+ await login(context, [subcommand, ...commandArguments].filter((item) => item !== undefined));
143
+ return 0;
144
+ }
145
+ if (name === 'logout') {
146
+ await logout(context);
147
+ return 0;
148
+ }
149
+ if (name === 'whoami') {
150
+ await whoami(context);
151
+ return 0;
152
+ }
153
+ if (standaloneCommands[name]) {
154
+ await standaloneCommands[name].run(context, [subcommand, ...commandArguments].filter((item) => item !== undefined));
155
+ return 0;
156
+ }
157
+ const group = commandTable[name];
158
+ if (!group) throw new UsageError(`알 수 없는 명령입니다: ${name}. sentinelctl --help를 확인하세요.`);
159
+ const command = group.commands[subcommand];
160
+ if (!command) {
161
+ throw new UsageError(`${name} 하위 명령이 필요합니다:\n${Object.values(group.commands).map((item) => ` sentinelctl ${item.usage}`).join('\n')}`);
162
+ }
163
+ await command.run(context, commandArguments);
164
+ return 0;
165
+ } catch (error) {
166
+ if (error instanceof UsageError) {
167
+ err(`사용법 오류: ${error.message}`);
168
+ return 2;
169
+ }
170
+ if (error instanceof ApiError) {
171
+ err(`오류: ${error.message}`);
172
+ return error.status === 401 || error.status === 403 ? 3 : 1;
173
+ }
174
+ err(`오류: ${error.message}`);
175
+ return 1;
176
+ }
177
+ }