@intflow/sentinelctl 0.1.0 → 0.3.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 CHANGED
@@ -1,91 +1,115 @@
1
1
  # sentinelctl
2
2
 
3
- Sentinel Fleet Console(`https://monitor.intflow.dev`)을 터미널에서 쓰는 명령행 도구입니다. 로그 검색, 장비 조회, 메타데이터 변경, 사용자 권한 관리를 지원합니다. 웹 콘솔과 같은 Intflow(Google Workspace) 로그인과 같은 역할 검사를 사용합니다.
3
+ Command-line client for Sentinel Fleet Console (`https://monitor.intflow.dev`): search device logs, inspect device status and metrics, edit device metadata and manage console roles. It uses the same Intflow (Google Workspace) login and the same role checks as the web console.
4
4
 
5
- - Node.js 20 이상, 외부 의존성 없음
6
- - 권한: `viewer`는 조회만, `editor`는 메타데이터 변경까지, `admin`은 원본 로그 필드·가져오기/내보내기·사용자 관리까지 가능합니다. 권한은 서버가 판정하며 CLI는 서버 결과를 그대로 보여 줍니다.
5
+ - Node.js 20+, no dependencies.
6
+ - Roles: `viewer` reads, `editor` also edits metadata, `admin` also sees raw log fields, imports/exports metadata and manages users. The server enforces roles; the CLI also checks your role before sending a request that would be refused.
7
7
 
8
- ## 설치
8
+ ## Install
9
9
 
10
10
  ```bash
11
- npm install -g @intflow/sentinelctl
11
+ npm install -g @intflow/sentinelctl # or the short alias: npm install -g sentinelctl
12
12
  ```
13
13
 
14
- ## 로그인
14
+ `sentinelctl` is an alias package that runs `@intflow/sentinelctl`. Install only one of them.
15
+
16
+ ## Log in
15
17
 
16
18
  ```bash
17
19
  sentinelctl login
18
20
  ```
19
21
 
20
- 1. 터미널에 승인 주소와 확인 코드(예: `BCDF-GHJK`)가 나타나고 브라우저가 열립니다. 원격 셸이면 `--no-browser`를 주고 주소를 직접 엽니다.
21
- 2. 브라우저에서 Intflow 계정으로 로그인한 뒤, 화면의 확인 코드가 터미널과 같은지 보고 **승인**합니다.
22
- 3. CLI가 토큰(기본 30일)을 받아 자격 증명 파일에 0600 권한으로 저장합니다.
22
+ 1. The terminal prints an approval URL and a confirmation code (e.g. `BCDF-GHJK`) and opens the browser. Over SSH, add `--no-browser` and open the URL yourself.
23
+ 2. Sign in with your intflow.ai account, check that the browser shows the same code, and approve.
24
+ 3. The CLI stores a 30-day token in an owner-only (0600) credentials file.
23
25
 
24
26
  ```bash
25
27
  sentinelctl whoami
26
- sentinelctl sessions list # 내 웹 세션·CLI 토큰
27
- sentinelctl logout # 서버에서 토큰 폐기 + 로컬 삭제
28
+ sentinelctl sessions list # your web sessions and CLI tokens
29
+ sentinelctl logout # revoke on the server and delete local credentials
30
+ ```
31
+
32
+ Credentials live in `~/.config/sentinelctl/credentials.json` (Linux/macOS) or `%APPDATA%\sentinelctl\credentials.json` (Windows); override with `SENTINELCTL_CONFIG`. The server is taken from `--server`, then `SENTINEL_URL`, then the saved profile, then the default. Automation can pass `SENTINEL_TOKEN` instead of a credentials file.
33
+
34
+ ## Getting help
35
+
36
+ ```bash
37
+ sentinelctl --help # overview
38
+ sentinelctl logs search --help # one command: options, examples, required role, output fields
39
+ sentinelctl help agents # guide for AI agents and scripts
40
+ sentinelctl commands --json # machine-readable catalog of every command
28
41
  ```
