@sonde/agent 0.2.6 → 0.2.8

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.
Files changed (47) hide show
  1. package/.turbo/turbo-build.log +6 -4
  2. package/.turbo/turbo-test.log +65 -23
  3. package/CHANGELOG.md +20 -0
  4. package/dist/cli/service.d.ts +12 -0
  5. package/dist/cli/service.d.ts.map +1 -0
  6. package/dist/cli/service.js +164 -0
  7. package/dist/cli/service.js.map +1 -0
  8. package/dist/cli/service.test.d.ts +2 -0
  9. package/dist/cli/service.test.d.ts.map +1 -0
  10. package/dist/cli/service.test.js +99 -0
  11. package/dist/cli/service.test.js.map +1 -0
  12. package/dist/cli/update.d.ts.map +1 -1
  13. package/dist/cli/update.js +11 -8
  14. package/dist/cli/update.js.map +1 -1
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +60 -12
  17. package/dist/index.js.map +1 -1
  18. package/dist/tui/installer/InstallerApp.d.ts.map +1 -1
  19. package/dist/tui/installer/InstallerApp.js +3 -1
  20. package/dist/tui/installer/InstallerApp.js.map +1 -1
  21. package/dist/tui/installer/StepService.d.ts +6 -0
  22. package/dist/tui/installer/StepService.d.ts.map +1 -0
  23. package/dist/tui/installer/StepService.js +49 -0
  24. package/dist/tui/installer/StepService.js.map +1 -0
  25. package/dist/tui/status/PackToggleView.d.ts +15 -0
  26. package/dist/tui/status/PackToggleView.d.ts.map +1 -0
  27. package/dist/tui/status/PackToggleView.js +30 -0
  28. package/dist/tui/status/PackToggleView.js.map +1 -0
  29. package/dist/tui/status/StatusApp.d.ts +6 -0
  30. package/dist/tui/status/StatusApp.d.ts.map +1 -0
  31. package/dist/tui/status/StatusApp.js +98 -0
  32. package/dist/tui/status/StatusApp.js.map +1 -0
  33. package/dist/tui/status/StatusInfoView.d.ts +13 -0
  34. package/dist/tui/status/StatusInfoView.d.ts.map +1 -0
  35. package/dist/tui/status/StatusInfoView.js +14 -0
  36. package/dist/tui/status/StatusInfoView.js.map +1 -0
  37. package/package.json +1 -1
  38. package/src/cli/service.test.ts +124 -0
  39. package/src/cli/service.ts +178 -0
  40. package/src/cli/update.ts +11 -8
  41. package/src/index.ts +71 -13
  42. package/src/tui/installer/InstallerApp.tsx +6 -2
  43. package/src/tui/installer/StepService.tsx +112 -0
  44. package/src/tui/status/PackToggleView.tsx +69 -0
  45. package/src/tui/status/StatusApp.tsx +167 -0
  46. package/src/tui/status/StatusInfoView.tsx +88 -0
  47. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,178 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+
