@intflow/sentinelctl 0.5.0 → 0.6.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
@@ -54,6 +54,8 @@ sentinelctl metrics top diskDaysLeft --below 14 # disks that fill up
54
54
  sentinelctl metrics top cpu --agg p95 -r 24h --compare --sort change
55
55
  sentinelctl metrics top reboots -r 7d --above 0 # many devices reboot daily on schedule; compare with p50
56
56
  sentinelctl metrics top networkTx -r 24h --label fleet=edge -o json
57
+ sentinelctl devices correlate <agent-id> --peak cpu -r 24h # metrics and logs around the CPU peak
58
+ sentinelctl devices correlate <agent-id> --at 2026-09-30T14:58:00+09:00 --window 30m
57
59
  ```
58
60
 
59
61
  `metrics top` runs one query over every device: `--agg avg|max|min|p95|last` per device over `-r 1h|6h|24h|7d|30d` (or `--from/--to`, up to 30 days), ranked, with min/p50/p90/p95/max across devices. Metrics: cpu, memory, disk, swap, load, temperature, processes, tcpConnections, networkRx, networkTx, reboots, diskDaysLeft. `--compare` adds the previous window of the same length. Device filters work like `devices list`; retired devices are excluded by default.
@@ -121,6 +123,10 @@ sentinelctl admin operations -r 7d
121
123
 
122
124
  Any other endpoint: `sentinelctl api "/api/devices?status=offline"`.
123
125
 
126
+ ## Updates
127
+
128
+ The server tells the CLI the latest version; when a newer one exists the CLI prints one notice a day on stderr (`{"notice":{"type":"update_available"}}` with -o json). Update with `npm install -g sentinelctl@latest`. A CLI older than the server minimum gets exit code 3 with the same command. `SENTINELCTL_NO_UPDATE_NOTICE=1` turns the notice off.
129
+
124
130
  ## Output and exit codes
125
131
 
126
132
  - `--select agentId,displayName,status,metadata.role` keeps only those fields of each item in json/ndjson output (plus top-level scalars), e.g. `devices list --all -o json` drops from ~700KB to ~20KB. `devices show --series none` (or `--series cpu,memory`) drops time series.
@@ -130,9 +136,10 @@ Any other endpoint: `sentinelctl api "/api/devices?status=offline"`.
130
136
 
131
137
  ## Publishing
132
138
 
133
- 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.
139
+ GitHub Actions publishes both packages (`.github/workflows/publish-cli.yml`) with npm trusted publishing, so no npm token is stored.
134
140
 
135
- ```bash
136
- cd cli && npm test && npm publish --access public
137
- cd unscoped && npm publish --access public
138
- ```
141
+ 1. Bump `cli/package.json`, `cli/unscoped/package.json` (version and its `@intflow/sentinelctl` dependency) and `cliLatestVersion` in `config/dashboard.json` to the same version; `node scripts/check-cli-release.mjs` checks this.
142
+ 2. Commit, then push a tag: `git tag cli-v0.6.0 && git push origin cli-v0.6.0`.
143
+ 3. The workflow runs the CLI checks and tests, publishes `@intflow/sentinelctl`, waits for the registry, then publishes the `sentinelctl` alias. Versions already on npm are skipped. Run it manually (Actions → publish-cli) to verify and pack without publishing.
144
+
145
+ One-time setup on npmjs.com, for each of the two packages: Settings → Trusted Publisher → GitHub Actions, organization or user `yeon3724`, repository `sentinel-fleet-console`, workflow `publish-cli.yml`, environment `npm`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intflow/sentinelctl",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
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": {
package/src/client.mjs CHANGED
@@ -9,9 +9,7 @@ export class ApiError extends Error {
9
9
  }
10
10
  }
11
11
 
12
- const userAgent = 'sentinelctl';
13
-
14
- export function createClient({ server, token = null, fetch: fetchImplementation = globalThis.fetch, timeoutMs = 60_000, maxRetries = 5, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) }) {
12
+ export function createClient({ server, token = null, userAgent = 'sentinelctl', onVersions = null, fetch: fetchImplementation = globalThis.fetch, timeoutMs = 60_000, maxRetries = 5, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) }) {
15
13
  async function request(method, path, { query, body, auth = true, allowStatuses = [] } = {}) {
16
14
  const url = new URL(path, server);
17
15
  for (const [key, value] of Object.entries(query ?? {})) {
@@ -36,6 +34,8 @@ export function createClient({ server, token = null, fetch: fetchImplementation
36
34
  const reason = error?.name === 'TimeoutError' ? 'timed out' : 'could not connect';
37
35
  throw new ApiError(`Request to ${url.origin} ${reason}.`, { code: 'network_error' });
38
36
  }
37
+ const latest = response.headers.get('x-sentinelctl-latest');
38
+ if (onVersions && latest) onVersions({ latest, minimum: response.headers.get('x-sentinelctl-minimum') });
39
39
  const text = await response.text();
40
40
  let payload = {};
41
41
  try {
package/src/commands.mjs CHANGED
@@ -13,17 +13,17 @@ const maxPages = 40;
13
13
  const deviceSorts = ['attention', 'status', 'displayName', 'cpu', 'memory', 'disk', 'load', 'temperature', 'ageSeconds', 'role', 'site', 'group', 'incidentCount'];
14
14
 
15
15
  const logFilterOptions = {
16
- query: { alias: 'q', type: 'string', placeholder: 'text', description: 'Words or phrase in the message, case-insensitive by default. Positional words are appended.' },
17
- pattern: { alias: 'p', type: 'string', placeholder: 'pattern', description: 'Exact message pattern with numbers as <N>, copied from `logs stats --by message` or `logs groups`. Fast and precise.' },
18
- 'case-sensitive': { type: 'boolean', description: 'Match -q case-sensitively. Much faster on long windows, but misses other casings.' },
19
- agent: { alias: 'a', type: 'string', placeholder: 'id', description: 'Exact agent ID (find it with `devices lookup`).' },
20
- level: { alias: 'l', type: 'string', choices: logLevels, description: 'emergency/critical/error include more severe levels; warning, info (with notice) and debug match that level only.' },
21
- source: { alias: 's', type: 'string', placeholder: 'source', description: 'Exact source: systemd unit, container name, syslog identifier (see `logs sources`).' },
22
- service: { type: 'string', placeholder: 'name', description: 'Logical service label (`service` field) across all devices, e.g. payment-api. Logs without the label never match.' },
23
- env: { type: 'string', placeholder: 'name', description: 'Environment label (`env` field), e.g. prod.' },
24
- range: { alias: 'r', type: 'string', choices: logRanges, description: 'Relative window. Default 15m when --from/--to are absent.' },
25
- from: { type: 'string', placeholder: 'ISO', description: 'Absolute window start, e.g. 2026-09-30T00:00:00+09:00. Requires --to; up to the retention period (31 days by default).' },
26
- to: { type: 'string', placeholder: 'ISO', description: 'Absolute window end.' },
16
+ query: { alias: 'q', type: 'string', placeholder: 'text', description: 'Words in the message, case-insensitive. Positional words are appended.' },
17
+ pattern: { alias: 'p', type: 'string', placeholder: 'pattern', description: 'Exact pattern as printed by logs stats --by message or logs groups (<N> = number).' },
18
+ 'case-sensitive': { type: 'boolean', description: 'Case-sensitive -q: much faster, misses other casings.' },
19
+ agent: { alias: 'a', type: 'string', placeholder: 'id', description: 'Agent ID (see devices lookup).' },
20
+ level: { alias: 'l', type: 'string', choices: logLevels, description: 'emergency/critical/error include more severe levels; warning, info, debug are exact.' },
21
+ source: { alias: 's', type: 'string', placeholder: 'source', description: 'systemd unit, container or syslog identifier (see logs sources).' },
22
+ service: { type: 'string', placeholder: 'name', description: '`service` label, across devices.' },
23
+ env: { type: 'string', placeholder: 'name', description: '`env` label.' },
24
+ range: { alias: 'r', type: 'string', choices: logRanges, description: 'Relative window (default 15m).' },
25
+ from: { type: 'string', placeholder: 'ISO', description: 'Start, e.g. 2026-09-30T09:00:00+09:00 (with --to; up to 31 days).' },
26
+ to: { type: 'string', placeholder: 'ISO', description: 'End.' },
27
27
  };
28
28
 
29
29
  async function getComplete(context, path, query) {
@@ -552,6 +552,52 @@ async function devicesOverview(context, { options, positionals }) {
552
552
  ]));
553
553
  }
554
554
 
555
+ function durationSeconds(value, name) {
556
+ const match = /^(\d+)([smh])$/.exec(String(value));
557
+ if (!match) throw new UsageError(`--${name} must look like 90s, 15m or 2h: ${value}`);
558
+ return Number(match[1]) * { s: 1, m: 60, h: 3600 }[match[2]];
559
+ }
560
+
561
+ const correlateMetricNames = ['cpu', 'memory', 'disk', 'load', 'temperature', 'swap', 'processes', 'tcpConnections', 'networkRx', 'networkTx'];
562
+ const correlateUnits = { cpu: '%', memory: '%', disk: '%', swap: '%', temperature: '°C', networkRx: 'B/s', networkTx: 'B/s' };
563
+
564
+ async function devicesCorrelate(context, { options, positionals }) {
565
+ const agentId = requireAgentId(positionals);
566
+ if (!options.at === !options.peak) throw new UsageError('Give exactly one of --at <time> or --peak <metric>.', { hint: 'Example: sentinelctl devices correlate <agent-id> --peak cpu -r 24h' });
567
+ if (options.at && !Number.isFinite(Date.parse(options.at))) throw new UsageError(`Cannot parse --at: ${options.at}`);
568
+ const body = await context.client.get(`/api/devices/${encodeURIComponent(agentId)}/correlate`, {
569
+ at: options.at ? new Date(options.at).toISOString() : undefined, peak: options.peak, range: options.peak ? options.range : undefined,
570
+ window: durationSeconds(options.window, 'window'), level: options.level, top: options.top, series: options.series ? 1 : undefined,
571
+ });
572
+ const value = (name, number) => metricValueText(number, correlateUnits[name]);
573
+ emit(context, body, () => [
574
+ renderKeyValues([
575
+ ['Device', `${body.displayName} (${body.agentId})`],
576
+ ['At', `${localTime(body.at)}${body.peak ? ` · peak ${body.peak.metric} ${value(body.peak.metric, body.peak.value)} in ${body.peak.range}` : ''}`],
577
+ ['Window', `${localTime(body.window.start)} → ${localTime(body.window.end)} (${body.stepSeconds}s step)`],
578
+ ['Reboots', body.reboots.map(localTime).join(', ') || 'none'],
579
+ ['Logs', `${body.logs.total} (${Object.entries(body.logs.levels).map(([level, count]) => `${level} ${count}`).join(', ') || '-'})`],
580
+ ]),
581
+ '',
582
+ renderTable(Object.entries(body.metrics), [
583
+ { header: 'METRIC', value: ([name]) => name },
584
+ { header: 'AT', value: ([name, item]) => value(name, item.atValue) },
585
+ { header: 'MIN', value: ([name, item]) => value(name, item.min) },
586
+ { header: 'MAX', value: ([name, item]) => value(name, item.max) },
587
+ { header: 'BIGGEST STEP', value: ([name, item]) => (item.biggestStep ? `${item.biggestStep.delta > 0 ? '+' : ''}${value(name, item.biggestStep.delta)} at ${localTime(item.biggestStep.at).slice(11)}` : '-') },
588
+ ], { width: context.width }),
589
+ '',
590
+ body.logs.groups.length ? renderTable(body.logs.groups, [
591
+ { header: 'COUNT', value: (group) => String(group.count) },
592
+ { header: 'FIRST', value: (group) => localTime(group.first).slice(11) },
593
+ { header: 'SOURCE', value: (group) => group.source, max: 22 },
594
+ { header: 'PATTERN', value: (group) => group.pattern },
595
+ ], { width: context.width, wide: options.wide }) : 'No logs in the window.',
596
+ ].join('\n'));
597
+ if (!body.logs.complete) context.hint('Log counts are partial; run again to complete.');
598
+ if (body.logs.truncated) context.hint(`Showing ${body.logs.groups.length} of ${body.logs.groupCount} log groups. Raise --top or add -l.`);
599
+ }
600
+
555
601
  async function health(context) {
556
602
  const body = await context.client.get('/api/health');
557
603
  emit(context, body, () => [
@@ -821,22 +867,22 @@ async function rawApi(context, { options, positionals }) {
821
867
  const commands = [
822
868
  {
823
869
  group: 'logs', name: 'search', run: logsSearch, role: 'viewer', mutates: false,
824
- summary: 'Raw log lines by window, device, severity, source, text or exact pattern; --collapse for groups',
825
- description: 'Newest first. Default 15m window and 100 logs. When fetching several pages, 250 logs are fetched per request, up to 10,000 in total, which on busy fleets covers only minutes. Use it for a handful of example lines after narrowing with `logs stats`/`logs groups` and -p. --collapse returns every matching group instead of lines (same as `logs groups`).',
870
+ summary: 'Raw log lines, newest first',
871
+ description: 'Stops at 10,000 lines, which covers minutes of this fleet: read examples here after logs stats/groups, never count with it.',
826
872
  args: [{ name: 'text', optional: true, description: 'Same as -q (words are joined with spaces).' }],
827
873
  options: {
828
874
  ...logFilterOptions,
829
- limit: { alias: 'n', type: 'integer', choices: [50, 100, 250], description: 'Logs per page. Default 100 (250 when paging).' },
830
- pages: { type: 'integer', placeholder: 'N', description: `Follow the cursor for up to N pages (1-${maxPages}).` },
831
- all: { type: 'boolean', description: 'Fetch every page of the window, stopping at 10,000 logs.' },
832
- fields: { type: 'boolean', description: 'Include raw fields (admin only, audited, 50 logs per page).' },
833
- collapse: { type: 'boolean', description: 'Group by device, source and pattern over the whole window with counts, first/last time and the latest line (see logs groups).' },
834
- top: { type: 'integer', placeholder: 'N', description: 'With --collapse: groups to return (1-500, default 50).' },
835
- dedupe: { type: 'boolean', description: 'Fold lines repeated within the same second on the same device and source into one with a repeat count.' },
836
- since: { type: 'string', placeholder: 'ISO', description: 'Only logs after this timestamp (exclusive) up to now; pass the printed nextSince to poll without re-reading.' },
837
- follow: { alias: 'f', type: 'boolean', description: 'Keep printing new logs (for humans; never ends, agents should not use it).' },
838
- interval: { type: 'integer', default: 5, placeholder: 'seconds', description: 'Polling interval for --follow (min 5).' },
839
- wide: { alias: 'w', type: 'boolean', description: 'Do not truncate messages in table output.' },
875
+ limit: { alias: 'n', type: 'integer', choices: [50, 100, 250], description: 'Lines per page (default 100; 250 when paging).' },
876
+ pages: { type: 'integer', placeholder: 'N', description: `Pages to follow (1-${maxPages}).` },
877
+ all: { type: 'boolean', description: 'All pages, up to 10,000 lines.' },
878
+ fields: { type: 'boolean', description: 'Raw fields (admin, audited, 50 per page).' },
879
+ collapse: { type: 'boolean', description: 'Same as logs groups.' },
880
+ top: { type: 'integer', placeholder: 'N', description: 'Groups with --collapse (1-500, default 50).' },
881
+ dedupe: { type: 'boolean', description: 'Fold same-second repeats into one line with a count.' },
882
+ since: { type: 'string', placeholder: 'ISO', description: 'Only logs after this time; poll with the printed nextSince.' },
883
+ follow: { alias: 'f', type: 'boolean', description: 'Stream new logs (humans only; never ends).' },
884
+ interval: { type: 'integer', default: 5, placeholder: 'seconds', description: 'Seconds between --follow polls (min 5).' },
885
+ wide: { alias: 'w', type: 'boolean', description: 'Do not truncate.' },
840
886
  },
841
887
  roleFor: (options) => (options.fields ? 'admin' : 'viewer'),
842
888
  roleNote: '--fields requires admin',
@@ -849,21 +895,21 @@ const commands = [
849
895
  'sentinelctl logs search -a <agent-id> -s ssh.service -r 6h --all -o json',
850
896
  'sentinelctl logs search --from 2026-09-30T09:00:00+09:00 --to 2026-09-30T10:00:00+09:00 -l warning',
851
897
  ],
852
- output: 'logs[]: timestamp (UTC ISO), severity, priority, agentId, host, displayName, source, message (+fields, +repeat with --dedupe). -o json wraps them as {criteria, window, count, hasMore, nextSince?, logs}.',
898
+ output: 'logs[]: {timestamp, severity, agentId, displayName, source, message, repeat?, fields?}; json adds {window, count, hasMore, nextSince?}.',
853
899
  },
854
900
  {
855
901
  group: 'logs', name: 'stats', run: logsStats, role: 'viewer', mutates: false,
856
- summary: 'Count and rank logs by source, device, severity or message pattern; compare windows; trends',
857
- description: 'Aggregates in VictoriaLogs instead of downloading logs, so counts are exact over the whole window. message groups by pattern with numbers collapsed to <N>; pass a pattern to -p for exact follow-ups. Windows over one day are computed per day and finished days are cached, so repeating a long query is cheap; long requests continue automatically.',
902
+ summary: 'Exact counts by source, device, level, message pattern or service',
903
+ description: 'Counts every log in the window on the server. --compare shows what changed, --timeline when.',
858
904
  args: [{ name: 'text', optional: true, description: 'Same as -q.' }],
859
905
  options: {
860
906
  ...logFilterOptions,
861
- by: { alias: 'b', type: 'string', choices: statsDimensions, default: 'source', description: 'Group by source, agent (device), level, message pattern or service label.' },
862
- top: { type: 'integer', default: 20, placeholder: 'N', description: 'Groups to return (1-100).' },
863
- compare: { type: 'boolean', description: 'Also count the previous window of the same length: previousCount, change and isNew per group.' },
864
- timeline: { type: 'boolean', description: 'Add per-group counts over time (about 48 buckets): when a pattern started, stopped or spiked.' },
865
- sort: { type: 'string', choices: ['count', 'change'], description: 'Rank by count (default) or by increase over the previous window (needs --compare).' },
866
- wide: { alias: 'w', type: 'boolean', description: 'Do not truncate long keys in table output.' },
907
+ by: { alias: 'b', type: 'string', choices: statsDimensions, default: 'source', description: 'Grouping (agent = device).' },
908
+ top: { type: 'integer', default: 20, placeholder: 'N', description: 'Groups (1-100).' },
909
+ compare: { type: 'boolean', description: 'Add the previous window: previousCount, change, isNew.' },
910
+ timeline: { type: 'boolean', description: 'Add counts over time per group (~48 buckets).' },
911
+ sort: { type: 'string', choices: ['count', 'change'], description: 'count or change (needs --compare).' },
912
+ wide: { alias: 'w', type: 'boolean', description: 'Do not truncate.' },
867
913
  },
868
914
  examples: [
869
915
  'sentinelctl logs stats -l error -r 1h --by source',
@@ -873,36 +919,36 @@ const commands = [
873
919
  'sentinelctl logs stats -a <agent-id> -r 7d --by message',
874
920
  'sentinelctl logs stats "connection refused" -r 24h --by agent',
875
921
  ],
876
- output: '{criteria, window, total, devices, groupCount, truncated, approximate, complete, coverage{chunks, done}, groups[]: {key, count, devices (distinct agents), first?, last? (with --timeline, bucket precision), host?, displayName?, searchText?, previousCount?, change?, isNew?, timeline?: [[epochSeconds, count]]}, timelineStepSeconds?, previous?: {window, total, devices, exhaustive}, newGroupCount?, rising?[≤5], falling?[≤5] (largest increases and decreases across all groups, not only the returned top)}.',
922
+ output: '{total, devices, groupCount, truncated, complete, groups[]: {key, count, devices, displayName?, previousCount?, change?, isNew?, timeline?[[epoch, count]], first?, last?}, previous?, newGroupCount?, rising?, falling?}.',
877
923
  },
878
924
  {
879
925
  group: 'logs', name: 'groups', run: logsGroups, role: 'viewer', mutates: false,
880
- summary: 'Every distinct (device, source, message pattern) in the window with counts, first/last time and the latest line',
881
- description: 'The lossless way to see what happened: each group is counted over the whole window and keeps its latest raw line, so nothing is skipped the way a 10,000-line download skips. Typically 50-100x smaller than the raw logs. Windows over one day are computed per day and cached.',
926
+ summary: 'Every device × source × pattern with count, first/last time and latest line',
927
+ description: 'Lossless summary of the window, typically 50-100x smaller than the raw lines.',
882
928
  args: [{ name: 'text', optional: true, description: 'Same as -q.' }],
883
929
  options: {
884
930
  ...logFilterOptions,
885
- top: { type: 'integer', default: 50, placeholder: 'N', description: 'Groups to return, largest first (1-500).' },
886
- wide: { alias: 'w', type: 'boolean', description: 'Do not truncate patterns in table output.' },
931
+ top: { type: 'integer', default: 50, placeholder: 'N', description: 'Groups, largest first (1-500).' },
932
+ wide: { alias: 'w', type: 'boolean', description: 'Do not truncate.' },
887
933
  },
888
934
  examples: [
889
935
  'sentinelctl logs groups -l error -r 1h',
890
936
  'sentinelctl logs groups -a <agent-id> -r 7d -o json',
891
937
  'sentinelctl logs groups -p "Failed to start <N>.service" -r 24h',
892
938
  ],
893
- output: '{criteria, window, complete, coverage, total, devices, groupCount, patternCount, truncated, approximate, groups[]: {agentId, displayName, source, pattern, count, first, last, latest{timestamp, severity, message}}}.',
939
+ output: '{total, devices, groupCount, patternCount, truncated, complete, groups[]: {agentId, displayName, source, pattern, count, first, last, latest{timestamp, severity, message}}}.',
894
940
  },
895
941
  {
896
942
  group: 'logs', name: 'facets', run: logsFacets, role: 'viewer', mutates: false,
897
- summary: 'Top values of device, host, severity, source, agent version and profile in one query',
898
- description: 'Orientation for an unfamiliar window: which devices, sources and severities dominate. Fleet-wide up to 24h; up to 7 days with -a, -s or -p.',
943
+ summary: 'Top devices, severities, sources and labels in one query',
944
+ description: 'Fleet-wide up to 24h; up to 7 days with -a, -s, --service or -p.',
899
945
  options: {
900
946
  ...logFilterOptions,
901
947
  range: { alias: 'r', type: 'string', choices: logRanges, description: 'Window (default 1h).' },
902
948
  top: { type: 'integer', default: 10, placeholder: 'N', description: 'Values per field (1-50).' },
903
949
  },
904
950
  examples: ['sentinelctl logs facets -l error -r 24h', 'sentinelctl logs facets -a <agent-id> -r 7d -o json'],
905
- output: '{criteria, window, facets{agent_id[{value, count, displayName}], host[], severity[], _SYSTEMD_UNIT[], …}}.',
951
+ output: '{facets{agent_id[{value, count, displayName}], severity[], _SYSTEMD_UNIT[], service[], …}}.',
906
952
  },
907
953
  {
908
954
  group: 'logs', name: 'context', run: logsContext, role: 'viewer', mutates: false,
@@ -925,14 +971,14 @@ const commands = [
925
971
  args: [{ name: 'text', optional: true, description: 'Same as -q.' }],
926
972
  options: logFilterOptions,
927
973
  examples: ['sentinelctl logs histogram -r 24h', 'sentinelctl logs histogram -a <agent-id> -r 30d -o json'],
928
- output: '{start, end, complete, coverage, stepSeconds, total, buckets[]: {at (epoch seconds), total, errors}}. Finished days are cached.',
974
+ output: '{stepSeconds, total, complete, buckets[]: {at (epoch), total, errors}}.',
929
975
  },
930
976
  {
931
977
  group: 'logs', name: 'sources', run: logsSources, role: 'viewer', mutates: false,
932
978
  summary: 'Log sources (systemd units, containers) ranked by exact count',
933
979
  options: { range: { alias: 'r', type: 'string', choices: logRanges, default: '24h', description: 'Sample window.' } },
934
980
  examples: ['sentinelctl logs sources -r 1h'],
935
- output: '{range, total, complete, sources[]: {value, count}}: top 50 by exact count.',
981
+ output: '{total, sources[]: {value, count}} (top 50).',
936
982
  },
937
983
  {
938
984
  group: 'devices', name: 'list', run: devicesList, role: 'viewer', mutates: false,
@@ -990,21 +1036,21 @@ const commands = [
990
1036
  },
991
1037
  {
992
1038
  group: 'metrics', name: 'top', run: metricsTop, role: 'viewer', mutates: false,
993
- summary: 'Rank devices by a metric aggregated over a window, with the fleet-wide distribution',
994
- description: 'One server-side query over every device: avg/max/min/p95/last of the metric per device, ranked, plus min/p50/p90/p95/max across devices. --compare adds the previous window of the same length. reboots counts uptime resets (many devices reboot daily on schedule; compare with the fleet p50). diskDaysLeft forecasts days until the root disk is full from the growth over the window (only growing disks; soonest first). Device filters work like devices list (retired devices are excluded by default). Cached for 60s.',
1039
+ summary: 'Rank all devices by a metric over a window, with the fleet distribution',
1040
+ description: 'reboots = uptime resets (daily scheduled reboots are normal; compare with p50). diskDaysLeft = days until the root disk is full at the current growth. Device filters match devices list.',
995
1041
  args: [{ name: 'metric', description: metricNames.join(', ') }],
996
1042
  options: {
997
- agg: { type: 'string', choices: ['avg', 'max', 'min', 'p95', 'last'], default: 'avg', description: 'Per-device aggregation over the window (ignored for reboots).' },
1043
+ agg: { type: 'string', choices: ['avg', 'max', 'min', 'p95', 'last'], default: 'avg', description: 'Per-device aggregation.' },
998
1044
  range: { alias: 'r', type: 'string', choices: metricWindows, description: 'Window (default 24h).' },
999
- from: { type: 'string', placeholder: 'ISO', description: 'Absolute window start (with --to, at most 30 days).' },
1000
- to: { type: 'string', placeholder: 'ISO', description: 'Absolute window end.' },
1001
- top: { type: 'integer', default: 10, placeholder: 'N', description: 'Devices to return (1-100).' },
1002
- order: { type: 'string', choices: ['desc', 'asc'], description: 'desc = highest first, asc = lowest first (default desc; asc for diskDaysLeft).' },
1003
- compare: { type: 'boolean', description: 'Also aggregate the previous window: previous and change per device.' },
1004
- sort: { type: 'string', choices: ['value', 'change'], description: 'Rank by value (default) or by change (needs --compare).' },
1005
- above: { type: 'number', placeholder: 'N', description: 'Only devices whose value is above N (e.g. --above 90).' },
1006
- below: { type: 'number', placeholder: 'N', description: 'Only devices whose value is below N.' },
1007
- query: { alias: 'q', type: 'string', placeholder: 'text', description: 'Device text filter (same as devices list).' },
1045
+ from: { type: 'string', placeholder: 'ISO', description: 'Start (with --to; up to 30 days).' },
1046
+ to: { type: 'string', placeholder: 'ISO', description: 'End.' },
1047
+ top: { type: 'integer', default: 10, placeholder: 'N', description: 'Devices (1-100).' },
1048
+ order: { type: 'string', choices: ['desc', 'asc'], description: 'Default desc (asc for diskDaysLeft).' },
1049
+ compare: { type: 'boolean', description: 'Add the previous window: previous, change.' },
1050
+ sort: { type: 'string', choices: ['value', 'change'], description: 'value or change (needs --compare).' },
1051
+ above: { type: 'number', placeholder: 'N', description: 'Only values above N.' },
1052
+ below: { type: 'number', placeholder: 'N', description: 'Only values below N.' },
1053
+ query: { alias: 'q', type: 'string', placeholder: 'text', description: 'Device text.' },
1008
1054
  status: { type: 'string', choices: deviceStatuses, description: 'Reception status.' },
1009
1055
  lifecycle: { type: 'string', choices: lifecycles, description: 'Lifecycle scope (default operational).' },
1010
1056
  role: { type: 'string', placeholder: 'role', description: 'Device role.' },
@@ -1022,19 +1068,37 @@ const commands = [
1022
1068
  'sentinelctl metrics top diskDaysLeft -r 24h --below 14',
1023
1069
  'sentinelctl metrics top networkTx -r 24h --label fleet=edge -o json',
1024
1070
  ],
1025
- output: '{metric, aggregation, unit, window{start, end, seconds, range}, criteria, scope{devices, withData, withoutData, matched}, distribution{count, min, avg, p50, p90, p95, max}, previousDistribution?, devices[]: {agentId, displayName, host, status, value, previous?, change?}}.',
1071
+ output: '{metric, unit, scope{devices, withData, matched}, distribution{min, avg, p50, p90, p95, max}, devices[]: {agentId, displayName, status, value, previous?, change?}}.',
1026
1072
  },
1027
1073
  {
1028
1074
  group: 'fleet', name: 'summary', run: fleetSummary, role: 'viewer', mutates: false,
1029
- summary: 'One-call fleet briefing: status, attention, busiest devices, metric trends, log levels and error patterns',
1030
- description: 'Start here to answer "how is the infrastructure?". Combines the device inventory, attention reasons with example devices, the highest current CPU/memory/disk/temperature, offline devices split by how long, metric trends (disks full within 14 days, temperature rises of 15°C or more, reboot counts well above the fleet norm) and log statistics for the window: totals by level, top, rising, falling and new error patterns (compared with the previous window) and the devices with the most errors. About 10KB as JSON.',
1075
+ summary: 'Status, attention, trends and error patterns in one call',
1076
+ description: 'Start here. Trends flag disks full within 14 days, temperature rises of 15°C+ and unusual reboot counts; error patterns are compared with the previous window. ~10KB JSON.',
1031
1077
  options: {
1032
1078
  range: { alias: 'r', type: 'string', choices: logRanges, default: '24h', description: 'Log window.' },
1033
- logs: { type: 'boolean', description: 'Include log statistics (default on; --no-logs skips them).' },
1034
- trends: { type: 'boolean', description: 'Include metric trends: disks full within 14 days, temperature rises, unusual reboot counts (default on; --no-trends skips them).' },
1079
+ logs: { type: 'boolean', description: 'Log statistics (--no-logs skips).' },
1080
+ trends: { type: 'boolean', description: 'Metric trends (--no-trends skips).' },
1035
1081
  },
1036
1082
  examples: ['sentinelctl fleet summary', 'sentinelctl fleet summary -r 1h -o json', 'sentinelctl fleet summary --no-logs'],
1037
- output: '{range, stale, devices{total, status, lifecycle, metrics, logs, offline{within24h[], over7dCount, otherCount}}, attention[]: {reason, count, devices[≤5]}, resources{cpu, memory, disk, temperature: [{agentId, displayName, value}]}, trends{diskFullWithin14Days[], temperatureRise[], rebootOutliers{typicalPerWeek, threshold, devices[]}}, logs{complete, window, total, devices, levels, errors{total, previousTotal, devices, newPatterns}, topErrorPatterns[], risingErrorPatterns[], fallingErrorPatterns[] (shrunk or gone since the previous window), topErrorDevices[]}}.',
1083
+ output: '{devices{status, metrics, logs, offline}, attention[]{reason, count, devices}, resources{cpu, memory, disk, temperature}, trends{diskFullWithin14Days, temperatureRise, rebootOutliers}, logs{levels, errors, topErrorPatterns, risingErrorPatterns, fallingErrorPatterns, topErrorDevices}}.',
1084
+ },
1085
+ {
1086
+ group: 'devices', name: 'correlate', run: devicesCorrelate, role: 'viewer', mutates: false,
1087
+ summary: 'Metrics and logs of one device around a time or metric peak',
1088
+ description: 'For "what happened when X spiked": metric values and biggest change, reboots and log groups around --at or the --peak of a metric.',
1089
+ args: [{ name: 'agent-id' }],
1090
+ options: {
1091
+ at: { type: 'string', placeholder: 'ISO', description: 'Center time.' },
1092
+ peak: { type: 'string', choices: correlateMetricNames, description: 'Center on the highest point of this metric within --range instead.' },
1093
+ range: { alias: 'r', type: 'string', choices: ['1h', '6h', '24h', '7d'], default: '24h', description: 'Where to look for --peak.' },
1094
+ window: { type: 'string', default: '15m', placeholder: 'duration', description: 'Look this far before and after (1m-6h).' },
1095
+ level: { alias: 'l', type: 'string', choices: logLevels, description: 'Only these log levels in the groups.' },
1096
+ top: { type: 'integer', default: 20, placeholder: 'N', description: 'Log groups (1-100).' },
1097
+ series: { type: 'boolean', description: 'Include the metric time series in JSON.' },
1098
+ wide: { alias: 'w', type: 'boolean', description: 'Do not truncate patterns.' },
1099
+ },
1100
+ examples: ['sentinelctl devices correlate <agent-id> --peak cpu -r 24h', 'sentinelctl devices correlate <agent-id> --at 2026-09-30T14:58:00+09:00 --window 30m -o json'],
1101
+ output: '{at, window, peak?, metrics{<name>: {atValue, min, max, change, biggestStep{at, delta}}}, reboots[], logs{total, levels, groups[]: {source, pattern, count, first, last, latest}}}.',
1038
1102
  },
1039
1103
  {
1040
1104
  group: 'metadata', name: 'get', run: metadataGet, role: 'viewer', mutates: false,
@@ -1171,10 +1235,10 @@ const commands = [
1171
1235
 
1172
1236
  export const catalog = commands;
1173
1237
  export const groups = {
1174
- fleet: 'One-call briefing of the whole fleet',
1175
- logs: 'Count, group and compare logs on the server; exact pattern search; context; volume; sources',
1176
- metrics: 'Rank devices by an aggregated metric across the fleet',
1177
- devices: 'Device list, details, lookup, reception overview',
1238
+ fleet: 'Fleet briefing',
1239
+ logs: 'Log counts, groups, patterns and raw lines',
1240
+ metrics: 'Fleet-wide metric rankings',
1241
+ devices: 'Device list, details, reception history, metric-log correlation',
1178
1242
  metadata: 'Device metadata: read, edit (editor), import/export (admin)',
1179
1243
  sessions: 'Your sessions and CLI tokens',
1180
1244
  admin: 'Roles, all sessions, audit clean-up, capacity (admin)',
package/src/main.mjs CHANGED
@@ -1,4 +1,6 @@
1
1
  import { readFileSync } from 'node:fs';
2
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
3
+ import { dirname, join } from 'node:path';
2
4
  import { closest, parseArguments, UsageError } from './args.mjs';
3
5
  import { ApiError, createClient } from './client.mjs';
4
6
  import { catalog, commandPath, findCommand, groups, outputChoices, roleLabels, usageLine } from './commands.mjs';
@@ -7,14 +9,23 @@ import { renderKeyValues } from './format.mjs';
7
9
  import { browserLogin, openBrowser } from './login.mjs';
8
10
 
9
11
  const version = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
12
+ const userAgent = `sentinelctl/${version} node/${process.versions.node}`;
13
+ const updateCommand = 'npm install -g sentinelctl@latest';
14
+
15
+ function newer(latest, current) {
16
+ const a = latest.split('.').map(Number);
17
+ const b = current.split('.').map(Number);
18
+ for (let index = 0; index < 3; index += 1) if (a[index] !== b[index]) return a[index] > b[index];
19
+ return false;
20
+ }
10
21
  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)' };
22
+ const exitCodes = { 0: 'success', 1: 'server or network error', 2: 'usage error (fix the arguments)', 3: 'not logged in, insufficient role or CLI too old (retrying will not help)' };
12
23
  const globalDefinitions = {
13
24
  server: { type: 'string', placeholder: 'url', description: 'Console URL (default https://monitor.intflow.dev; also SENTINEL_URL).' },
14
25
  profile: { type: 'string', placeholder: 'name', description: 'Saved login profile (default "default").' },
15
26
  output: { alias: 'o', type: 'string', choices: outputChoices, description: 'Output format. Use json or ndjson from agents and scripts.' },
16
27
  json: { type: 'boolean', description: 'Same as -o json.' },
17
- select: { type: 'string', placeholder: 'fields', description: 'json/ndjson: keep only these comma-separated fields (dotted paths like metadata.role) of each item in the main list, plus top-level scalars. Example: --select agentId,displayName,status,memory' },
28
+ select: { type: 'string', placeholder: 'fields', description: 'json/ndjson: keep only these fields of each item, e.g. agentId,status,metadata.role.' },
18
29
  quiet: { type: 'boolean', description: 'Suppress summaries and hints on stderr (errors are still printed).' },
19
30
  help: { alias: 'h', type: 'boolean', description: 'Show help.' },
20
31
  version: { alias: 'V', type: 'boolean', description: 'Show the version.' },
@@ -119,84 +130,54 @@ function overviewHelp() {
119
130
  return lines.join('\n');
120
131
  }
121
132
 
122
- const agentGuide = `Using sentinelctl from AI agents and scripts
123
-
124
- SETUP
125
- - A human logs in once (\`sentinelctl login\`, approve in the browser; 30 days). Later commands are
126
- non-interactive. Elsewhere pass SENTINEL_TOKEN and SENTINEL_URL.
127
- - Use -o json or -o ndjson. stdout is data only; notes, hints and errors go to stderr (--quiet drops
128
- notes and hints). A json/ndjson error is one stderr line {"error":{status,code,message,hint,exitCode}}.
129
- - Exit codes: 0 ok · 1 server/network (retry later) · 2 usage (fix the arguments) · 3 login/role (do
130
- not retry). \`sentinelctl commands <group> --json\` describes options and output fields.
131
-
132
- HOW TO INVESTIGATE: broad to narrow, count before you read
133
- 1. Overview fleet summary -o json
134
- status, attention reasons, busiest devices, trends (disk full soon, temperature
135
- rises, unusual reboots), error levels, top/rising/falling/new error patterns.
136
- 2. What and where logs stats -l error -r 24h --by message --compare -o json (patterns, change)
137
- logs groups -l error -r 24h -o json (every device x source x pattern with
138
- count, first/last time, latest line)
139
- 3. When logs stats -p "<pattern>" --by agent --timeline -r 7d -o json
140
- logs histogram -a <id> -r 7d -o json
141
- 4. Read a few lines logs search -p "<pattern>" -a <id> -r 24h -n 50 -o json
142
- logs context -a <id> -s <source> -t <timestamp> (lines around one log)
143
- 5. Device state devices overview <id> -r 7d -o json · devices show <id> --series cpu,memory -o json
144
- 6. Fleet metrics metrics top memory -r 7d · metrics top disk --agg last --above 85
145
- metrics top diskDaysLeft --below 14 · metrics top cpu --agg p95 --compare
146
- metrics top reboots -r 7d (most devices reboot daily; compare with p50)
133
+ const agentGuide = `sentinelctl for AI agents
147
134
 
148
- LOG SEARCH RULES (complete answers at low cost)
149
- - Never download logs to count, rank or summarize them. logs search returns at most 10,000 lines,
150
- which on this fleet covers minutes, not hours: the rest of the window is silently missing.
151
- logs stats and logs groups count every log in the window on the server.
152
- - Prefer logs groups over logs search: errors in one hour are typically ~10,000 lines but ~150
153
- groups, each with its exact count and latest line.
154
- - Copy patterns exactly. Patterns from logs stats --by message / logs groups (numbers shown as <N>)
155
- go to -p/--pattern, which matches that pattern exactly and quickly. -q matches words
156
- case-insensitively and can also hit unrelated messages.
157
- - Narrow before widening: device (-a), source (-s), service (--service), severity (-l), pattern (-p).
158
- Start with 1h-24h; widen to 7d or 30d only when you need history.
159
- - Fleet-wide windows over 7 days need a narrowing filter (-a, -s, --service, -p or -q with
160
- --case-sensitive); otherwise the server answers 422 log_query_too_broad.
161
- Single-device, single-service and single-pattern queries can use the whole retention period
162
- (31 days by default).
163
- - Windows over one day are aggregated per day and finished days are cached: repeating a long query
164
- is cheap, and the CLI continues automatically if the server needs several requests. A partial
165
- result says so in complete/coverage.
166
- - To watch for new logs, poll with --since <nextSince from the previous run>, not --follow.
167
- - --dedupe folds lines repeated within the same second (the agent writes some messages twice).
168
- - Levels: emergency/critical/error include more severe levels; warning, info and debug are exact.
169
- - Applications are found by source (-s: systemd unit, container) on one device, or by the service
170
- label (--service, --env) across devices when the collector sets it; logs stats --by service ranks them.
135
+ Setup A human runs \`sentinelctl login\` once (30 days), or set SENTINEL_TOKEN and SENTINEL_URL.
136
+ Output Use -o json. stdout is data; notes, hints and errors go to stderr (--quiet drops notes).
137
+ Errors One stderr line {"error":{status,code,message,hint,exitCode}}; messages are Korean, hints English.
138
+ Exit 0 ok · 1 server/network (retry later) · 2 usage (fix arguments) · 3 login/role/too old (stop).
139
+ Updates A stderr {"notice":{"type":"update_available",…}} line means a newer CLI exists; tell the user.
140
+ Help sentinelctl <command> --help · sentinelctl commands <group> --json
171
141
 
172
- LIMITS (shared with the web console, per user)
173
- - 2 log queries and 2 metric aggregations at a time; 60 seconds of log query time per minute.
174
- 429 responses are retried automatically with backoff; run commands one after another.
175
- - Retrying a 422 or 400 without changing the arguments will fail again; read the hint.
142
+ Workflow: broad to narrow, count before reading
143
+ 1. fleet summary status, attention, trends, error patterns
144
+ 2. logs stats -l error -r 24h --by message --compare which patterns, what changed
145
+ logs groups -l error -r 24h device x source x pattern, latest line
146
+ 3. logs stats -p "<pattern>" --by agent --timeline who and since when
147
+ devices correlate <id> --peak cpu metrics and logs around a spike
148
+ 4. logs search -p "<pattern>" -a <id> -n 50 a few raw lines (logs context for neighbors)
149
+ Metrics: metrics top <metric>, e.g. memory -r 7d · disk --agg last · diskDaysLeft · reboots
176
150
 
177
- KEEP OUTPUT SMALL
178
- - --select a,b.c keeps only those fields (devices list --all -o json: ~700KB -> ~20KB).
179
- - devices show --series none drops time series; logs groups/stats return compact rows.
151
+ Rules
152
+ - Never count or summarize by downloading: logs search stops at 10,000 lines (minutes of this
153
+ fleet); logs stats and logs groups count the whole window.
154
+ - Pass patterns to -p exactly as printed (<N> = number). -q is a case-insensitive word match.
155
+ - Narrow first (-a, -s, --service, -l, -p) and start with 1h-24h. Fleet-wide windows over 7 days
156
+ need one of -a, -s, --service, -p or --case-sensitive (else 422 log_query_too_broad); narrowed
157
+ queries reach 31 days. Long aggregations are cached per day and continue automatically.
158
+ - Run commands one at a time: per user 2 log queries at once and 60s of log time per minute
159
+ (429 is retried automatically). Poll with --since <nextSince>, never --follow.
160
+ - Keep output small: --select a,b.c · devices show --series none.
161
+ - Commands that change data (metadata, admin): --dry-run first. Viewer tokens are read-only.`;
180
162
 
181
- CHANGES
182
- - metadata set/bulk/rollback/import and admin commands change data: run with --dry-run first when
183
- available, check the result, then apply. Viewer tokens cannot change anything.
184
- - Server error messages are Korean (shared with the web console); hints are English.`;
163
+ function compact(record) {
164
+ return Object.fromEntries(Object.entries(record).filter(([, value]) => value !== undefined && value !== null && !(Array.isArray(value) && !value.length)));
165
+ }
185
166
 
186
167
  function commandsCatalog(selected = null) {
187
168
  return {
188
169
  version,
189
- globalOptions: globalDefinitions,
170
+ globalOptions: Object.fromEntries(Object.entries(globalDefinitions).map(([name, spec]) => [name, compact(spec)])),
190
171
  exitCodes,
191
172
  commands: [
192
- ...(selected ? [] : Object.entries(specialCommands).map(([name, command]) => ({ command: name, usage: command.usage, summary: command.summary, role: null, mutates: name === 'logout' }))),
193
- ...(selected ?? catalog).map((command) => ({
194
- command: commandPath(command), usage: usageLine(command), summary: command.summary, description: command.description ?? null,
195
- role: command.role, roleNote: command.roleNote ?? null, mutates: command.mutates, args: command.args ?? [],
196
- options: Object.fromEntries(Object.entries(command.options).map(([name, spec]) => [name, {
197
- alias: spec.alias ?? null, type: spec.type, choices: spec.choices ?? null, default: spec.default ?? null,
198
- multiple: Boolean(spec.multiple), description: spec.description ?? null,
199
- }])),
173
+ ...(selected ? [] : Object.entries(specialCommands).map(([name, command]) => ({ command: name, usage: command.usage, summary: command.summary, mutates: name === 'logout' }))),
174
+ ...(selected ?? catalog).map((command) => compact({
175
+ command: commandPath(command), usage: usageLine(command), summary: command.summary, description: command.description,
176
+ role: command.role, roleNote: command.roleNote, mutates: command.mutates, args: command.args,
177
+ options: Object.fromEntries(Object.entries(command.options).map(([name, spec]) => [name, compact({
178
+ alias: spec.alias, type: spec.type, choices: spec.choices, default: spec.default,
179
+ multiple: spec.multiple || undefined, description: spec.description,
180
+ })])),
200
181
  examples: command.examples, output: command.output,
201
182
  })),
202
183
  ],
@@ -205,7 +186,7 @@ function commandsCatalog(selected = null) {
205
186
 
206
187
  async function login(context, argv) {
207
188
  const { options } = parseArguments(argv, { 'no-browser': { type: 'boolean' }, name: { type: 'string' } });
208
- const client = createClient({ server: context.connection.server, fetch: context.fetch });
189
+ const client = createClient({ server: context.connection.server, fetch: context.fetch, userAgent });
209
190
  const result = await browserLogin(client, {
210
191
  clientName: options.name,
211
192
  launchBrowser: !options['no-browser'],
@@ -240,6 +221,21 @@ async function logout(context) {
240
221
  context.out(removed ? 'Logged out.' : 'No saved login to remove.');
241
222
  }
242
223
 
224
+ async function noticeUpdate(context, environment) {
225
+ const latest = context?.versions?.latest;
226
+ if (!latest || !newer(latest, version) || environment.SENTINELCTL_NO_UPDATE_NOTICE) return;
227
+ const path = join(dirname(context.connection.path), 'update-notice.json');
228
+ let last = {};
229
+ try { last = JSON.parse(await readFile(path, 'utf8')); } catch {}
230
+ if (last.latest === latest && Date.now() - (last.at ?? 0) < 86_400_000) return;
231
+ const message = `sentinelctl ${latest} is available (you have ${version}). Update: ${updateCommand}`;
232
+ context.err(context.global.output === 'table' ? `notice: ${message}` : JSON.stringify({ notice: { type: 'update_available', current: version, latest, command: updateCommand } }));
233
+ try {
234
+ await mkdir(dirname(path), { recursive: true });
235
+ await writeFile(path, JSON.stringify({ latest, at: Date.now() }), { mode: 0o600 });
236
+ } catch {}
237
+ }
238
+
243
239
  async function rememberRole(context, role) {
244
240
  const saved = context.connection.profile;
245
241
  if (!saved || context.connection.fromEnvironment || saved.role === role) return;
@@ -254,13 +250,14 @@ async function whoami(context) {
254
250
  const identity = await context.client.get('/api/session');
255
251
  await rememberRole(context, identity.role);
256
252
  if (context.global.output === 'json' || context.global.output === 'ndjson') {
257
- context.out(JSON.stringify({ server: context.connection.server, ...identity, tokenExpiresAt: context.connection.profile?.expiresAt ?? null }));
253
+ context.out(JSON.stringify({ server: context.connection.server, ...identity, tokenExpiresAt: context.connection.profile?.expiresAt ?? null, cli: { version, latest: context.versions?.latest ?? null } }));
258
254
  return;
259
255
  }
260
256
  context.out(renderKeyValues([
261
257
  ['Server', context.connection.server], ['User', identity.user], ['Name', identity.name],
262
258
  ['Role', roleLabels[identity.role] ?? identity.role], ['Token ID', identity.sessionId],
263
259
  ['Token expires', context.connection.profile?.expiresAt],
260
+ ['CLI', `${version}${context.versions?.latest ? ` (latest ${context.versions.latest})` : ''}`],
264
261
  ]));
265
262
  }
266
263
 
@@ -299,6 +296,7 @@ function apiHint(error, context, command) {
299
296
  ? 'Check the agent ID with `sentinelctl devices lookup <part of the name>`.' : 'Not found; check the ID.';
300
297
  }
301
298
  if (error.status === 409) return 'Someone saved a change first. Run the command again to apply on top of the latest revision.';
299
+ if (error.code === 'cli_upgrade_required') return `Update the CLI: ${updateCommand} (or @intflow/sentinelctl@latest), then rerun.`;
302
300
  if (error.code === 'log_query_too_broad') {
303
301
  return 'Narrow it: add -a <agent-id>, -s <source>, --service <name>, -p "<pattern>" (from `logs stats --by message`) or --case-sensitive with -q, or use a window of 7 days or less.';
304
302
  }
@@ -412,7 +410,7 @@ export async function main(argv, {
412
410
  select: global.select ? global.select.split(',').map((field) => field.trim()).filter(Boolean) : null,
413
411
  hint: (text) => { if (!global.quiet) err(`hint: ${text}`); },
414
412
  note: (text) => { if (!global.quiet && global.output === 'table') err(text); },
415
- client: createClient({ server: connection.server, token: connection.token, fetch, sleep }),
413
+ client: createClient({ server: connection.server, token: connection.token, fetch, sleep, userAgent, onVersions: (versions) => { context.versions = versions; } }),
416
414
  };
417
415
  if (name === 'login') { await login(context, commandArgv); return 0; }
418
416
  if (name === 'logout') { await logout(context); return 0; }
@@ -438,9 +436,11 @@ export async function main(argv, {
438
436
  `Ask an admin to run \`sentinelctl admin grant <your-email> --role ${error.required}\`. Nothing was sent to the server.`, 3);
439
437
  }
440
438
  if (error instanceof ApiError) {
441
- const exitCode = error.status === 401 || error.status === 403 || error.code === 'not_logged_in' ? 3 : 1;
439
+ const exitCode = error.status === 401 || error.status === 403 || error.status === 426 || error.code === 'not_logged_in' ? 3 : 1;
442
440
  return report(error, apiHint(error, context ?? { connection: {} }, command), exitCode);
443
441
  }
444
442
  return report(error, null, 1);
443
+ } finally {
444
+ if (context) await noticeUpdate(context, environment);
445
445
  }
446
446
  }