29
42
 
30
- 자격 증명 파일 위치는 Linux/macOS `~/.config/sentinelctl/credentials.json`, Windows `%APPDATA%\sentinelctl\credentials.json`이며 `SENTINELCTL_CONFIG`로 바꿀 수 있습니다. 서버는 `--server`, `SENTINEL_URL`, 저장된 프로필, 기본값 순서로 정합니다. 자동화에서는 파일 대신 `SENTINEL_TOKEN` 환경 변수를 쓸 수 있습니다.
43
+ Mistyped commands and options get a suggestion (`Did you mean --range?`), and every error comes with a hint for the next step.
31
44
 
32
- ## 로그
45
+ ## Logs
33
46
 
34
47
  ```bash
35
- sentinelctl logs search timeout -r 1h # 최근 1시간 "timeout" 검색
36
- sentinelctl logs search -a agent-123 -l error -r 24h --all # 장비·수준 필터, 모든 페이지
48
+ sentinelctl logs search timeout -r 1h
49
+ sentinelctl logs search -a <agent-id> -l error -r 24h --all
37
50
  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
51
+ sentinelctl logs search -l error -f # follow new errors (Ctrl+C to stop)
52
+ sentinelctl logs search -q boom -o ndjson | jq .message
53
+ sentinelctl logs context -a <agent-id> -s app.service -t 2026-09-30T01:02:03.456Z
41
54
  sentinelctl logs histogram -l error -r 24h
55
+ sentinelctl logs stats -l error -r 1h --by source # exact counts, one server-side query
56
+ sentinelctl logs stats -l error -r 24h --by agent --top 10
57
+ sentinelctl logs stats -a <agent-id> -r 7d --by message # message patterns, numbers collapsed to <N>
42
58
  sentinelctl logs sources -r 24h
43
59
  ```
44
60
 
45
- 검색 범위는 `15m`·`1h`·`6h`·`24h`·`7d` 또는 최대 30일의 `--from`/`--to`입니다. 한 페이지는 `-n 50|100|250`건이고 서버 상한 때문에 최대 10,000건까지 이어서 가져옵니다. `--fields`(원본 필드)는 관리자 전용이며 감사 로그에 남습니다.
61
+ Windows are `15m`, `1h`, `6h`, `24h`, `7d`, or `--from`/`--to` up to 30 days. One page holds 50, 100 or 250 logs; when you ask for several pages (`--pages N`, at most 40, or `--all`) the CLI fetches 250 per request and stops at 10,000 logs. To answer "how many" or "which devices the most", use `logs stats`: it aggregates on the server and returns exact counts instead of downloading logs. `--fields` (raw fields) is admin-only and audited. `--follow` polls at most every 5 seconds.
46
62
 
47
- ## 장비와 메타데이터
63
+ Each user can run 2 log queries at a time, web and CLI combined; a third concurrent query gets 429 and GET requests retry automatically.
64
+
65
+ ## Devices and metadata
48
66
 
49
67
  ```bash
50
68
  sentinelctl devices list --status offline
51
- sentinelctl devices list 서울 --site seoul --all -o json
52
- sentinelctl devices show agent-123 -r 24h
69
+ sentinelctl devices list --focus attention --all -o json
70
+ sentinelctl devices show <agent-id> -r 24h -o json
53
71
  sentinelctl devices lookup edge-01
72
+ sentinelctl devices overview <agent-id> -r 7d
54
73
  sentinelctl health
55
74
 
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
75
+ sentinelctl metadata get <agent-id>
76
+ sentinelctl metadata history <agent-id>
77
+ sentinelctl metadata set <agent-id> --display-name "Edge 1" --label site=seoul --dry-run # editor
78
+ sentinelctl metadata set <agent-id> --lifecycle maintenance --maintenance-until 2026-10-01T09:00:00+09:00
79
+ sentinelctl metadata bulk id1 id2 id3 --role camera --dry-run # editor, up to 100
80
+ sentinelctl metadata rollback <agent-id> --to 3
81
+ sentinelctl metadata export --file backup.json # admin
82
+ sentinelctl metadata import backup.json --dry-run # admin
63
83
  ```
