@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.
@@ -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(`서버 주소가 올바르지 않습니다: ${value}`);
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('서버 주소는 경로가 없는 origin이어야 합니다. 예: https://monitor.intflow.dev');
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('서버 주소는 HTTPS여야 합니다(localhost만 HTTP 허용).');
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(`자격 증명 파일을 읽을 수 없습니다: ${path}`);
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('브라우저 승인 시간이 지났습니다. sentinelctl login을 다시 실행하세요.', { code: 'expired_token' });
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 { commandTable, outputChoices, standaloneCommands } from './commands.mjs';
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 roleLabels = { viewer: '조회 전용', editor: '편집 가능', admin: '관리자' };
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
- help: { alias: 'h', type: 'boolean' },
17
- version: { alias: 'V', type: 'boolean' },
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 helpText() {
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
- '사용법: sentinelctl [--server URL] [--profile 이름] [-o table|json|ndjson] <명령> …',
94
+ 'Usage: sentinelctl [global options] <command> [subcommand] [arguments] [options]',
48
95
  '',
49
- '인증:',
50
- ' login [--no-browser] 브라우저에서 Intflow 계정으로 승인하고 CLI 토큰을 저장',
51
- ' logout 서버의 CLI 토큰을 폐기하고 로컬 자격 증명을 삭제',
52
- ' whoami 현재 사용자·권한 확인',
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, 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}`);
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 [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()}`);
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('아래 주소를 브라우저에서 열고 Intflow 계정으로 로그인한 뒤 승인하세요.');
183
+ context.err('Open this URL, sign in with your Intflow account and approve:');
76
184
  context.err(` ${verificationUrl}`);
77
- context.err(`확인 코드: ${userCode} (브라우저에 표시된 코드와 같은지 확인하세요 · ${Math.round(expiresIn / 60)}분 안에 승인)`);
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(`로그인했습니다: ${result.identity.user} (${roleLabels[result.identity.role] ?? result.identity.role}) · 토큰 만료 ${result.expiresAt}`);
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(`서버에서 토큰을 폐기하지 못했습니다: ${error.message}. 로컬 자격 증명은 삭제합니다.`);
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
- ['서버', context.connection.server], ['사용자', identity.user], ['이름', identity.name],
110
- ['권한', roleLabels[identity.role] ?? identity.role], ['토큰 ID', identity.sessionId],
111
- ['토큰 만료', context.connection.profile?.expiresAt],
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 { global, rest } = splitGlobalOptions(argv);
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
- 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;
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 (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));
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 === 'logout') {
146
- await logout(context);
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 === 'whoami') {
150
- await whoami(context);
339
+ if (specialCommands[name] && wantsHelp) {
340
+ out(`Usage: ${specialCommands[name].usage}\n\n${specialCommands[name].summary}`);
151
341
  return 0;
152
342
  }
153
- if (standaloneCommands[name]) {
154
- await standaloneCommands[name].run(context, [subcommand, ...commandArguments].filter((item) => item !== undefined));
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
- 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')}`);
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
- await command.run(context, commandArguments);
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
- err(`사용법 오류: ${error.message}`);
168
- return 2;
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
- err(`오류: ${error.message}`);
172
- return error.status === 401 || error.status === 403 ? 3 : 1;
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
- err(`오류: ${error.message}`);
175
- return 1;
399
+ return report(error, null, 1);
176
400
  }
177
401
  }