@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/src/credentials.mjs
CHANGED
|
@@ -16,14 +16,14 @@ export function normalizeServer(value) {
|
|
|
16
16
|
try {
|
|
17
17
|
url = new URL(String(value ?? ''));
|
|
18
18
|
} catch {
|
|
19
|
-
throw new Error(
|
|
19
|
+
throw new Error(`Invalid server URL: ${value}`);
|
|
20
20
|
}
|
|
21
21
|
const loopback = ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname);
|
|
22
22
|
if (url.username || url.password || url.search || url.hash || (url.pathname !== '/' && url.pathname !== '')) {
|
|
23
|
-
throw new Error('
|
|
23
|
+
throw new Error('The server must be an origin without a path, e.g. https://monitor.intflow.dev');
|
|
24
24
|
}
|
|
25
25
|
if (url.protocol !== 'https:' && !(url.protocol === 'http:' && loopback)) {
|
|
26
|
-
throw new Error('
|
|
26
|
+
throw new Error('The server must use HTTPS (plain HTTP is allowed only for localhost).');
|
|
27
27
|
}
|
|
28
28
|
return url.origin;
|
|
29
29
|
}
|
|
@@ -32,16 +32,15 @@ export async function readCredentials(path) {
|
|
|
32
32
|
try {
|
|
33
33
|
const parsed = JSON.parse(await readFile(path, 'utf8'));
|
|
34
34
|
if (parsed?.version !== 1 || typeof parsed.profiles !== 'object' || parsed.profiles === null) {
|
|
35
|
-
throw new Error('
|
|
35
|
+
throw new Error('format');
|
|
36
36
|
}
|
|
37
37
|
return parsed;
|
|
38
38
|
} catch (error) {
|
|
39
39
|
if (error.code === 'ENOENT') return { version: 1, profiles: {} };
|
|
40
|
-
throw new Error(
|
|
40
|
+
throw new Error(`Cannot read the credentials file: ${path}`);
|
|
41
41
|
}
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
// 토큰 파일은 소유자만 읽을 수 있게 0600으로 쓰고, 임시 파일을 거쳐 원자적으로 교체한다.
|
|
45
44
|
export async function writeCredentials(path, credentials) {
|
|
46
45
|
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
47
46
|
const temporary = `${path}.${process.pid}.tmp`;
|
|
@@ -65,12 +64,12 @@ export async function removeProfile(path, profileName) {
|
|
|
65
64
|
return true;
|
|
66
65
|
}
|
|
67
66
|
|
|
68
|
-
// 우선순위: 명령행 --server > SENTINEL_URL > 프로필 > 기본값. 토큰은 SENTINEL_TOKEN > 프로필.
|
|
69
67
|
export async function resolveConnection({ server, profile = 'default', environment = process.env, path = credentialsPath(environment) }) {
|
|
70
68
|
const credentials = await readCredentials(path);
|
|
71
69
|
const saved = credentials.profiles[profile] ?? null;
|
|
72
70
|
const resolvedServer = normalizeServer(server ?? environment.SENTINEL_URL ?? saved?.server ?? defaultServer);
|
|
71
|
+
const fromEnvironment = Boolean(environment.SENTINEL_TOKEN);
|
|
73
72
|
const token = environment.SENTINEL_TOKEN
|
|
74
73
|
?? (saved && normalizeServer(saved.server) === resolvedServer ? saved.token : null);
|
|
75
|
-
return { server: resolvedServer, token: token ?? null, profile: saved, profileName: profile, path };
|
|
74
|
+
return { server: resolvedServer, token: token ?? null, profile: saved, profileName: profile, path, fromEnvironment };
|
|
76
75
|
}
|
package/src/format.mjs
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
// 한글·CJK 전각 문자는 터미널에서 두 칸을 차지한다.
|
|
2
1
|
const wideCharacter = /[ᄀ-ᅟ⺀-가-힣豈-︰-﹏-⦆¢-₩]/u;
|
|
3
2
|
|
|
4
3
|
export function displayWidth(value) {
|
|
@@ -30,7 +29,6 @@ export function singleLine(value) {
|
|
|
30
29
|
return String(value ?? '').replace(/[\r\n\t]+/g, ' ').replace(/[\u0000-\u001f\u007f]/g, '');
|
|
31
30
|
}
|
|
32
31
|
|
|
33
|
-
// columns: [{ header, value(row), max? }]. 마지막 열은 남은 터미널 폭에 맞춰 자른다.
|
|
34
32
|
export function renderTable(rows, columns, { width = 120, wide = false } = {}) {
|
|
35
33
|
const cells = rows.map((row) => columns.map((column) => singleLine(column.value(row) ?? '-')));
|
|
36
34
|
const widths = columns.map((column, index) => Math.min(column.max ?? Infinity,
|
|
@@ -66,8 +64,8 @@ export function percentText(value) {
|
|
|
66
64
|
|
|
67
65
|
export function ageText(seconds) {
|
|
68
66
|
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)}
|
|
67
|
+
if (seconds < 60) return `${Math.max(0, Math.round(seconds))}s ago`;
|
|
68
|
+
if (seconds < 3_600) return `${Math.round(seconds / 60)}m ago`;
|
|
69
|
+
if (seconds < 86_400) return `${Math.round(seconds / 3_600)}h ago`;
|
|
70
|
+
return `${Math.round(seconds / 86_400)}d ago`;
|
|
73
71
|
}
|
package/src/login.mjs
CHANGED
|
@@ -21,7 +21,6 @@ export function defaultClientName() {
|
|
|
21
21
|
return `sentinelctl@${hostname()}`.replace(/[\u0000-\u001f\u007f]/g, '').slice(0, 80);
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
// 서버가 발급하는 토큰은 브라우저 승인 뒤 폴링으로 한 번만 받는다. poll secret은 이 프로세스 밖으로 나가지 않는다.
|
|
25
24
|
export async function browserLogin(client, {
|
|
26
25
|
clientName = defaultClientName(), launchBrowser = true, notify, open = openBrowser,
|
|
27
26
|
sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), now = () => Date.now(),
|
|
@@ -50,5 +49,5 @@ export async function browserLogin(client, {
|
|
|
50
49
|
if (result.status === 202) continue;
|
|
51
50
|
return result.body;
|
|
52
51
|
}
|
|
53
|
-
throw new ApiError('
|
|
52
|
+
throw new ApiError('Browser approval timed out.', { code: 'expired_token' });
|
|
54
53
|
}
|
package/src/main.mjs
CHANGED
|
@@ -1,23 +1,31 @@
|
|
|
1
1
|
import { readFileSync } from 'node:fs';
|
|
2
|
-
import { parseArguments, UsageError } from './args.mjs';
|
|
2
|
+
import { closest, parseArguments, UsageError } from './args.mjs';
|
|
3
3
|
import { ApiError, createClient } from './client.mjs';
|
|
4
|
-
import {
|
|
5
|
-
import { credentialsPath, normalizeServer, removeProfile, resolveConnection, saveProfile } from './credentials.mjs';
|
|
4
|
+
import { catalog, commandPath, findCommand, groups, outputChoices, roleLabels, usageLine } from './commands.mjs';
|
|
5
|
+
import { credentialsPath, normalizeServer, readCredentials, removeProfile, resolveConnection, saveProfile, writeCredentials } from './credentials.mjs';
|
|
6
6
|
import { renderKeyValues } from './format.mjs';
|
|
7
7
|
import { browserLogin, openBrowser } from './login.mjs';
|
|
8
8
|
|
|
9
9
|
const version = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
|
|
10
|
-
const
|
|
10
|
+
const roleRank = { viewer: 0, editor: 1, admin: 2 };
|
|
11
|
+
const exitCodes = { 0: 'success', 1: 'server or network error', 2: 'usage error (fix the arguments)', 3: 'not logged in or insufficient role (retrying will not help)' };
|
|
11
12
|
const globalDefinitions = {
|
|
12
|
-
server: { type: 'string' },
|
|
13
|
-
profile: { type: 'string' },
|
|
14
|
-
output: { alias: 'o', type: 'string' },
|
|
15
|
-
json: { type: 'boolean' },
|
|
16
|
-
|
|
17
|
-
|
|
13
|
+
server: { type: 'string', placeholder: 'url', description: 'Console URL (default https://monitor.intflow.dev; also SENTINEL_URL).' },
|
|
14
|
+
profile: { type: 'string', placeholder: 'name', description: 'Saved login profile (default "default").' },
|
|
15
|
+
output: { alias: 'o', type: 'string', choices: outputChoices, description: 'Output format. Use json or ndjson from agents and scripts.' },
|
|
16
|
+
json: { type: 'boolean', description: 'Same as -o json.' },
|
|
17
|
+
quiet: { type: 'boolean', description: 'Suppress summaries and hints on stderr (errors are still printed).' },
|
|
18
|
+
help: { alias: 'h', type: 'boolean', description: 'Show help.' },
|
|
19
|
+
version: { alias: 'V', type: 'boolean', description: 'Show the version.' },
|
|
20
|
+
};
|
|
21
|
+
const specialCommands = {
|
|
22
|
+
login: { summary: 'Approve in the browser with your intflow.ai Google Workspace account and store a 30-day CLI token', usage: 'sentinelctl login [--no-browser] [--name <client-name>]' },
|
|
23
|
+
logout: { summary: 'Revoke the CLI token on the server and delete local credentials', usage: 'sentinelctl logout' },
|
|
24
|
+
whoami: { summary: 'Show the server, user, role and token expiry', usage: 'sentinelctl whoami' },
|
|
25
|
+
commands: { summary: 'List every command; with --json, a machine-readable catalog with options, roles and examples', usage: 'sentinelctl commands [--json]' },
|
|
26
|
+
help: { summary: 'Help: help <command>, help agents (guide for AI agents)', usage: 'sentinelctl help [<command> | agents]' },
|
|
18
27
|
};
|
|
19
28
|
|
|
20
|
-
// 전역 옵션은 명령 앞뒤 어디에 와도 되게 먼저 떼어 낸다.
|
|
21
29
|
export function splitGlobalOptions(argv) {
|
|
22
30
|
const global = [];
|
|
23
31
|
const rest = [];
|
|
@@ -40,29 +48,129 @@ export function splitGlobalOptions(argv) {
|
|
|
40
48
|
return { global: parseArguments(global, globalDefinitions).options, rest };
|
|
41
49
|
}
|
|
42
50
|
|
|
43
|
-
function
|
|
51
|
+
function roleOf(command, options) {
|
|
52
|
+
return command.roleFor?.(options) ?? command.role;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function optionLines(options) {
|
|
56
|
+
return Object.entries(options).map(([name, spec]) => {
|
|
57
|
+
const value = spec.type === 'boolean' ? '' : ` <${spec.choices ? spec.choices.join('|') : spec.placeholder ?? 'value'}>`;
|
|
58
|
+
const flag = `${spec.alias ? `-${spec.alias}, ` : ' '}--${name}${value}`;
|
|
59
|
+
const suffix = [spec.multiple ? 'repeatable' : null, spec.default !== undefined ? `default ${spec.default}` : null].filter(Boolean).join(', ');
|
|
60
|
+
return ` ${flag}\n ${spec.description ?? ''}${suffix ? ` (${suffix})` : ''}`;
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function commandHelp(command) {
|
|
65
|
+
const lines = [
|
|
66
|
+
`Usage: ${usageLine(command)}`,
|
|
67
|
+
'',
|
|
68
|
+
command.summary + (command.description ? `\n${command.description}` : ''),
|
|
69
|
+
'',
|
|
70
|
+
`Requires: ${roleLabels[command.role]}${command.roleNote ? ` (${command.roleNote})` : ''} · ${command.mutates ? 'changes data' : 'read-only'}`,
|
|
71
|
+
];
|
|
72
|
+
if (command.args?.length) {
|
|
73
|
+
lines.push('', 'Arguments:', ...command.args.map((arg) => ` ${arg.optional ? `[${arg.name}]` : `<${arg.name}>`} ${arg.description ?? ''}`));
|
|
74
|
+
}
|
|
75
|
+
if (Object.keys(command.options).length) lines.push('', 'Options:', ...optionLines(command.options));
|
|
76
|
+
lines.push('', 'Examples:', ...command.examples.map((example) => ` ${example}`));
|
|
77
|
+
lines.push('', `Output: ${command.output}`);
|
|
78
|
+
lines.push('', 'Global: -o table|json|ndjson, --server <url>, --profile <name>, --quiet.',
|
|
79
|
+
`Exit codes: ${Object.entries(exitCodes).map(([code, meaning]) => `${code} ${meaning}`).join(' · ')}.`);
|
|
80
|
+
return lines.join('\n');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function groupHelp(group) {
|
|
84
|
+
const members = catalog.filter((command) => command.group === group);
|
|
85
|
+
return [`Usage: sentinelctl ${group} <command> [options]`, '', groups[group], '',
|
|
86
|
+
...members.map((command) => ` ${command.name.padEnd(10)} ${command.summary}${command.role === 'viewer' ? '' : ` [${command.role}]`}`),
|
|
87
|
+
'', `Details: sentinelctl ${group} <command> --help`].join('\n');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function overviewHelp() {
|
|
44
91
|
const lines = [
|
|
45
|
-
`sentinelctl ${version} — Sentinel Fleet Console
|
|
92
|
+
`sentinelctl ${version} — Sentinel Fleet Console CLI: device metrics, logs and metadata`,
|
|
46
93
|
'',
|
|
47
|
-
'
|
|
94
|
+
'Usage: sentinelctl [global options] <command> [subcommand] [arguments] [options]',
|
|
48
95
|
'',
|
|
49
|
-
'
|
|
50
|
-
' login
|
|
51
|
-
'
|
|
52
|
-
'
|
|
96
|
+
'Getting started:',
|
|
97
|
+
' sentinelctl login approve once in the browser (valid for 30 days)',
|
|
98
|
+
' sentinelctl devices list --status offline',
|
|
99
|
+
' sentinelctl logs search -l error -r 1h',
|
|
53
100
|
'',
|
|
54
|
-
'
|
|
101
|
+
'Authentication:',
|
|
102
|
+
...['login', 'logout', 'whoami'].map((name) => ` ${name.padEnd(10)} ${specialCommands[name].summary}`),
|
|
103
|
+
'',
|
|
104
|
+
'Commands:',
|
|
55
105
|
];
|
|
56
|
-
for (const [group,
|
|
57
|
-
lines.push(` ${group.padEnd(10)} ${
|
|
58
|
-
for (const command of
|
|
106
|
+
for (const [group, summary] of Object.entries(groups)) {
|
|
107
|
+
lines.push(` ${group.padEnd(10)} ${summary}`);
|
|
108
|
+
for (const command of catalog.filter((item) => item.group === group)) {
|
|
109
|
+
lines.push(` ${command.name.padEnd(10)} ${command.summary}${command.role === 'viewer' ? '' : ` [${command.role}]`}`);
|
|
110
|
+
}
|
|
59
111
|
}
|
|
60
|
-
for (const
|
|
61
|
-
lines.push(''
|
|
62
|
-
|
|
112
|
+
for (const command of catalog.filter((item) => !item.group)) lines.push(` ${command.name.padEnd(10)} ${command.summary}`);
|
|
113
|
+
lines.push(` ${'commands'.padEnd(10)} ${specialCommands.commands.summary}`);
|
|
114
|
+
lines.push('', 'Global options:', ...optionLines(globalDefinitions));
|
|
115
|
+
lines.push('', 'More: sentinelctl <command> <subcommand> --help · Guide for AI agents: sentinelctl help agents',
|
|
116
|
+
`Environment: SENTINEL_URL (server), SENTINEL_TOKEN (token for automation), SENTINELCTL_CONFIG (credentials file, default ${credentialsPath()})`);
|
|
63
117
|
return lines.join('\n');
|
|
64
118
|
}
|
|
65
119
|
|
|
120
|
+
const agentGuide = `Using sentinelctl from AI agents and scripts
|
|
121
|
+
|
|
122
|
+
1. A human logs in once: \`sentinelctl login\` and approve in the browser. The token lasts 30 days;
|
|
123
|
+
every later command is non-interactive. The token carries the logged-in user's role, so log in
|
|
124
|
+
with a viewer account for agents that should only read. Elsewhere, pass SENTINEL_TOKEN and
|
|
125
|
+
SENTINEL_URL as environment variables.
|
|
126
|
+
2. Use -o json (one document) or -o ndjson (one item per line). stdout carries data only; summaries,
|
|
127
|
+
hints and errors go to stderr. --quiet drops summaries and hints. With json/ndjson output an error
|
|
128
|
+
is one stderr line: {"error":{"status","code","message","hint","exitCode"}}.
|
|
129
|
+
3. Exit codes: 0 success · 1 server/network error · 2 usage error (fix the arguments and retry)
|
|
130
|
+
· 3 not logged in or insufficient role (do not retry). GET requests that hit 429 are retried
|
|
131
|
+
automatically after Retry-After.
|
|
132
|
+
4. \`sentinelctl commands --json\` returns every command with its options, choices, required role,
|
|
133
|
+
whether it changes data, examples and output fields.
|
|
134
|
+
5. Typical investigation
|
|
135
|
+
- find a device: devices lookup <part of name> -> agentId
|
|
136
|
+
- status: devices list --status offline -o json | devices overview <id> -o json
|
|
137
|
+
- metrics: devices show <id> -r 24h -o json (series.cpu etc. are [epochSeconds, value])
|
|
138
|
+
- logs: logs search -a <id> -l error -r 24h -o ndjson
|
|
139
|
+
then logs context -a <id> -s <source> -t <timestamp> for surrounding lines
|
|
140
|
+
- counts/ranking: logs stats -l error -r 24h --by agent|source|message -o json
|
|
141
|
+
- trend: logs histogram -a <id> -r 7d -o json
|
|
142
|
+
6. Efficiency
|
|
143
|
+
- To answer "how many" or "which devices/sources the most", use logs stats: one server-side
|
|
144
|
+
aggregation with exact counts. Do not download thousands of logs to count them.
|
|
145
|
+
- Narrow the window and filters (-a, -s, -l, -q) first. Use --pages N (max 40) to take only what you
|
|
146
|
+
need; --all stops at 10,000 logs.
|
|
147
|
+
- Each user can run 2 log queries at a time (web and CLI combined); run commands one after another.
|
|
148
|
+
- Do not use --follow (it never ends). Repeat a -r 15m search instead.
|
|
149
|
+
- The server caches identical log searches for 5s, histograms and sources for 60s.
|
|
150
|
+
7. For commands that change data (metadata set/bulk/rollback/import, admin ...), run with --dry-run
|
|
151
|
+
first when available, check the result, then apply.
|
|
152
|
+
8. Server error messages are in Korean (shared with the web console); the "hint" field is English.`;
|
|
153
|
+
|
|
154
|
+
function commandsCatalog() {
|
|
155
|
+
return {
|
|
156
|
+
version,
|
|
157
|
+
globalOptions: globalDefinitions,
|
|
158
|
+
exitCodes,
|
|
159
|
+
commands: [
|
|
160
|
+
...Object.entries(specialCommands).map(([name, command]) => ({ command: name, usage: command.usage, summary: command.summary, role: null, mutates: name === 'logout' })),
|
|
161
|
+
...catalog.map((command) => ({
|
|
162
|
+
command: commandPath(command), usage: usageLine(command), summary: command.summary, description: command.description ?? null,
|
|
163
|
+
role: command.role, roleNote: command.roleNote ?? null, mutates: command.mutates, args: command.args ?? [],
|
|
164
|
+
options: Object.fromEntries(Object.entries(command.options).map(([name, spec]) => [name, {
|
|
165
|
+
alias: spec.alias ?? null, type: spec.type, choices: spec.choices ?? null, default: spec.default ?? null,
|
|
166
|
+
multiple: Boolean(spec.multiple), description: spec.description ?? null,
|
|
167
|
+
}])),
|
|
168
|
+
examples: command.examples, output: command.output,
|
|
169
|
+
})),
|
|
170
|
+
],
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
66
174
|
async function login(context, argv) {
|
|
67
175
|
const { options } = parseArguments(argv, { 'no-browser': { type: 'boolean' }, name: { type: 'string' } });
|
|
68
176
|
const client = createClient({ server: context.connection.server, fetch: context.fetch });
|
|
@@ -72,16 +180,17 @@ async function login(context, argv) {
|
|
|
72
180
|
open: context.openBrowser,
|
|
73
181
|
sleep: context.sleep,
|
|
74
182
|
notify: ({ verificationUrl, userCode, expiresIn }) => {
|
|
75
|
-
context.err('
|
|
183
|
+
context.err('Open this URL, sign in with your Intflow account and approve:');
|
|
76
184
|
context.err(` ${verificationUrl}`);
|
|
77
|
-
context.err(
|
|
185
|
+
context.err(`Confirmation code: ${userCode} (check the browser shows the same code; approve within ${Math.round(expiresIn / 60)} minutes)`);
|
|
78
186
|
},
|
|
79
187
|
});
|
|
80
188
|
await saveProfile(context.connection.path, context.global.profile ?? 'default', {
|
|
81
189
|
server: context.connection.server, token: result.token, tokenId: result.tokenId,
|
|
82
190
|
user: result.identity.user, role: result.identity.role, expiresAt: result.expiresAt,
|
|
83
191
|
});
|
|
84
|
-
context.out(
|
|
192
|
+
context.out(`Logged in as ${result.identity.user} (${roleLabels[result.identity.role] ?? result.identity.role}). Token expires ${result.expiresAt}.`);
|
|
193
|
+
context.hint('Next: sentinelctl devices list, or sentinelctl logs search -l error -r 1h (all commands: sentinelctl --help).');
|
|
85
194
|
}
|
|
86
195
|
|
|
87
196
|
async function logout(context) {
|
|
@@ -91,87 +200,202 @@ async function logout(context) {
|
|
|
91
200
|
await context.client.post('/api/logout', {});
|
|
92
201
|
} catch (error) {
|
|
93
202
|
if (!(error instanceof ApiError) || error.status !== 401) {
|
|
94
|
-
context.err(
|
|
203
|
+
context.err(`Could not revoke the token on the server (${error.message}). Local credentials are deleted anyway.`);
|
|
95
204
|
}
|
|
96
205
|
}
|
|
97
206
|
}
|
|
98
207
|
const removed = await removeProfile(context.connection.path, profileName);
|
|
99
|
-
context.out(removed ? '
|
|
208
|
+
context.out(removed ? 'Logged out.' : 'No saved login to remove.');
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function rememberRole(context, role) {
|
|
212
|
+
const saved = context.connection.profile;
|
|
213
|
+
if (!saved || context.connection.fromEnvironment || saved.role === role) return;
|
|
214
|
+
const credentials = await readCredentials(context.connection.path);
|
|
215
|
+
const name = context.global.profile ?? 'default';
|
|
216
|
+
if (!credentials.profiles[name]) return;
|
|
217
|
+
credentials.profiles[name].role = role;
|
|
218
|
+
await writeCredentials(context.connection.path, credentials);
|
|
100
219
|
}
|
|
101
220
|
|
|
102
221
|
async function whoami(context) {
|
|
103
222
|
const identity = await context.client.get('/api/session');
|
|
223
|
+
await rememberRole(context, identity.role);
|
|
104
224
|
if (context.global.output === 'json' || context.global.output === 'ndjson') {
|
|
105
225
|
context.out(JSON.stringify({ server: context.connection.server, ...identity, tokenExpiresAt: context.connection.profile?.expiresAt ?? null }));
|
|
106
226
|
return;
|
|
107
227
|
}
|
|
108
228
|
context.out(renderKeyValues([
|
|
109
|
-
['
|
|
110
|
-
['
|
|
111
|
-
['
|
|
229
|
+
['Server', context.connection.server], ['User', identity.user], ['Name', identity.name],
|
|
230
|
+
['Role', roleLabels[identity.role] ?? identity.role], ['Token ID', identity.sessionId],
|
|
231
|
+
['Token expires', context.connection.profile?.expiresAt],
|
|
112
232
|
]));
|
|
113
233
|
}
|
|
114
234
|
|
|
235
|
+
class PermissionError extends Error {
|
|
236
|
+
constructor(required, current) {
|
|
237
|
+
super(`This command requires the ${required} role (you are ${current}).`);
|
|
238
|
+
this.required = required;
|
|
239
|
+
this.current = current;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async function ensureRole(context, required) {
|
|
244
|
+
if (!required || required === 'viewer' || !context.connection.token) return;
|
|
245
|
+
const cached = context.connection.fromEnvironment ? null : context.connection.profile?.role;
|
|
246
|
+
if (!cached || roleRank[cached] >= roleRank[required]) return;
|
|
247
|
+
const identity = await context.client.get('/api/session');
|
|
248
|
+
await rememberRole(context, identity.role);
|
|
249
|
+
if (roleRank[identity.role] < roleRank[required]) throw new PermissionError(required, identity.role);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function apiHint(error, context, command) {
|
|
253
|
+
if (error.code === 'network_error') return `Check the server (--server or SENTINEL_URL) and the network. Current server: ${context.connection.server}`;
|
|
254
|
+
if (error.code === 'not_logged_in') return 'Run `sentinelctl login` and approve in the browser.';
|
|
255
|
+
if (error.code === 'expired_token') return 'Run `sentinelctl login` again and approve within 5 minutes.';
|
|
256
|
+
if (error.status === 401) {
|
|
257
|
+
return context.connection.fromEnvironment
|
|
258
|
+
? 'SENTINEL_TOKEN has expired or was revoked; supply a new token.'
|
|
259
|
+
: 'The CLI token has expired or was revoked. Run `sentinelctl login` again.';
|
|
260
|
+
}
|
|
261
|
+
if (error.status === 403) {
|
|
262
|
+
const required = command ? roleOf(command, context.options ?? {}) : null;
|
|
263
|
+
return `Insufficient role${required && required !== 'viewer' ? ` (requires ${required})` : ''}. Ask an admin to run \`sentinelctl admin grant <your-email> --role <role>\`; check yours with \`sentinelctl whoami\`.`;
|
|
264
|
+
}
|
|
265
|
+
if (error.status === 404) {
|
|
266
|
+
return command?.group === 'devices' || command?.group === 'metadata'
|
|
267
|
+
? 'Check the agent ID with `sentinelctl devices lookup <part of the name>`.' : 'Not found; check the ID.';
|
|
268
|
+
}
|
|
269
|
+
if (error.status === 409) return 'Someone saved a change first. Run the command again to apply on top of the latest revision.';
|
|
270
|
+
if (error.status === 429) return `Too many requests${error.retryAfterSeconds ? `; wait ${error.retryAfterSeconds}s` : ''} and retry. Narrower filters reduce load.`;
|
|
271
|
+
if ([400, 413, 415, 422].includes(error.status)) {
|
|
272
|
+
return command ? `Check the input: sentinelctl ${commandPath(command)} --help` : 'Check the input.';
|
|
273
|
+
}
|
|
274
|
+
if (error.status >= 500) return 'Server or backend error. Check `sentinelctl health` and retry shortly.';
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function unknownCommandError(name, candidates, prefix = '') {
|
|
279
|
+
const suggestion = closest(name, candidates);
|
|
280
|
+
return new UsageError(`Unknown command: ${prefix}${name}`,
|
|
281
|
+
{ hint: `${suggestion ? `Did you mean \`sentinelctl ${prefix}${suggestion}\`? ` : ''}All commands: sentinelctl --help` });
|
|
282
|
+
}
|
|
283
|
+
|
|
115
284
|
export async function main(argv, {
|
|
116
285
|
stdout = process.stdout, stderr = process.stderr, environment = process.env, fetch = globalThis.fetch,
|
|
117
286
|
sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), openBrowser: open = openBrowser, signal,
|
|
118
287
|
} = {}) {
|
|
119
288
|
const out = (text) => stdout.write(`${text}\n`);
|
|
120
289
|
const err = (text) => stderr.write(`${text}\n`);
|
|
290
|
+
let global = { output: 'table' };
|
|
291
|
+
let command = null;
|
|
292
|
+
let context = null;
|
|
293
|
+
const report = (error, hint, exitCode) => {
|
|
294
|
+
if (global.output === 'json' || global.output === 'ndjson') {
|
|
295
|
+
err(JSON.stringify({ error: { status: error.status ?? null, code: error.code ?? error.name ?? null, message: error.message, hint: hint ?? null, exitCode } }));
|
|
296
|
+
} else {
|
|
297
|
+
err(`${error instanceof UsageError ? 'usage error' : 'error'}: ${error.message}`);
|
|
298
|
+
if (hint) err(`hint: ${hint}`);
|
|
299
|
+
}
|
|
300
|
+
return exitCode;
|
|
301
|
+
};
|
|
121
302
|
try {
|
|
122
|
-
const
|
|
303
|
+
const split = splitGlobalOptions(argv);
|
|
304
|
+
global = split.global;
|
|
305
|
+
global.output = global.json ? 'json' : (global.output ?? 'table');
|
|
123
306
|
if (global.version) {
|
|
124
307
|
out(version);
|
|
125
308
|
return 0;
|
|
126
309
|
}
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
return name || global.help || name === 'help' ? 0 : 2;
|
|
310
|
+
const [name, subcommand, ...commandArguments] = split.rest;
|
|
311
|
+
const wantsHelp = global.help;
|
|
312
|
+
if (!name) {
|
|
313
|
+
out(overviewHelp());
|
|
314
|
+
return wantsHelp ? 0 : 2;
|
|
133
315
|
}
|
|
134
|
-
if (
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
316
|
+
if (name === 'help') {
|
|
317
|
+
if (!subcommand) out(overviewHelp());
|
|
318
|
+
else if (subcommand === 'agents') out(agentGuide);
|
|
319
|
+
else if (specialCommands[subcommand]) out(`Usage: ${specialCommands[subcommand].usage}\n\n${specialCommands[subcommand].summary}`);
|
|
320
|
+
else if (groups[subcommand] && !commandArguments[0]) out(groupHelp(subcommand));
|
|
321
|
+
else {
|
|
322
|
+
const target = groups[subcommand] ? findCommand(subcommand, commandArguments[0]) : findCommand(null, subcommand);
|
|
323
|
+
if (!target) {
|
|
324
|
+
throw unknownCommandError([subcommand, commandArguments[0]].filter(Boolean).join(' '),
|
|
325
|
+
[...Object.keys(groups), ...Object.keys(specialCommands), ...catalog.filter((item) => !item.group).map((item) => item.name), 'agents'], 'help ');
|
|
326
|
+
}
|
|
327
|
+
out(commandHelp(target));
|
|
328
|
+
}
|
|
143
329
|
return 0;
|
|
144
330
|
}
|
|
145
|
-
if (name === '
|
|
146
|
-
|
|
331
|
+
if (name === 'commands') {
|
|
332
|
+
if (global.output === 'table') {
|
|
333
|
+
out(catalog.map((item) => `${commandPath(item).padEnd(20)} ${item.role.padEnd(7)} ${item.mutates ? 'write' : 'read '} ${item.summary}`).join('\n'));
|
|
334
|
+
} else {
|
|
335
|
+
out(JSON.stringify(commandsCatalog(), null, global.output === 'json' ? 2 : 0));
|
|
336
|
+
}
|
|
147
337
|
return 0;
|
|
148
338
|
}
|
|
149
|
-
if (name
|
|
150
|
-
|
|
339
|
+
if (specialCommands[name] && wantsHelp) {
|
|
340
|
+
out(`Usage: ${specialCommands[name].usage}\n\n${specialCommands[name].summary}`);
|
|
151
341
|
return 0;
|
|
152
342
|
}
|
|
153
|
-
|
|
154
|
-
|
|
343
|
+
|
|
344
|
+
if (groups[name]) {
|
|
345
|
+
command = findCommand(name, subcommand);
|
|
346
|
+
if (!command) {
|
|
347
|
+
if (!subcommand || wantsHelp) {
|
|
348
|
+
out(groupHelp(name));
|
|
349
|
+
return subcommand || wantsHelp ? 0 : 2;
|
|
350
|
+
}
|
|
351
|
+
throw unknownCommandError(subcommand, catalog.filter((item) => item.group === name).map((item) => item.name), `${name} `);
|
|
352
|
+
}
|
|
353
|
+
} else if (!specialCommands[name]) {
|
|
354
|
+
command = findCommand(null, name);
|
|
355
|
+
if (!command) throw unknownCommandError(name, [...Object.keys(groups), ...Object.keys(specialCommands), ...catalog.filter((item) => !item.group).map((item) => item.name)]);
|
|
356
|
+
}
|
|
357
|
+
const commandArgv = command?.group ? commandArguments : [subcommand, ...commandArguments].filter((item) => item !== undefined);
|
|
358
|
+
if (command && wantsHelp) {
|
|
359
|
+
out(commandHelp(command));
|
|
155
360
|
return 0;
|
|
156
361
|
}
|
|
157
|
-
|
|
158
|
-
if (
|
|
159
|
-
const
|
|
160
|
-
|
|
161
|
-
|
|
362
|
+
|
|
363
|
+
if (global.server) global.server = normalizeServer(global.server);
|
|
364
|
+
const connection = await resolveConnection({ server: global.server, profile: global.profile ?? 'default', environment,
|
|
365
|
+
path: credentialsPath(environment) });
|
|
366
|
+
context = {
|
|
367
|
+
global, connection, out, err, fetch, sleep, signal, openBrowser: open, width: stdout.columns ?? 120,
|
|
368
|
+
hint: (text) => { if (!global.quiet) err(`hint: ${text}`); },
|
|
369
|
+
note: (text) => { if (!global.quiet && global.output === 'table') err(text); },
|
|
370
|
+
client: createClient({ server: connection.server, token: connection.token, fetch, sleep }),
|
|
371
|
+
};
|
|
372
|
+
if (name === 'login') { await login(context, commandArgv); return 0; }
|
|
373
|
+
if (name === 'logout') { await logout(context); return 0; }
|
|
374
|
+
if (name === 'whoami') { await whoami(context); return 0; }
|
|
375
|
+
|
|
376
|
+
let parsed;
|
|
377
|
+
try {
|
|
378
|
+
parsed = parseArguments(commandArgv, command.options);
|
|
379
|
+
} catch (error) {
|
|
380
|
+
if (error instanceof UsageError) error.hint = [error.hint, `Usage: ${usageLine(command)} (details: --help)`].filter(Boolean).join(' · ');
|
|
381
|
+
throw error;
|
|
162
382
|
}
|
|
163
|
-
|
|
383
|
+
context.options = parsed.options;
|
|
384
|
+
await ensureRole(context, roleOf(command, parsed.options));
|
|
385
|
+
await command.run(context, parsed);
|
|
164
386
|
return 0;
|
|
165
387
|
} catch (error) {
|
|
166
388
|
if (error instanceof UsageError) {
|
|
167
|
-
|
|
168
|
-
|
|
389
|
+
return report(error, error.hint ?? (command ? `Usage: ${usageLine(command)} (details: --help)` : null), 2);
|
|
390
|
+
}
|
|
391
|
+
if (error instanceof PermissionError) {
|
|
392
|
+
return report(Object.assign(error, { status: 403, code: 'insufficient_role' }),
|
|
393
|
+
`Ask an admin to run \`sentinelctl admin grant <your-email> --role ${error.required}\`. Nothing was sent to the server.`, 3);
|
|
169
394
|
}
|
|
170
395
|
if (error instanceof ApiError) {
|
|
171
|
-
|
|
172
|
-
return error
|
|
396
|
+
const exitCode = error.status === 401 || error.status === 403 || error.code === 'not_logged_in' ? 3 : 1;
|
|
397
|
+
return report(error, apiHint(error, context ?? { connection: {} }, command), exitCode);
|
|
173
398
|
}
|
|
174
|
-
|
|
175
|
-
return 1;
|
|
399
|
+
return report(error, null, 1);
|
|
176
400
|
}
|
|
177
401
|
}
|