5
+ const UNIT_NAME = 'sonde-agent';
6
+ const UNIT_PATH = `/etc/systemd/system/${UNIT_NAME}.service`;
7
+
8
+ export interface ServiceResult {
9
+ success: boolean;
10
+ message: string;
11
+ }
12
+
13
+ function isLinux(): boolean {
14
+ return process.platform === 'linux';
15
+ }
16
+
17
+ function resolveSondeBinary(): string {
18
+ return execFileSync('which', ['sonde'], {
19
+ encoding: 'utf-8',
20
+ timeout: 5_000,
21
+ }).trim();
22
+ }
23
+
24
+ export function generateUnitFile(): string {
25
+ const user = os.userInfo();
26
+ const sondeBin = resolveSondeBinary();
27
+
28
+ return `[Unit]
29
+ Description=Sonde Agent
30
+ After=network-online.target
31
+ Wants=network-online.target
32
+
33
+ [Service]
34
+ Type=simple
35
+ User=${user.username}
36
+ Environment=HOME=${user.homedir}
37
+ ExecStart=${sondeBin} start --headless
38
+ Restart=on-failure
39
+ RestartSec=5
40
+ StandardOutput=journal
41
+ StandardError=journal
42
+ SyslogIdentifier=${UNIT_NAME}
43
+
44
+ [Install]
45
+ WantedBy=multi-user.target
46
+ `;
47
+ }
48
+
49
+ export function isServiceInstalled(): boolean {
50
+ if (!isLinux()) return false;
51
+ return fs.existsSync(UNIT_PATH);
52
+ }
53
+
54
+ export function getServiceStatus(): string {
55
+ if (!isLinux()) return 'unsupported';
56
+ if (!isServiceInstalled()) return 'not-installed';
57
+
58
+ try {
59
+ return execFileSync('systemctl', ['is-active', UNIT_NAME], {
60
+ encoding: 'utf-8',
61
+ timeout: 5_000,
62
+ }).trim();
63
+ } catch {
64
+ return 'inactive';
65
+ }
66
+ }
67
+
68
+ export function installService(): ServiceResult {
69
+ if (!isLinux()) {
70
+ return {
71
+ success: false,
72
+ message: 'systemd services are only supported on Linux.',
73
+ };
74
+ }
75
+
76
+ try {
77
+ const unitContent = generateUnitFile();
78
+
79
+ execFileSync('sudo', ['tee', UNIT_PATH], {
80
+ input: unitContent,
81
+ stdio: ['pipe', 'pipe', 'pipe'],
82
+ timeout: 10_000,
83
+ });
84
+ execFileSync('sudo', ['systemctl', 'daemon-reload'], { stdio: 'pipe', timeout: 10_000 });
85
+ execFileSync('sudo', ['systemctl', 'enable', UNIT_NAME], { stdio: 'pipe', timeout: 10_000 });
86
+ execFileSync('sudo', ['systemctl', 'start', UNIT_NAME], { stdio: 'pipe', timeout: 10_000 });
87
+
88
+ return {
89
+ success: true,
90
+ message: `${UNIT_NAME} service installed and started.`,
91
+ };
92
+ } catch (err: unknown) {
93
+ const msg = err instanceof Error ? err.message : String(err);
94
+ return { success: false, message: `Failed to install service: ${msg}` };
95
+ }
96
+ }
97
+
98
+ export function uninstallService(): ServiceResult {
99
+ if (!isLinux()) {
100
+ return {
101
+ success: false,
102
+ message: 'systemd services are only supported on Linux.',
103
+ };
104
+ }
105
+
106
+ if (!isServiceInstalled()) {
107
+ return { success: false, message: 'Service is not installed.' };
108
+ }
109
+
110
+ try {
111
+ execFileSync('sudo', ['systemctl', 'stop', UNIT_NAME], { stdio: 'pipe', timeout: 10_000 });
112
+ execFileSync('sudo', ['systemctl', 'disable', UNIT_NAME], { stdio: 'pipe', timeout: 10_000 });
113
+ execFileSync('sudo', ['rm', '-f', UNIT_PATH], { stdio: 'pipe', timeout: 5_000 });
114
+ execFileSync('sudo', ['systemctl', 'daemon-reload'], { stdio: 'pipe', timeout: 10_000 });
115
+
116
+ return {
117
+ success: true,
118
+ message: `${UNIT_NAME} service removed.`,
119
+ };
120
+ } catch (err: unknown) {
121
+ const msg = err instanceof Error ? err.message : String(err);
122
+ return {
123
+ success: false,
124
+ message: `Failed to uninstall service: ${msg}`,
125
+ };
126
+ }
127
+ }
128
+
129
+ export function stopService(): ServiceResult {
130
+ if (!isLinux()) {
131
+ return {
132
+ success: false,
133
+ message: 'systemd services are only supported on Linux.',
134
+ };
135
+ }
136
+
137
+ try {
138
+ execFileSync('systemctl', ['stop', UNIT_NAME], { stdio: 'pipe', timeout: 10_000 });
139
+ return { success: true, message: `Stopped ${UNIT_NAME} service.` };
140
+ } catch {
141
+ try {
142
+ execFileSync('sudo', ['systemctl', 'stop', UNIT_NAME], { stdio: 'inherit', timeout: 30_000 });
143
+ return { success: true, message: `Stopped ${UNIT_NAME} service.` };
144
+ } catch {
145
+ return {
146
+ success: false,
147
+ message: `Could not stop service. Try: sudo systemctl stop ${UNIT_NAME}`,
148
+ };
149
+ }
150
+ }
151
+ }
152
+
153
+ export function restartService(): ServiceResult {
154
+ if (!isLinux()) {
155
+ return {
156
+ success: false,
157
+ message: 'systemd services are only supported on Linux.',
158
+ };
159
+ }
160
+
161
+ try {
162
+ execFileSync('systemctl', ['restart', UNIT_NAME], { stdio: 'pipe', timeout: 10_000 });
163
+ return { success: true, message: `Restarted ${UNIT_NAME} service.` };
164
+ } catch {
165
+ try {
166
+ execFileSync('sudo', ['systemctl', 'restart', UNIT_NAME], {
167
+ stdio: 'inherit',
168
+ timeout: 30_000,
169
+ });
170
+ return { success: true, message: `Restarted ${UNIT_NAME} service.` };
171
+ } catch {
172
+ return {
173
+ success: false,
174
+ message: `Could not restart service. Try: sudo systemctl restart ${UNIT_NAME}`,
175
+ };
176
+ }
177
+ }
178
+ }
package/src/cli/update.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { execFileSync } from 'node:child_process';
2
2
  import { VERSION } from '../version.js';