64
84
 
65
- `metadata set`은 현재 변경 버전을 읽은 뒤 그 버전으로 수정합니다. 그 사이 다른 사람이 바꿨으면 서버가 `409`로 거부하므로 다시 실행하면 됩니다.
85
+ `metadata set` and `metadata bulk` read the latest revision and apply on top of it. If someone saves in between, the server answers 409; run the command again.
66
86
 
67
- ## 관리자
87
+ ## Admin
68
88
 
69
89
  ```bash
70
90
  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 토큰 즉시 종료
91
+ sentinelctl admin grant kim@intflow.ai --role editor # works before the first login
92
+ sentinelctl admin reset kim@intflow.ai # back to the default viewer
93
+ sentinelctl admin disable kim@intflow.ai # block access and end all sessions now
74
94
  sentinelctl admin sessions --kind cli
75
- sentinelctl admin revoke <세션 ID>
95
+ sentinelctl admin revoke <session-id>
96
+ sentinelctl admin audit --cleanup
97
+ sentinelctl admin operations -r 7d
76
98
  ```
77
99
 
78
- 그 밖의 API는 `sentinelctl api /api/operations?range=7d`처럼 직접 호출할 수 있습니다.
100
+ Any other endpoint: `sentinelctl api "/api/devices?status=offline"`.
101
+
102
+ ## Output and exit codes
79
103
 
80
- ## 출력과 종료 코드
104
+ - `-o table` (default), `-o json` (`--json`), `-o ndjson`. Data goes to stdout; summaries, hints and errors go to stderr (`--quiet` drops summaries and hints). With json/ndjson, an error is one stderr line: `{"error":{"status","code","message","hint","exitCode"}}`.
105
+ - Exit codes: `0` success, `1` server or network error, `2` usage error, `3` not logged in or insufficient role.
106
+ - Server error messages are Korean because the web console shares them; hints are English.
81
107
 
82
- - `-o table`(기본), `-o json`(`--json`), `-o ndjson`
83
- - 종료 코드: `0` 성공, `1` 서버·네트워크 오류, `2` 사용법 오류, `3` 인증 필요 또는 권한 부족
108
+ ## Publishing
84
109
 
85
- ## 배포
110
+ Publish the scoped package first, then the alias with the same version (`cli/unscoped/package.json` depends on it). Accounts with two-factor authentication need an interactive terminal for the browser approval.
86
111
 
87
112
  ```bash
88
- cd cli
89
- npm test
90
- npm publish --access public
113
+ cd cli && npm test && npm publish --access public
114
+ cd unscoped && npm publish --access public
91
115
  ```
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@intflow/sentinelctl",
3
- "version": "0.1.0",
4
- "description": "Sentinel Fleet Console 명령행 도구: 로그 검색, 장비 조회, 메타데이터·권한 관리",
3
+ "version": "0.3.0",
4
+ "description": "Sentinel Fleet Console CLI: search device logs, inspect status and metrics, edit metadata and manage roles",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "sentinelctl": "bin/sentinelctl.mjs"
@@ -24,5 +24,12 @@
24
24
  "license": "UNLICENSED",
25
25
  "publishConfig": {
26
26
  "access": "public"
27
- }
27
+ },
28
+ "keywords": [
29
+ "sentinel",
30
+ "intflow",
31
+ "logs",
32
+ "monitoring",
33
+ "cli"
34
+ ]
28
35
  }
