@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 +67 -43
- package/package.json +10 -3
- package/src/args.mjs +42 -11
- package/src/client.mjs +4 -8
- package/src/commands.mjs +603 -247
- package/src/credentials.mjs +7 -8
- package/src/format.mjs +4 -6
- package/src/login.mjs +1 -2
- package/src/main.mjs +291 -67
package/README.md
CHANGED
|
@@ -1,91 +1,115 @@
|
|
|
1
1
|
# sentinelctl
|
|
2
2
|
|
|
3
|
-
Sentinel Fleet Console(`https://monitor.intflow.dev`)
|
|
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
|
-
-
|
|
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.
|
|
21
|
-
2.
|
|
22
|
-
3. CLI
|
|
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 #
|
|
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
|
-
|
|
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
|
|
36
|
-
sentinelctl logs search -a agent-
|
|
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 #
|
|
39
|
-
sentinelctl logs search -q boom -o ndjson | jq .message
|
|
40
|
-
sentinelctl logs context -a agent-
|
|
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
|
-
|
|
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
|
|
52
|
-
sentinelctl devices show agent-
|
|
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-
|
|
57
|
-
sentinelctl metadata history agent-
|
|
58
|
-
sentinelctl metadata set agent-
|
|
59
|
-
sentinelctl metadata set agent-
|
|
60
|
-
sentinelctl metadata
|
|
61
|
-
sentinelctl metadata
|
|
62
|
-
sentinelctl metadata
|
|
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
|
|
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 #
|
|
73
|
-
sentinelctl admin disable kim@intflow.ai #
|
|
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
|
|
95
|
+
sentinelctl admin revoke <session-id>
|
|
96
|
+
sentinelctl admin audit --cleanup
|
|
97
|
+
sentinelctl admin operations -r 7d
|
|
76
98
|
```
|
|
77
99
|
|
|
78
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
9
|
-
|
|
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
|
|
64
|
+
if (!name) throw unknown(argument);
|
|
38
65
|
}
|
|
39
66
|
const spec = definitions[name];
|
|
40
|
-
if (!spec) throw
|
|
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)
|
|
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
|
-
|
|
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}
|
|
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('
|
|
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(
|
|
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
|
-
|
|
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 {
|