3
+ import { isServiceInstalled, restartService } from './service.js';
3
4
 
4
5
  /**
5
6
  * Lightweight semver comparison: returns true if a < b.
@@ -66,13 +67,15 @@ export function performUpdate(targetVersion: string): void {
66
67
 
67
68
  console.log(`Successfully updated to v${targetVersion}`);
68
69
 
69
- // Best-effort systemd restart
70
- try {
71
- execFileSync('systemctl', ['restart', 'sonde-agent'], {
72
- timeout: 10_000,
73
- });
74
- console.log('Restarted sonde-agent systemd service');
75
- } catch {
76
- // systemd not available or service not installed — that's fine
70
+ if (isServiceInstalled()) {
71
+ const result = restartService();
72
+ console.log(result.message);
73
+ } else {
74
+ console.log('Restart the agent to use the new version:');
75
+ console.log(' sonde restart');
76
+ if (process.platform === 'linux') {
77
+ console.log('');
78
+ console.log('Tip: Run "sonde service install" to start on boot.');
79
+ }
77
80
  }
78
81
  }
package/src/index.ts CHANGED
@@ -4,6 +4,14 @@ import { spawn } from 'node:child_process';
4
4
  import os from 'node:os';
5
5
  import { packRegistry } from '@sonde/packs';
6
6
  import { buildEnabledPacks, handlePacksCommand } from './cli/packs.js';
7
+ import {
8
+ getServiceStatus,
9
+ installService,
10
+ isServiceInstalled,
11
+ restartService,
12
+ stopService,
13
+ uninstallService,
14
+ } from './cli/service.js';
7
15
  import { checkForUpdate, performUpdate } from './cli/update.js';
8
16
  import {
9
17
  type AgentConfig,
@@ -40,6 +48,7 @@ function printUsage(): void {
40
48
  console.log(' restart Restart the agent in background');
41
49
  console.log(' status Show agent status');
42
50
  console.log(' packs Manage packs (list, scan, install, uninstall)');
51
+ console.log(' service Manage systemd service (install, uninstall, status)');
43
52
  console.log(' update Check for and install agent updates');
44
53
  console.log(' mcp-bridge stdio MCP bridge (for Claude Code integration)');
45
54
  console.log('');
@@ -247,6 +256,12 @@ async function cmdManager(): Promise<void> {
247
256
  }
248
257
 
249
258
  function cmdStop(): void {
259
+ if (isServiceInstalled() && getServiceStatus() === 'active') {
260
+ const result = stopService();
261
+ console.log(result.message);
262
+ return;
263
+ }
264
+
250
265
  if (stopRunningAgent()) {
251
266
  console.log('Agent stopped.');
252
267
  } else {
@@ -255,24 +270,61 @@ function cmdStop(): void {
255
270
  }
256
271
 
257
272
  function cmdRestart(): void {
273
+ if (isServiceInstalled() && getServiceStatus() === 'active') {
274
+ const result = restartService();
275
+ console.log(result.message);
276
+ return;
277
+ }
278
+
258
279
  stopRunningAgent();
259
280
  const pid = spawnBackgroundAgent();
260
281
  console.log(`Agent restarted in background (PID: ${pid}).`);
261
282
  }
262
283
 
263
- function cmdStatus(): void {
264
- const config = loadConfig();
265
- if (!config) {
266
- console.log('Status: Not enrolled');
267
- console.log(`Run "sonde enroll" to get started.`);
268
- return;
269
- }
284
+ async function cmdStatus(): Promise<void> {
285
+ const { render } = await import('ink');
286
+ const { createElement } = await import('react');
287
+ const { StatusApp } = await import('./tui/status/StatusApp.js');
288
+ const { waitUntilExit } = render(
289
+ createElement(StatusApp, { respawnAgent: spawnBackgroundAgent }),
290
+ );
291
+ await waitUntilExit();
292
+ }
270
293
 
271
- console.log(`Sonde Agent v${VERSION}`);
272
- console.log(` Name: ${config.agentName}`);
273
- console.log(` Hub: ${config.hubUrl}`);
274
- console.log(` Agent ID: ${config.agentId ?? '(not yet assigned)'}`);
275
- console.log(` Config: ${getConfigPath()}`);
294
+ function handleServiceCommand(subArgs: string[]): void {
295
+ const sub = subArgs[0];
296
+
297
+ switch (sub) {
298
+ case 'install': {
299
+ const result = installService();
300
+ console.log(result.message);
301
+ if (!result.success) process.exit(1);
302
+ break;
303
+ }
304
+ case 'uninstall': {
305
+ const result = uninstallService();
306
+ console.log(result.message);
307
+ if (!result.success) process.exit(1);
308
+ break;
309
+ }
310
+ case 'status': {
311
+ const status = getServiceStatus();
312
+ console.log(`sonde-agent service: ${status}`);
313
+ break;
314
+ }
315
+ default:
316
+ console.log('Usage: sonde service <command>');
317
+ console.log('');
318
+ console.log('Commands:');
319
+ console.log(' install Install systemd service (starts on boot)');
320
+ console.log(' uninstall Remove systemd service');
321
+ console.log(' status Show service status');
322
+ if (sub) {
323
+ console.error(`\nUnknown subcommand: ${sub}`);
324
+ process.exit(1);
325
+ }
326
+ break;
327
+ }
276
328
  }
277
329
 
278
330
  async function cmdInstall(): Promise<void> {
@@ -334,11 +386,17 @@ switch (command) {
334
386
  cmdRestart();
335
387
  break;
336
388
  case 'status':
337
- cmdStatus();
389
+ cmdStatus().catch((err: Error) => {
390
+ console.error(err.message);
391
+ process.exit(1);
392
+ });
338
393
  break;
339
394
  case 'packs':
340
395
  handlePacksCommand(args.slice(1));
341
396
  break;
397
+ case 'service':
398
+ handleServiceCommand(args.slice(1));
399
+ break;
342
400
  case 'update':
343
401
  cmdUpdate().catch((err: Error) => {
344
402
  console.error(`Update failed: ${err.message}`);
@@ -7,8 +7,9 @@ import { StepHub } from './StepHub.js';
7
7
  import { StepPacks } from './StepPacks.js';
8
8
  import { StepPermissions } from './StepPermissions.js';
9
9
  import { StepScan } from './StepScan.js';
10
+ import { StepService } from './StepService.js';
10
11
 
11
- type Step = 'hub' | 'scan' | 'packs' | 'permissions' | 'complete';
12
+ type Step = 'hub' | 'scan' | 'packs' | 'permissions' | 'service' | 'complete';
12
13
 
13
14
  export interface HubConfig {
14
15
  hubUrl: string;
@@ -21,6 +22,7 @@ const STEP_LABELS: Record<Step, string> = {
21
22
  scan: 'System Scan',
22
23
  packs: 'Pack Selection',
23
24
  permissions: 'Permissions',
25
+ service: 'Systemd Service',
24
26
  complete: 'Complete',
25
27
  };
26
28
 
@@ -75,11 +77,13 @@ export function InstallerApp({ initialHubUrl }: InstallerAppProps): JSX.Element
75
77
  {step === 'permissions' && (
76
78
  <StepPermissions
77
79
  selectedPacks={selectedPacks}
78
- onNext={() => setStep('complete')}
80
+ onNext={() => setStep('service')}
79
81
  onBack={() => setStep('packs')}
80
82
  />
81
83
  )}
82
84
 
85
+ {step === 'service' && <StepService onNext={() => setStep('complete')} />}
86
+
83
87
  {step === 'complete' && <StepComplete hubConfig={hubConfig} selectedPacks={selectedPacks} />}
84
88
  </Box>
85
89
  );
@@ -0,0 +1,112 @@
1
+ import { Box, Text, useInput } from 'ink';
2
+ import Spinner from 'ink-spinner';
3
+ import { useState } from 'react';
4
+ import { type ServiceResult, installService } from '../../cli/service.js';
5
+
6
+ interface StepServiceProps {
7
+ onNext: () => void;
8
+ }
9
+
10
+ type Phase = 'prompt' | 'installing' | 'done';
11
+
12
+ export function StepService({ onNext }: StepServiceProps): JSX.Element {
13
+ const [phase, setPhase] = useState<Phase>('prompt');
14
+ const [result, setResult] = useState<ServiceResult | null>(null);
15
+ const isLinux = process.platform === 'linux';
16
+
17
+ useInput((input, key) => {
18
+ if (!isLinux) {
19
+ if (key.return) onNext();
20
+ return;
21
+ }
22
+
23
+ if (phase === 'prompt') {
24
+ if (input === 'y' || input === 'Y' || key.return) {
25
+ setPhase('installing');
26
+ // Run async to let the spinner render
27
+ setTimeout(() => {
28
+ const res = installService();
29
+ setResult(res);
30
+ setPhase('done');
31
+ }, 0);
32
+ } else if (input === 'n' || input === 'N') {
33
+ setResult(null);
34
+ setPhase('done');
35
+ }
36
+ }
37
+
38
+ if (phase === 'done' && (key.return || input)) {
39
+ onNext();
40
+ }
41
+ });
42
+
43
+ if (!isLinux) {
44
+ return (
45
+ <Box flexDirection="column">
46
+ <Text bold>Systemd Service</Text>
47
+ <Box marginTop={1}>
48
+ <Text color="gray">Skipped — systemd services are only available on Linux.</Text>
49
+ </Box>
50
+ <Box marginTop={1}>
51
+ <Text color="gray">Press Enter to continue.</Text>
52
+ </Box>
53
+ </Box>
54
+ );
55
+ }
56
+
57
+ if (phase === 'installing') {
58
+ return (
59
+ <Box>
60
+ <Text color="cyan">
61
+ <Spinner type="dots" />
62
+ </Text>
63
+ <Text> Installing systemd service...</Text>
64
+ </Box>
65
+ );
66
+ }
67
+
68
+ if (phase === 'done') {
69
+ if (result === null) {
70
+ return (
71
+ <Box flexDirection="column">
72
+ <Text bold>Systemd Service</Text>
73
+ <Box marginTop={1}>
74
+ <Text color="gray">
75
+ Skipped. You can set this up later with:{' '}
76
+ <Text color="cyan">sonde service install</Text>
77
+ </Text>
78
+ </Box>
79
+ <Box marginTop={1}>
80
+ <Text color="gray">Press any key to continue.</Text>
81
+ </Box>
82
+ </Box>
83
+ );
84
+ }
85
+
86
+ return (
87
+ <Box flexDirection="column">
88
+ <Text bold>Systemd Service</Text>
89
+ <Box marginTop={1}>
90
+ <Text color={result.success ? 'green' : 'red'}>
91
+ {result.success ? ' OK' : ' !!'} {result.message}
92
+ </Text>
93
+ </Box>
94
+ <Box marginTop={1}>
95
+ <Text color="gray">Press any key to continue.</Text>
96
+ </Box>
97
+ </Box>
98
+ );
99
+ }
100
+
101
+ return (
102
+ <Box flexDirection="column">
103
+ <Text bold>Systemd Service</Text>
104
+ <Box marginTop={1}>
105
+ <Text>Set up sonde-agent as a systemd service? (starts on boot)</Text>
106
+ </Box>
107
+ <Box marginTop={1}>
108
+ <Text color="gray">y: install service | n: skip</Text>
109
+ </Box>
110
+ </Box>
111
+ );
112
+ }
@@ -0,0 +1,69 @@
1
+ import { Box, Text, useInput } from 'ink';
2
+ import { useState } from 'react';
3
+
4
+ export interface PackRow {
5
+ name: string;
6
+ description: string;
7
+ probeCount: number;
8
+ detected: boolean;
9
+ enabled: boolean;
10
+ }
11
+
12
+ interface PackToggleViewProps {
13
+ initialRows: PackRow[];
14
+ onConfirm: (enabledNames: string[]) => void;
15
+ onBack: () => void;
16
+ }
17
+
18
+ export function PackToggleView({
19
+ initialRows,
20
+ onConfirm,
21
+ onBack,
22
+ }: PackToggleViewProps): JSX.Element {
23
+ const [rows, setRows] = useState<PackRow[]>(initialRows);
24
+ const [cursor, setCursor] = useState(0);
25
+
26
+ useInput((input, key) => {
27
+ if (key.upArrow) {
28
+ setCursor((prev) => (prev > 0 ? prev - 1 : rows.length - 1));
29
+ } else if (key.downArrow) {
30
+ setCursor((prev) => (prev < rows.length - 1 ? prev + 1 : 0));
31
+ } else if (input === ' ') {
32
+ setRows((prev) =>
33
+ prev.map((row, i) => (i === cursor ? { ...row, enabled: !row.enabled } : row)),
34
+ );
35
+ } else if (key.return) {
36
+ onConfirm(rows.filter((r) => r.enabled).map((r) => r.name));
37
+ } else if (input === 'b') {
38
+ onBack();
39
+ }
40
+ });
41
+
42
+ return (
43
+ <Box flexDirection="column">
44
+ <Text color="gray">Toggle packs on/off. Press Enter to save, b to go back.</Text>
45
+ <Box marginTop={1} flexDirection="column">
46
+ {rows.map((row, i) => {
47
+ const isCursor = i === cursor;
48
+ const checkbox = row.enabled ? '[x]' : '[ ]';
49
+ return (
50
+ <Box key={row.name}>
51
+ <Text color={isCursor ? 'cyan' : 'white'} bold={isCursor}>
52
+ {isCursor ? '> ' : ' '}
53
+ {checkbox} {row.name}
54
+ </Text>
55
+ <Text color="gray">
56
+ {' '}
57
+ ({row.probeCount} probes) — {row.description}
58
+ </Text>
59
+ {row.detected && <Text color="green"> [detected]</Text>}
60
+ </Box>
61
+ );
62
+ })}
63
+ </Box>
64
+ <Box marginTop={1}>
65
+ <Text color="gray">Up/Down: navigate | Space: toggle | Enter: save | b: back</Text>
66
+ </Box>
67
+ </Box>
68
+ );
69
+ }