package/src/args.mjs CHANGED
@@ -1,23 +1,50 @@
1
1
  export class UsageError extends Error {
2
- constructor(message) {
2
+ constructor(message, { hint = null } = {}) {
3
3
  super(message);
4
4
  this.name = 'UsageError';
5
+ this.hint = hint;
5
6
  }
6
7
  }
7
8
 
8
- // 옵션 정의: { name: { alias, type: 'string'|'boolean'|'integer', multiple } }
9
- // "--name value", "--name=value", "-a value", "--flag", "--no-flag"를 지원한다.
9
+ function editDistance(left, right) {
10
+ const previous = Array.from({ length: right.length + 1 }, (_, index) => index);
11
+ for (let i = 1; i <= left.length; i += 1) {
12
+ let diagonal = previous[0];
13
+ previous[0] = i;
14
+ for (let j = 1; j <= right.length; j += 1) {
15
+ const saved = previous[j];
16
+ previous[j] = Math.min(previous[j] + 1, previous[j - 1] + 1, diagonal + (left[i - 1] === right[j - 1] ? 0 : 1));
17
+ diagonal = saved;
18
+ }
19
+ }
20
+ return previous[right.length];
21
+ }
22
+
23
+ export function closest(value, candidates) {
24
+ const input = String(value ?? '').toLowerCase();
25
+ let best = null;
26
+ for (const candidate of candidates) {
27
+ const distance = candidate.startsWith(input) && input.length >= 2 ? 0 : editDistance(input, candidate.toLowerCase());
28
+ if (!best || distance < best.distance) best = { candidate, distance };
29
+ }
30
+ return best && best.distance <= Math.max(1, Math.floor(best.candidate.length / 2)) ? best.candidate : null;
31
+ }
32
+
10
33
  export function parseArguments(argv, definitions) {
11
34
  const options = {};
12
35
  const positionals = [];
13
36
  const aliases = new Map(Object.entries(definitions).filter(([, spec]) => spec.alias).map(([name, spec]) => [spec.alias, name]));
37
+ const unknown = (flag) => {
38
+ const suggestion = closest(flag.replace(/^-+/, ''), Object.keys(definitions));
39
+ return new UsageError(`Unknown option: ${flag}`, { hint: suggestion ? `Did you mean --${suggestion}?` : null });
40
+ };
14
41
  for (let index = 0; index < argv.length; index += 1) {
15
42
  const argument = argv[index];
16
43
  if (argument === '--') {
17
44
  positionals.push(...argv.slice(index + 1));
18
45
  break;
19
46
  }
20
- if (!argument.startsWith('-') || argument === '-') {
47
+ if (!argument.startsWith('-') || argument === '-' || /^-\d/.test(argument)) {
21
48
  positionals.push(argument);
22
49
  continue;
23
50
  }
@@ -34,27 +61,31 @@ export function parseArguments(argv, definitions) {
34
61
  }
35
62
  } else {
36
63
  name = aliases.get(argument.slice(1));
37
- if (!name) throw new UsageError(`알 수 없는 옵션입니다: ${argument}`);
64
+ if (!name) throw unknown(argument);
38
65
  }
39
66
  const spec = definitions[name];
40
- if (!spec) throw new UsageError(`알 수 없는 옵션입니다: --${name}`);
67
+ if (!spec) throw unknown(`--${name}`);
41
68
  if (spec.type === 'boolean') {
42
- if (value !== undefined) throw new UsageError(`--${name}에는 값을 줄 수 없습니다.`);
69
+ if (value !== undefined) throw new UsageError(`--${name} does not take a value.`);
43
70
  options[name] = true;
44
71
  continue;
45
72
  }
46
73
  if (value === undefined) {
47
74
  value = argv[index + 1];
48
- if (value === undefined) throw new UsageError(`--${name}에 값이 필요합니다.`);
75
+ if (value === undefined) {
76
+ throw new UsageError(`--${name} requires a value.`, { hint: spec.choices ? `Allowed values: ${spec.choices.join(', ')}` : null });
77
+ }
49
78
  index += 1;
50
79
  }
51
80
  let parsed = value;
52
81
  if (spec.type === 'integer') {
53
82
  parsed = Number(value);
54
- if (!Number.isInteger(parsed)) throw new UsageError(`--${name}은 정수여야 합니다.`);
83
+ if (!Number.isInteger(parsed)) throw new UsageError(`--${name} must be an integer: ${value}`);
55
84
  }
56
85
  if (spec.choices && !spec.choices.includes(parsed)) {
57
- throw new UsageError(`--${name}은 ${spec.choices.join(', ')} 중 하나여야 합니다.`);
86
+ const suggestion = closest(String(parsed), spec.choices.map(String));
87
+ throw new UsageError(`--${name} must be one of ${spec.choices.join(', ')}: ${value}`,
88
+ { hint: suggestion ? `Did you mean --${name} ${suggestion}?` : null });
58
89
  }
59
90
  if (spec.multiple) (options[name] ??= []).push(parsed);
60
91
  else options[name] = parsed;
@@ -67,6 +98,6 @@ export function parseArguments(argv, definitions) {
67
98
 
68
99
  export function keyValue(value, label) {
69
100
  const separator = String(value).indexOf('=');
70
- if (separator < 1) throw new UsageError(`${label}은 key=value 형식이어야 합니다: ${value}`);
101
+ if (separator < 1) throw new UsageError(`${label} must be key=value: ${value}`);
71
102
  return [value.slice(0, separator), value.slice(separator + 1)];
72
103
  }
package/src/client.mjs CHANGED
@@ -21,7 +21,7 @@ export function createClient({ server, token = null, fetch: fetchImplementation
21
21
  }
22
22
  const headers = { accept: 'application/json', 'user-agent': userAgent };
23
23
  if (auth) {
24
- if (!token) throw new ApiError('로그인이 필요합니다. sentinelctl login을 먼저 실행하세요.', { status: 401, code: 'not_logged_in' });
24
+ if (!token) throw new ApiError('Not logged in.', { status: 401, code: 'not_logged_in' });
25
25
  headers.authorization = `Bearer ${token}`;
26
26
  }
27
27
  if (body !== undefined) headers['content-type'] = 'application/json';
@@ -33,8 +33,8 @@ export function createClient({ server, token = null, fetch: fetchImplementation
33
33
  redirect: 'error', signal: AbortSignal.timeout(timeoutMs),
34
34
  });
35
35
  } catch (error) {
36
- const reason = error?.name === 'TimeoutError' ? '응답 시간이 초과되었습니다' : '연결하지 못했습니다';
37
- throw new ApiError(`${url.origin}에 ${reason}.`, { code: 'network_error' });
36
+ const reason = error?.name === 'TimeoutError' ? 'timed out' : 'could not connect';
37
+ throw new ApiError(`Request to ${url.origin} ${reason}.`, { code: 'network_error' });
38
38
  }
39
39
  const text = await response.text();
40
40
  let payload = {};
@@ -44,16 +44,12 @@ export function createClient({ server, token = null, fetch: fetchImplementation
44
44
  payload = { error: text.slice(0, 200) };
45
45
  }
46
46
  const retryAfterSeconds = Number(response.headers.get('retry-after')) || null;
47
- // 429는 서버가 알려 준 시간만큼 기다렸다가 GET만 다시 시도한다. 변경 요청은 중복 실행을 막기 위해 재시도하지 않는다.
48
47
  if (response.status === 429 && method === 'GET' && attempt < maxRetries) {
49
48
  await sleep(Math.min(retryAfterSeconds ?? 1, 30) * 1_000);
50
49
  continue;
51
50
  }
52
51
  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 });
52
+ throw new ApiError(payload.error || `HTTP ${response.status}`, { status: response.status, code: payload.code, retryAfterSeconds, body: payload });
57
53
  }
58
54
  }
59
55
  return {