@holic512/plugin-systemd 1.0.1

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 ADDED
@@ -0,0 +1,71 @@
1
+ # @holic512/plugin-systemd
2
+
3
+ Interactive systemd service management tool for SlothTool.
4
+
5
+ SlothTool 的 systemd 服务管理插件,提供交互式操作与命令行支持。
6
+
7
+ ## Install / 安装
8
+
9
+ ```bash
10
+ slothtool install @holic512/plugin-systemd
11
+ ```
12
+
13
+ ## Usage / 使用
14
+
15
+ ### Interactive mode / 交互式模式
16
+
17
+ ```bash
18
+ slothtool systemd -i
19
+ ```
20
+
21
+ ### Command mode / 命令模式
22
+
23
+ ```bash
24
+ # List services
25
+ slothtool systemd list --state active
26
+ slothtool systemd list --all
27
+
28
+ # Service actions
29
+ slothtool systemd start ssh.service
30
+ slothtool systemd stop ssh.service
31
+ slothtool systemd restart ssh.service
32
+ slothtool systemd enable ssh.service
33
+ slothtool systemd disable ssh.service
34
+
35
+ # Logs
36
+ slothtool systemd logs ssh.service --lines 50
37
+ slothtool systemd logs ssh.service --follow
38
+ slothtool systemd logs ssh.service --since "1 hour ago"
39
+ ```
40
+
41
+ ## Features / 功能
42
+
43
+ - Interactive-first UX with history-based quick actions
44
+ - Service management: start/stop/restart/enable/disable/status
45
+ - Logs with line limit, follow, and since filters
46
+ - History cache for recent services/actions/searches
47
+ - Bilingual (Chinese/English) messages
48
+
49
+ - 交互式优先,支持历史记录快捷操作
50
+ - 服务管理:启动/停止/重启/启用/禁用/状态
51
+ - 日志查看:行数限制、持续跟随、时间过滤
52
+ - 历史缓存:最近服务/操作/搜索记录
53
+ - 中英文双语支持
54
+
55
+ ## Troubleshooting / 常见问题
56
+
57
+ ### Permission denied / 权限不足
58
+
59
+ Some actions require root permission. The plugin does not auto-sudo. Copy the suggestion it prints, for example:
60
+
61
+ ```bash
62
+ sudo systemctl restart nginx.service
63
+ ```
64
+
65
+ 部分操作需要 root 权限。插件不会自动提权,请复制提示的 sudo 命令执行。
66
+
67
+ ### systemctl not found / 找不到 systemctl
68
+
69
+ Ensure you are running on a Linux system with systemd installed and running.
70
+
71
+ 请确认运行环境为 Linux 且已安装并启用 systemd。
package/bin/systemd.js ADDED
@@ -0,0 +1,193 @@
1
+ #!/usr/bin/env node
2
+
3
+ const {t} = require('../lib/i18n');
4
+ const systemd = require('../lib/systemd');
5
+ const history = require('../lib/history');
6
+ const config = require('../lib/config');
7
+ const {interactiveMain} = require('../lib/interactive');
8
+
9
+ const args = process.argv.slice(2);
10
+
11
+ function showHelp() {
12
+ console.log(t('title') + '\n');
13
+ console.log(t('usage'));
14
+ console.log(' systemd [options] [command]\n');
15
+ console.log(t('options'));
16
+ console.log(' -h, --help ' + t('help'));
17
+ console.log(' -i, --interactive ' + t('interactive') + '\n');
18
+ console.log(t('commands'));
19
+ console.log(' list [--all] [--state active|inactive|failed] [--pattern <keyword>] ' + t('commandList'));
20
+ console.log(' start <service> ' + t('commandStart'));
21
+ console.log(' stop <service> ' + t('commandStop'));
22
+ console.log(' restart <service> ' + t('commandRestart'));
23
+ console.log(' enable <service> ' + t('commandEnable'));
24
+ console.log(' disable <service> ' + t('commandDisable'));
25
+ console.log(' logs <service> [--lines N] [--follow] [--since "..."] ' + t('commandLogs'));
26
+ console.log(' status <service> ' + t('commandStatus') + '\n');
27
+ console.log(t('examples'));
28
+ console.log(' systemd -i ' + t('exampleInteractive'));
29
+ console.log(' systemd list --state active ' + t('exampleList'));
30
+ console.log(' systemd list --all ' + t('exampleListAll'));
31
+ console.log(' systemd start ssh.service ' + t('exampleStart'));
32
+ console.log(' systemd logs ssh.service --lines 50 ' + t('exampleLogs'));
33
+ }
34
+
35
+ function printError(message) {
36
+ console.error(message);
37
+ }
38
+
39
+ function checkEnvOrExit() {
40
+ const env = systemd.checkEnvironment();
41
+ if (!env.ok) {
42
+ printError(env.message);
43
+ process.exit(1);
44
+ }
45
+ }
46
+
47
+ function requireServiceName(name) {
48
+ if (!name) {
49
+ printError(t('missingService'));
50
+ process.exit(2);
51
+ }
52
+ if (!systemd.validateServiceName(name)) {
53
+ printError(t('invalidServiceName'));
54
+ process.exit(2);
55
+ }
56
+ }
57
+
58
+ function handlePermissionSuggestion(result, action, service) {
59
+ if (!result || result.success) return;
60
+ if (systemd.detectPermissionError(result.message || result.stderr)) {
61
+ const suggestion = action === 'logs'
62
+ ? `sudo journalctl -u ${service}`
63
+ : action === 'list'
64
+ ? 'sudo systemctl list-units --type=service'
65
+ : `sudo systemctl ${action} ${service}`;
66
+ console.error(t('permissionDenied'));
67
+ console.error(t('suggestion') + ' ' + suggestion);
68
+ }
69
+ }
70
+
71
+ async function main() {
72
+ if (args.length === 0 || args.includes('-i') || args.includes('--interactive')) {
73
+ checkEnvOrExit();
74
+ await interactiveMain();
75
+ return;
76
+ }
77
+
78
+ if (args.includes('--help') || args.includes('-h')) {
79
+ showHelp();
80
+ process.exit(0);
81
+ }
82
+
83
+ checkEnvOrExit();
84
+
85
+ const command = args[0];
86
+
87
+ if (command === 'list') {
88
+ const all = args.includes('--all');
89
+ let state;
90
+ let pattern;
91
+ for (let i = 1; i < args.length; i += 1) {
92
+ if (args[i] === '--state' && args[i + 1]) {
93
+ state = args[i + 1];
94
+ }
95
+ if (args[i] === '--pattern' && args[i + 1]) {
96
+ pattern = args[i + 1];
97
+ }
98
+ }
99
+ const result = await systemd.listServices({all, state, pattern});
100
+ if (result.result.success) {
101
+ process.stdout.write(result.result.stdout);
102
+ } else {
103
+ printError(result.result.message || t('commandFailed'));
104
+ handlePermissionSuggestion(result.result, 'list', '');
105
+ process.exit(1);
106
+ }
107
+ return;
108
+ }
109
+
110
+ if (command === 'start' || command === 'stop' || command === 'restart' || command === 'enable' || command === 'disable' || command === 'status') {
111
+ const service = args[1];
112
+ requireServiceName(service);
113
+ let result;
114
+ switch (command) {
115
+ case 'start':
116
+ result = await systemd.startService(service);
117
+ break;
118
+ case 'stop':
119
+ result = await systemd.stopService(service);
120
+ break;
121
+ case 'restart':
122
+ result = await systemd.restartService(service);
123
+ break;
124
+ case 'enable':
125
+ result = await systemd.enableService(service);
126
+ break;
127
+ case 'disable':
128
+ result = await systemd.disableService(service);
129
+ break;
130
+ case 'status':
131
+ result = await systemd.statusService(service);
132
+ break;
133
+ }
134
+ if (result.success) {
135
+ if (result.stdout && !result.streamed) process.stdout.write(result.stdout);
136
+ } else {
137
+ printError(result.message || t('commandFailed'));
138
+ handlePermissionSuggestion(result, command, service);
139
+ process.exit(1);
140
+ }
141
+ history.addRecentService(service);
142
+ history.addAction(systemd.buildResult(command, service, result));
143
+ return;
144
+ }
145
+
146
+ if (command === 'logs') {
147
+ const service = args[1];
148
+ requireServiceName(service);
149
+ let lines;
150
+ let follow = false;
151
+ let since;
152
+ for (let i = 2; i < args.length; i += 1) {
153
+ if (args[i] === '--lines' && args[i + 1]) {
154
+ lines = Number(args[i + 1]);
155
+ }
156
+ if (args[i] === '--follow') {
157
+ follow = true;
158
+ }
159
+ if (args[i] === '--since' && args[i + 1]) {
160
+ since = args[i + 1];
161
+ }
162
+ }
163
+ const cfg = config.readConfig();
164
+ const result = await systemd.showLogs(service, {
165
+ lines: lines || cfg.logLines,
166
+ follow,
167
+ since
168
+ });
169
+ if (result.success) {
170
+ if (result.stdout && !result.streamed) process.stdout.write(result.stdout);
171
+ } else {
172
+ printError(result.message || t('commandFailed'));
173
+ handlePermissionSuggestion(result, 'logs', service);
174
+ process.exit(1);
175
+ }
176
+ history.addRecentService(service);
177
+ history.addAction(systemd.buildResult('logs', service, result));
178
+ return;
179
+ }
180
+
181
+ printError(t('invalidArgs'));
182
+ showHelp();
183
+ process.exit(2);
184
+ }
185
+
186
+ process.on('SIGINT', () => {
187
+ process.exit(0);
188
+ });
189
+
190
+ main().catch(error => {
191
+ console.error(t('commandFailed') + ': ' + error.message);
192
+ process.exit(1);
193
+ });
package/lib/config.js ADDED
@@ -0,0 +1,77 @@
1
+ const os = require('os');
2
+ const path = require('path');
3
+ const fs = require('fs');
4
+
5
+ function getSlothToolHome() {
6
+ return path.join(os.homedir(), '.slothtool');
7
+ }
8
+
9
+ function getPluginConfigsDir() {
10
+ return path.join(getSlothToolHome(), 'plugin-configs');
11
+ }
12
+
13
+ function getSystemdConfigPath() {
14
+ return path.join(getPluginConfigsDir(), 'systemd.json');
15
+ }
16
+
17
+ function ensureDir(dir) {
18
+ if (!fs.existsSync(dir)) {
19
+ fs.mkdirSync(dir, {recursive: true});
20
+ }
21
+ }
22
+
23
+ function getDefaultConfig() {
24
+ return {
25
+ logLines: 200,
26
+ confirmDangerous: true,
27
+ historyLimits: {
28
+ services: 20,
29
+ actions: 50,
30
+ searches: 20
31
+ }
32
+ };
33
+ }
34
+
35
+ function readConfig() {
36
+ const configPath = getSystemdConfigPath();
37
+ ensureDir(getPluginConfigsDir());
38
+
39
+ if (!fs.existsSync(configPath)) {
40
+ return getDefaultConfig();
41
+ }
42
+
43
+ try {
44
+ const content = fs.readFileSync(configPath, 'utf8');
45
+ const config = JSON.parse(content);
46
+ const defaultConfig = getDefaultConfig();
47
+ return {
48
+ logLines: config.logLines ?? defaultConfig.logLines,
49
+ confirmDangerous: config.confirmDangerous ?? defaultConfig.confirmDangerous,
50
+ historyLimits: {
51
+ ...defaultConfig.historyLimits,
52
+ ...(config.historyLimits || {})
53
+ }
54
+ };
55
+ } catch (error) {
56
+ console.error('Failed to read config:', error.message);
57
+ return getDefaultConfig();
58
+ }
59
+ }
60
+
61
+ function writeConfig(config) {
62
+ const configPath = getSystemdConfigPath();
63
+ ensureDir(getPluginConfigsDir());
64
+
65
+ try {
66
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf8');
67
+ } catch (error) {
68
+ console.error('Failed to write config:', error.message);
69
+ throw error;
70
+ }
71
+ }
72
+
73
+ module.exports = {
74
+ getDefaultConfig,
75
+ readConfig,
76
+ writeConfig
77
+ };
package/lib/history.js ADDED
@@ -0,0 +1,104 @@
1
+ const os = require('os');
2
+ const path = require('path');
3
+ const fs = require('fs');
4
+ const config = require('./config');
5
+
6
+ function getSlothToolHome() {
7
+ return path.join(os.homedir(), '.slothtool');
8
+ }
9
+
10
+ function getPluginConfigsDir() {
11
+ return path.join(getSlothToolHome(), 'plugin-configs');
12
+ }
13
+
14
+ function getHistoryPath() {
15
+ return path.join(getPluginConfigsDir(), 'systemd.history.json');
16
+ }
17
+
18
+ function ensureDir(dir) {
19
+ if (!fs.existsSync(dir)) {
20
+ fs.mkdirSync(dir, {recursive: true});
21
+ }
22
+ }
23
+
24
+ function getDefaultHistory() {
25
+ return {
26
+ recentServices: [],
27
+ recentSearches: [],
28
+ actions: []
29
+ };
30
+ }
31
+
32
+ function readHistory() {
33
+ const historyPath = getHistoryPath();
34
+ ensureDir(getPluginConfigsDir());
35
+
36
+ if (!fs.existsSync(historyPath)) {
37
+ return getDefaultHistory();
38
+ }
39
+
40
+ try {
41
+ const content = fs.readFileSync(historyPath, 'utf8');
42
+ const history = JSON.parse(content);
43
+ return {
44
+ recentServices: Array.isArray(history.recentServices) ? history.recentServices : [],
45
+ recentSearches: Array.isArray(history.recentSearches) ? history.recentSearches : [],
46
+ actions: Array.isArray(history.actions) ? history.actions : []
47
+ };
48
+ } catch (error) {
49
+ return getDefaultHistory();
50
+ }
51
+ }
52
+
53
+ function writeHistory(history) {
54
+ const historyPath = getHistoryPath();
55
+ ensureDir(getPluginConfigsDir());
56
+ fs.writeFileSync(historyPath, JSON.stringify(history, null, 2), 'utf8');
57
+ }
58
+
59
+ function trimList(list, limit) {
60
+ if (!Array.isArray(list)) {
61
+ return [];
62
+ }
63
+ return list.slice(0, limit);
64
+ }
65
+
66
+ function addRecentService(service) {
67
+ if (!service) return;
68
+ const cfg = config.readConfig();
69
+ const history = readHistory();
70
+ const newList = [service, ...history.recentServices.filter(item => item !== service)];
71
+ history.recentServices = trimList(newList, cfg.historyLimits.services);
72
+ writeHistory(history);
73
+ }
74
+
75
+ function addRecentSearch(search) {
76
+ if (!search) return;
77
+ const cfg = config.readConfig();
78
+ const history = readHistory();
79
+ const newList = [search, ...history.recentSearches.filter(item => item !== search)];
80
+ history.recentSearches = trimList(newList, cfg.historyLimits.searches);
81
+ writeHistory(history);
82
+ }
83
+
84
+ function addAction(action) {
85
+ if (!action) return;
86
+ const cfg = config.readConfig();
87
+ const history = readHistory();
88
+ const newList = [action, ...history.actions];
89
+ history.actions = trimList(newList, cfg.historyLimits.actions);
90
+ writeHistory(history);
91
+ }
92
+
93
+ function clearHistory() {
94
+ writeHistory(getDefaultHistory());
95
+ }
96
+
97
+ module.exports = {
98
+ readHistory,
99
+ writeHistory,
100
+ addRecentService,
101
+ addRecentSearch,
102
+ addAction,
103
+ clearHistory
104
+ };
package/lib/i18n.js ADDED
@@ -0,0 +1,204 @@
1
+ const os = require('os');
2
+ const path = require('path');
3
+ const fs = require('fs');
4
+
5
+ function getSettingsPath() {
6
+ return path.join(os.homedir(), '.slothtool', 'settings.json');
7
+ }
8
+
9
+ function getLanguage() {
10
+ try {
11
+ if (fs.existsSync(getSettingsPath())) {
12
+ const settings = JSON.parse(fs.readFileSync(getSettingsPath(), 'utf8'));
13
+ return settings.language || 'zh';
14
+ }
15
+ } catch (error) {
16
+ // ignore errors and fallback
17
+ }
18
+ return 'zh';
19
+ }
20
+
21
+ const messages = {
22
+ zh: {
23
+ title: 'systemd - 服务管理工具',
24
+ usage: '用法:',
25
+ options: '选项:',
26
+ commands: '命令:',
27
+ examples: '示例:',
28
+ help: '显示帮助信息',
29
+ interactive: '交互式模式',
30
+ commandList: '列出服务',
31
+ commandStart: '启动服务',
32
+ commandStop: '停止服务',
33
+ commandRestart: '重启服务',
34
+ commandEnable: '启用服务',
35
+ commandDisable: '禁用服务',
36
+ commandLogs: '查看服务日志',
37
+ commandStatus: '查看服务状态',
38
+ listAll: '包含未运行服务',
39
+ listState: '按状态过滤',
40
+ listPattern: '按关键词过滤',
41
+ logsLines: '显示行数',
42
+ logsFollow: '持续跟随日志',
43
+ logsSince: '起始时间',
44
+ exampleInteractive: '进入交互式模式',
45
+ exampleList: '列出运行中的服务',
46
+ exampleListAll: '列出所有服务',
47
+ exampleStart: '启动服务',
48
+ exampleLogs: '查看服务日志',
49
+ notLinux: '当前系统不支持 systemd(仅支持 Linux)',
50
+ systemctlNotFound: '未找到 systemctl 命令,请确认 systemd 已安装',
51
+ invalidServiceName: '无效的服务名称',
52
+ missingService: '缺少服务名称参数',
53
+ invalidArgs: '参数错误',
54
+ commandFailed: '命令执行失败',
55
+ permissionDenied: '权限不足,无法执行操作',
56
+ suggestion: '可尝试:',
57
+ noServicesFound: '未找到匹配的服务',
58
+ menuTitle: '请选择操作:',
59
+ menuQuickActions: '快速操作(历史记录)',
60
+ menuListChoose: '列表并选择服务',
61
+ menuStart: '启动服务',
62
+ menuStop: '停止服务',
63
+ menuRestart: '重启服务',
64
+ menuEnable: '启用服务',
65
+ menuDisable: '禁用服务',
66
+ menuLogs: '查看日志',
67
+ menuHistory: '历史记录',
68
+ menuSettings: '设置',
69
+ menuExit: '退出',
70
+ menuStatus: '查看服务状态',
71
+ promptSearch: '输入搜索关键词(可选):',
72
+ promptAction: '选择操作:',
73
+ quickActionRecentActions: '最近操作',
74
+ quickActionRecentService: '最近服务',
75
+ promptState: '选择状态过滤:',
76
+ stateAll: '全部',
77
+ stateActive: '运行中',
78
+ stateInactive: '未运行',
79
+ stateFailed: '失败',
80
+ promptService: '选择服务:',
81
+ promptServiceName: '请输入服务名称:',
82
+ promptConfirmStop: '确定要停止该服务吗?',
83
+ promptConfirmDisable: '确定要禁用该服务吗?',
84
+ promptConfirmRestart: '确定要重启该服务吗?',
85
+ promptLogLines: '日志行数:',
86
+ promptLogFollow: '是否持续跟随日志?',
87
+ promptLogSince: '起始时间(可选):',
88
+ historyEmpty: '暂无历史记录',
89
+ historyActionsTitle: '最近操作:',
90
+ historyServicesTitle: '最近服务:',
91
+ historySearchesTitle: '最近搜索:',
92
+ historyClearConfirm: '确定清空历史记录吗?',
93
+ historyCleared: '历史记录已清空',
94
+ settingsTitle: '设置',
95
+ settingsLogLines: '默认日志行数:',
96
+ settingsConfirmDangerous: '危险操作确认:',
97
+ settingsHistoryLimits: '历史记录上限:',
98
+ settingsSaved: '设置已保存',
99
+ actionSuccess: '操作成功',
100
+ actionFailed: '操作失败',
101
+ unknownError: '未知错误'
102
+ },
103
+ en: {
104
+ title: 'systemd - Service Management Tool',
105
+ usage: 'Usage:',
106
+ options: 'Options:',
107
+ commands: 'Commands:',
108
+ examples: 'Examples:',
109
+ help: 'Show help message',
110
+ interactive: 'Interactive mode',
111
+ commandList: 'List services',
112
+ commandStart: 'Start service',
113
+ commandStop: 'Stop service',
114
+ commandRestart: 'Restart service',
115
+ commandEnable: 'Enable service',
116
+ commandDisable: 'Disable service',
117
+ commandLogs: 'Show service logs',
118
+ commandStatus: 'Show service status',
119
+ listAll: 'Include inactive services',
120
+ listState: 'Filter by state',
121
+ listPattern: 'Filter by keyword',
122
+ logsLines: 'Number of lines',
123
+ logsFollow: 'Follow logs',
124
+ logsSince: 'Since time',
125
+ exampleInteractive: 'Enter interactive mode',
126
+ exampleList: 'List active services',
127
+ exampleListAll: 'List all services',
128
+ exampleStart: 'Start a service',
129
+ exampleLogs: 'Show service logs',
130
+ notLinux: 'systemd is only supported on Linux',
131
+ systemctlNotFound: 'systemctl not found. Please ensure systemd is installed',
132
+ invalidServiceName: 'Invalid service name',
133
+ missingService: 'Missing service name',
134
+ invalidArgs: 'Invalid arguments',
135
+ commandFailed: 'Command failed',
136
+ permissionDenied: 'Permission denied',
137
+ suggestion: 'Try:',
138
+ noServicesFound: 'No services matched',
139
+ menuTitle: 'Select an action:',
140
+ menuQuickActions: 'Quick actions (history)',
141
+ menuListChoose: 'List and choose service',
142
+ menuStart: 'Start service',
143
+ menuStop: 'Stop service',
144
+ menuRestart: 'Restart service',
145
+ menuEnable: 'Enable service',
146
+ menuDisable: 'Disable service',
147
+ menuLogs: 'View logs',
148
+ menuHistory: 'History',
149
+ menuSettings: 'Settings',
150
+ menuExit: 'Exit',
151
+ menuStatus: 'Show service status',
152
+ promptSearch: 'Search keyword (optional):',
153
+ promptAction: 'Select action:',
154
+ quickActionRecentActions: 'Recent actions',
155
+ quickActionRecentService: 'Recent services',
156
+ promptState: 'Select state filter:',
157
+ stateAll: 'All',
158
+ stateActive: 'Active',
159
+ stateInactive: 'Inactive',
160
+ stateFailed: 'Failed',
161
+ promptService: 'Select service:',
162
+ promptServiceName: 'Enter service name:',
163
+ promptConfirmStop: 'Are you sure you want to stop this service?',
164
+ promptConfirmDisable: 'Are you sure you want to disable this service?',
165
+ promptConfirmRestart: 'Are you sure you want to restart this service?',
166
+ promptLogLines: 'Log lines:',
167
+ promptLogFollow: 'Follow logs?',
168
+ promptLogSince: 'Since time (optional):',
169
+ historyEmpty: 'No history available',
170
+ historyActionsTitle: 'Recent actions:',
171
+ historyServicesTitle: 'Recent services:',
172
+ historySearchesTitle: 'Recent searches:',
173
+ historyClearConfirm: 'Clear history?',
174
+ historyCleared: 'History cleared',
175
+ settingsTitle: 'Settings',
176
+ settingsLogLines: 'Default log lines:',
177
+ settingsConfirmDangerous: 'Confirm dangerous actions:',
178
+ settingsHistoryLimits: 'History limits:',
179
+ settingsSaved: 'Settings saved',
180
+ actionSuccess: 'Action succeeded',
181
+ actionFailed: 'Action failed',
182
+ unknownError: 'Unknown error'
183
+ }
184
+ };
185
+
186
+ function t(key, params = {}) {
187
+ const lang = getLanguage();
188
+ const langMessages = messages[lang] || messages.zh;
189
+ let message = langMessages[key];
190
+ if (message === undefined) {
191
+ return key;
192
+ }
193
+ if (typeof message === 'string') {
194
+ return message.replace(/\{(\w+)\}/g, (match, param) => {
195
+ return params[param] !== undefined ? params[param] : match;
196
+ });
197
+ }
198
+ return message;
199
+ }
200
+
201
+ module.exports = {
202
+ t,
203
+ getLanguage
204
+ };
@@ -0,0 +1,458 @@
1
+ const prompts = require('prompts');
2
+ const {t} = require('./i18n');
3
+ const config = require('./config');
4
+ const history = require('./history');
5
+ const systemd = require('./systemd');
6
+
7
+ function formatServiceChoice(service) {
8
+ const description = `${service.load} ${service.active} ${service.sub}`;
9
+ const title = `${service.unit} - ${service.description}`;
10
+ return {title, value: service.unit, description};
11
+ }
12
+
13
+ function formatActionLabel(action) {
14
+ switch (action) {
15
+ case 'start':
16
+ return t('commandStart');
17
+ case 'stop':
18
+ return t('commandStop');
19
+ case 'restart':
20
+ return t('commandRestart');
21
+ case 'enable':
22
+ return t('commandEnable');
23
+ case 'disable':
24
+ return t('commandDisable');
25
+ case 'status':
26
+ return t('commandStatus');
27
+ case 'logs':
28
+ return t('commandLogs');
29
+ default:
30
+ return action;
31
+ }
32
+ }
33
+
34
+ function printResult(result, action, service) {
35
+ if (result.success) {
36
+ if (result.stdout && !result.streamed) {
37
+ process.stdout.write(result.stdout);
38
+ }
39
+ if (!result.streamed) {
40
+ console.log(t('actionSuccess'));
41
+ }
42
+ } else {
43
+ console.error(t('actionFailed') + ': ' + (result.message || t('unknownError')));
44
+ if (systemd.detectPermissionError(result.message || result.stderr)) {
45
+ console.error(t('permissionDenied'));
46
+ const suggestion = action === 'logs'
47
+ ? `sudo journalctl -u ${service}`
48
+ : action === 'list'
49
+ ? 'sudo systemctl list-units --type=service'
50
+ : `sudo systemctl ${action} ${service}`;
51
+ console.error(t('suggestion') + ' ' + suggestion);
52
+ }
53
+ }
54
+ }
55
+
56
+ async function chooseServiceByList() {
57
+ const search = await prompts({
58
+ type: 'text',
59
+ name: 'keyword',
60
+ message: t('promptSearch')
61
+ });
62
+ if (search.keyword) {
63
+ history.addRecentSearch(search.keyword);
64
+ }
65
+ const state = await prompts({
66
+ type: 'select',
67
+ name: 'state',
68
+ message: t('promptState'),
69
+ choices: [
70
+ {title: t('stateAll'), value: 'all'},
71
+ {title: t('stateActive'), value: 'active'},
72
+ {title: t('stateInactive'), value: 'inactive'},
73
+ {title: t('stateFailed'), value: 'failed'}
74
+ ]
75
+ });
76
+ const filterState = state.state === 'all' ? undefined : state.state;
77
+ const list = await systemd.listServices({
78
+ all: state.state === 'all',
79
+ state: filterState,
80
+ pattern: search.keyword || undefined,
81
+ forInteractive: true
82
+ });
83
+ if (!list.result.success) {
84
+ printResult(list.result, 'list', '');
85
+ return null;
86
+ }
87
+ if (list.services.length === 0) {
88
+ console.log(t('noServicesFound'));
89
+ return null;
90
+ }
91
+ const serviceChoice = await prompts({
92
+ type: 'select',
93
+ name: 'service',
94
+ message: t('promptService'),
95
+ choices: list.services.map(formatServiceChoice)
96
+ });
97
+ if (!serviceChoice.service) {
98
+ return null;
99
+ }
100
+ return serviceChoice.service;
101
+ }
102
+
103
+ async function inputServiceName() {
104
+ const response = await prompts({
105
+ type: 'text',
106
+ name: 'service',
107
+ message: t('promptServiceName'),
108
+ validate: value => systemd.validateServiceName(value) || t('invalidServiceName')
109
+ });
110
+ return response.service || null;
111
+ }
112
+
113
+ async function confirmIfDangerous(action) {
114
+ const cfg = config.readConfig();
115
+ if (!cfg.confirmDangerous) return true;
116
+ if (action === 'stop') {
117
+ const confirm = await prompts({
118
+ type: 'confirm',
119
+ name: 'ok',
120
+ message: t('promptConfirmStop'),
121
+ initial: false
122
+ });
123
+ return !!confirm.ok;
124
+ }
125
+ if (action === 'disable') {
126
+ const confirm = await prompts({
127
+ type: 'confirm',
128
+ name: 'ok',
129
+ message: t('promptConfirmDisable'),
130
+ initial: false
131
+ });
132
+ return !!confirm.ok;
133
+ }
134
+ if (action === 'restart') {
135
+ const confirm = await prompts({
136
+ type: 'confirm',
137
+ name: 'ok',
138
+ message: t('promptConfirmRestart'),
139
+ initial: true
140
+ });
141
+ return !!confirm.ok;
142
+ }
143
+ return true;
144
+ }
145
+
146
+ async function runAction(action, service) {
147
+ if (!service) return;
148
+ let result;
149
+ switch (action) {
150
+ case 'start':
151
+ result = await systemd.startService(service);
152
+ break;
153
+ case 'stop':
154
+ result = await systemd.stopService(service);
155
+ break;
156
+ case 'restart':
157
+ result = await systemd.restartService(service);
158
+ break;
159
+ case 'enable':
160
+ result = await systemd.enableService(service);
161
+ break;
162
+ case 'disable':
163
+ result = await systemd.disableService(service);
164
+ break;
165
+ case 'status':
166
+ result = await systemd.statusService(service);
167
+ break;
168
+ case 'logs':
169
+ result = await systemd.showLogs(service, {lines: config.readConfig().logLines});
170
+ break;
171
+ default:
172
+ return;
173
+ }
174
+ printResult(result, action, service);
175
+ if (result.success) {
176
+ history.addRecentService(service);
177
+ }
178
+ history.addAction(systemd.buildResult(action, service, result));
179
+ }
180
+
181
+ async function quickActionsMenu() {
182
+ const hist = history.readHistory();
183
+ const hasActions = hist.actions.length > 0;
184
+ const hasServices = hist.recentServices.length > 0;
185
+ if (!hasActions && !hasServices) {
186
+ console.log(t('historyEmpty'));
187
+ return;
188
+ }
189
+
190
+ const modeResponse = await prompts({
191
+ type: 'select',
192
+ name: 'mode',
193
+ message: t('menuQuickActions'),
194
+ choices: [
195
+ {title: t('quickActionRecentActions'), value: 'actions', disabled: !hasActions},
196
+ {title: t('quickActionRecentService'), value: 'service', disabled: !hasServices},
197
+ {title: t('menuExit'), value: 'exit'}
198
+ ]
199
+ });
200
+
201
+ if (!modeResponse.mode || modeResponse.mode === 'exit') {
202
+ return;
203
+ }
204
+
205
+ if (modeResponse.mode === 'actions') {
206
+ const actionChoices = hist.actions.map((entry, index) => {
207
+ const label = `${entry.time} - ${formatActionLabel(entry.action)} ${entry.service} (${entry.result})`;
208
+ return {title: label, value: index};
209
+ });
210
+ const response = await prompts({
211
+ type: 'select',
212
+ name: 'actionIndex',
213
+ message: t('quickActionRecentActions'),
214
+ choices: [
215
+ ...actionChoices,
216
+ {title: t('menuExit'), value: '__exit__'}
217
+ ]
218
+ });
219
+ if (response.actionIndex === undefined || response.actionIndex === '__exit__') return;
220
+ const selected = hist.actions[response.actionIndex];
221
+ if (!selected) return;
222
+ await runAction(selected.action, selected.service);
223
+ return;
224
+ }
225
+
226
+ if (modeResponse.mode === 'service') {
227
+ const serviceResponse = await prompts({
228
+ type: 'select',
229
+ name: 'service',
230
+ message: t('promptService'),
231
+ choices: hist.recentServices.slice(0, 10).map(service => ({
232
+ title: service,
233
+ value: service
234
+ }))
235
+ });
236
+ if (!serviceResponse.service) return;
237
+
238
+ const actionResponse = await prompts({
239
+ type: 'select',
240
+ name: 'action',
241
+ message: t('promptAction'),
242
+ choices: [
243
+ {title: t('commandStart'), value: 'start'},
244
+ {title: t('commandStop'), value: 'stop'},
245
+ {title: t('commandRestart'), value: 'restart'},
246
+ {title: t('commandEnable'), value: 'enable'},
247
+ {title: t('commandDisable'), value: 'disable'},
248
+ {title: t('commandStatus'), value: 'status'},
249
+ {title: t('commandLogs'), value: 'logs'}
250
+ ]
251
+ });
252
+ if (!actionResponse.action) return;
253
+
254
+ if (['stop', 'disable', 'restart'].includes(actionResponse.action)) {
255
+ const confirmed = await confirmIfDangerous(actionResponse.action);
256
+ if (!confirmed) return;
257
+ }
258
+
259
+ if (actionResponse.action === 'logs') {
260
+ await handleLogsFlow(serviceResponse.service);
261
+ return;
262
+ }
263
+
264
+ await runAction(actionResponse.action, serviceResponse.service);
265
+ }
266
+ }
267
+
268
+ async function historyMenu() {
269
+ const hist = history.readHistory();
270
+ if (!hist.actions.length && !hist.recentServices.length && !hist.recentSearches.length) {
271
+ console.log(t('historyEmpty'));
272
+ return;
273
+ }
274
+ console.log(t('historyActionsTitle'));
275
+ hist.actions.slice(0, 10).forEach(entry => {
276
+ console.log(` ${entry.time} ${formatActionLabel(entry.action)} ${entry.service} (${entry.result})`);
277
+ });
278
+ console.log('\n' + t('historyServicesTitle'));
279
+ hist.recentServices.slice(0, 10).forEach(service => console.log(' ' + service));
280
+ console.log('\n' + t('historySearchesTitle'));
281
+ hist.recentSearches.slice(0, 10).forEach(search => console.log(' ' + search));
282
+
283
+ const response = await prompts({
284
+ type: 'confirm',
285
+ name: 'clear',
286
+ message: t('historyClearConfirm'),
287
+ initial: false
288
+ });
289
+ if (response.clear) {
290
+ history.clearHistory();
291
+ console.log(t('historyCleared'));
292
+ }
293
+ }
294
+
295
+ async function settingsMenu() {
296
+ const cfg = config.readConfig();
297
+ const logLinesResponse = await prompts({
298
+ type: 'number',
299
+ name: 'logLines',
300
+ message: t('settingsLogLines'),
301
+ initial: cfg.logLines,
302
+ min: 1
303
+ });
304
+ const confirmResponse = await prompts({
305
+ type: 'confirm',
306
+ name: 'confirmDangerous',
307
+ message: t('settingsConfirmDangerous'),
308
+ initial: cfg.confirmDangerous
309
+ });
310
+ const historyResponse = await prompts({
311
+ type: 'number',
312
+ name: 'actionsLimit',
313
+ message: `${t('settingsHistoryLimits')} (actions)`,
314
+ initial: cfg.historyLimits.actions,
315
+ min: 1
316
+ });
317
+ const servicesResponse = await prompts({
318
+ type: 'number',
319
+ name: 'servicesLimit',
320
+ message: `${t('settingsHistoryLimits')} (services)`,
321
+ initial: cfg.historyLimits.services,
322
+ min: 1
323
+ });
324
+ const searchesResponse = await prompts({
325
+ type: 'number',
326
+ name: 'searchesLimit',
327
+ message: `${t('settingsHistoryLimits')} (searches)`,
328
+ initial: cfg.historyLimits.searches,
329
+ min: 1
330
+ });
331
+
332
+ const newConfig = {
333
+ logLines: logLinesResponse.logLines || cfg.logLines,
334
+ confirmDangerous: confirmResponse.confirmDangerous ?? cfg.confirmDangerous,
335
+ historyLimits: {
336
+ actions: historyResponse.actionsLimit || cfg.historyLimits.actions,
337
+ services: servicesResponse.servicesLimit || cfg.historyLimits.services,
338
+ searches: searchesResponse.searchesLimit || cfg.historyLimits.searches
339
+ }
340
+ };
341
+ config.writeConfig(newConfig);
342
+ console.log(t('settingsSaved'));
343
+ }
344
+
345
+ async function handleLogsFlow(service) {
346
+ const cfg = config.readConfig();
347
+ const linesResponse = await prompts({
348
+ type: 'number',
349
+ name: 'lines',
350
+ message: t('promptLogLines'),
351
+ initial: cfg.logLines,
352
+ min: 1
353
+ });
354
+ const followResponse = await prompts({
355
+ type: 'confirm',
356
+ name: 'follow',
357
+ message: t('promptLogFollow'),
358
+ initial: false
359
+ });
360
+ const sinceResponse = await prompts({
361
+ type: 'text',
362
+ name: 'since',
363
+ message: t('promptLogSince')
364
+ });
365
+ const result = await systemd.showLogs(service, {
366
+ lines: linesResponse.lines || cfg.logLines,
367
+ follow: !!followResponse.follow,
368
+ since: sinceResponse.since || undefined
369
+ });
370
+ printResult(result, 'logs', service);
371
+ if (result.success) {
372
+ history.addRecentService(service);
373
+ }
374
+ history.addAction(systemd.buildResult('logs', service, result));
375
+ }
376
+
377
+ async function interactiveMain() {
378
+ while (true) {
379
+ const response = await prompts({
380
+ type: 'select',
381
+ name: 'action',
382
+ message: t('menuTitle'),
383
+ choices: [
384
+ {title: t('menuQuickActions'), value: 'quick'},
385
+ {title: t('menuListChoose'), value: 'listChoose'},
386
+ {title: t('menuStart'), value: 'start'},
387
+ {title: t('menuStop'), value: 'stop'},
388
+ {title: t('menuRestart'), value: 'restart'},
389
+ {title: t('menuEnable'), value: 'enable'},
390
+ {title: t('menuDisable'), value: 'disable'},
391
+ {title: t('menuStatus'), value: 'status'},
392
+ {title: t('menuLogs'), value: 'logs'},
393
+ {title: t('menuHistory'), value: 'history'},
394
+ {title: t('menuSettings'), value: 'settings'},
395
+ {title: t('menuExit'), value: 'exit'}
396
+ ]
397
+ });
398
+
399
+ if (!response.action || response.action === 'exit') {
400
+ break;
401
+ }
402
+
403
+ if (response.action === 'quick') {
404
+ await quickActionsMenu();
405
+ } else if (response.action === 'listChoose') {
406
+ const service = await chooseServiceByList();
407
+ if (service) {
408
+ history.addRecentService(service);
409
+ console.log(service);
410
+ }
411
+ } else if (response.action === 'start') {
412
+ const service = await inputServiceName();
413
+ if (service) {
414
+ await runAction('start', service);
415
+ }
416
+ } else if (response.action === 'stop') {
417
+ const service = await inputServiceName();
418
+ if (service && await confirmIfDangerous('stop')) {
419
+ await runAction('stop', service);
420
+ }
421
+ } else if (response.action === 'restart') {
422
+ const service = await inputServiceName();
423
+ if (service && await confirmIfDangerous('restart')) {
424
+ await runAction('restart', service);
425
+ }
426
+ } else if (response.action === 'enable') {
427
+ const service = await inputServiceName();
428
+ if (service) {
429
+ await runAction('enable', service);
430
+ }
431
+ } else if (response.action === 'disable') {
432
+ const service = await inputServiceName();
433
+ if (service && await confirmIfDangerous('disable')) {
434
+ await runAction('disable', service);
435
+ }
436
+ } else if (response.action === 'status') {
437
+ const service = await inputServiceName();
438
+ if (service) {
439
+ await runAction('status', service);
440
+ }
441
+ } else if (response.action === 'logs') {
442
+ const service = await inputServiceName();
443
+ if (service) {
444
+ await handleLogsFlow(service);
445
+ }
446
+ } else if (response.action === 'history') {
447
+ await historyMenu();
448
+ } else if (response.action === 'settings') {
449
+ await settingsMenu();
450
+ }
451
+
452
+ console.log('');
453
+ }
454
+ }
455
+
456
+ module.exports = {
457
+ interactiveMain
458
+ };
package/lib/systemd.js ADDED
@@ -0,0 +1,184 @@
1
+ const {spawn, spawnSync} = require('child_process');
2
+ const {t} = require('./i18n');
3
+
4
+ const SAFE_SERVICE_RE = /^[A-Za-z0-9@._:-]+$/;
5
+
6
+ function validateServiceName(name) {
7
+ return typeof name === 'string' && name.length > 0 && SAFE_SERVICE_RE.test(name);
8
+ }
9
+
10
+ function isLinux() {
11
+ return process.platform === 'linux';
12
+ }
13
+
14
+ function isSystemctlAvailable() {
15
+ const result = spawnSync('systemctl', ['--version'], {encoding: 'utf8'});
16
+ return result.status === 0;
17
+ }
18
+
19
+ function checkEnvironment() {
20
+ if (!isLinux()) {
21
+ return {ok: false, message: t('notLinux')};
22
+ }
23
+ if (!isSystemctlAvailable()) {
24
+ return {ok: false, message: t('systemctlNotFound')};
25
+ }
26
+ return {ok: true};
27
+ }
28
+
29
+ function buildResult(action, service, result) {
30
+ return {
31
+ time: new Date().toISOString(),
32
+ action,
33
+ service,
34
+ result: result.success ? 'success' : 'failed',
35
+ code: result.code ?? 1,
36
+ message: result.message || ''
37
+ };
38
+ }
39
+
40
+ function runCommand(command, args, options = {}) {
41
+ const streamOutput = !!options.streamOutput;
42
+ return new Promise((resolve) => {
43
+ const child = spawn(command, args, {stdio: ['ignore', 'pipe', 'pipe']});
44
+ let stdout = '';
45
+ let stderr = '';
46
+
47
+ if (child.stdout) {
48
+ child.stdout.on('data', (data) => {
49
+ const text = data.toString();
50
+ stdout += text;
51
+ if (streamOutput) {
52
+ process.stdout.write(text);
53
+ }
54
+ });
55
+ }
56
+
57
+ if (child.stderr) {
58
+ child.stderr.on('data', (data) => {
59
+ const text = data.toString();
60
+ stderr += text;
61
+ if (streamOutput) {
62
+ process.stderr.write(text);
63
+ }
64
+ });
65
+ }
66
+
67
+ child.on('close', (code) => {
68
+ const exitCode = code ?? 1;
69
+ resolve({
70
+ success: exitCode === 0,
71
+ code: exitCode,
72
+ stdout,
73
+ stderr,
74
+ message: stderr || stdout,
75
+ streamed: streamOutput
76
+ });
77
+ });
78
+
79
+ child.on('error', (error) => {
80
+ resolve({
81
+ success: false,
82
+ code: 1,
83
+ stdout,
84
+ stderr,
85
+ message: error.message,
86
+ streamed: streamOutput
87
+ });
88
+ });
89
+ });
90
+ }
91
+
92
+ function detectPermissionError(text) {
93
+ if (!text) return false;
94
+ const lowered = text.toLowerCase();
95
+ return lowered.includes('permission denied') ||
96
+ lowered.includes('access denied') ||
97
+ lowered.includes('authentication is required');
98
+ }
99
+
100
+ async function listServices(options = {}) {
101
+ const args = ['list-units', '--type=service', '--no-pager'];
102
+ if (options.all) {
103
+ args.push('--all');
104
+ }
105
+ if (options.state) {
106
+ args.push(`--state=${options.state}`);
107
+ }
108
+ if (options.pattern) {
109
+ args.push(`--pattern=${options.pattern}`);
110
+ }
111
+ if (options.forInteractive) {
112
+ args.push('--no-legend');
113
+ }
114
+ const result = await runCommand('systemctl', args);
115
+ if (options.forInteractive && result.success) {
116
+ const lines = result.stdout.split('\n').filter(line => line.trim());
117
+ const services = lines.map(line => {
118
+ const parts = line.split(/\s+/);
119
+ const unit = parts[0];
120
+ const load = parts[1];
121
+ const active = parts[2];
122
+ const sub = parts[3];
123
+ const description = parts.slice(4).join(' ');
124
+ return {unit, load, active, sub, description};
125
+ });
126
+ return {result, services};
127
+ }
128
+ return {result, services: []};
129
+ }
130
+
131
+ function statusService(service) {
132
+ return runCommand('systemctl', ['status', service, '--no-pager'], {streamOutput: true});
133
+ }
134
+
135
+ function startService(service) {
136
+ return runCommand('systemctl', ['start', service]);
137
+ }
138
+
139
+ function stopService(service) {
140
+ return runCommand('systemctl', ['stop', service]);
141
+ }
142
+
143
+ function restartService(service) {
144
+ return runCommand('systemctl', ['restart', service]);
145
+ }
146
+
147
+ function enableService(service) {
148
+ return runCommand('systemctl', ['enable', service]);
149
+ }
150
+
151
+ function disableService(service) {
152
+ return runCommand('systemctl', ['disable', service]);
153
+ }
154
+
155
+ function showLogs(service, options = {}) {
156
+ const args = ['-u', service, '--no-pager'];
157
+ if (options.lines) {
158
+ args.push('-n', String(options.lines));
159
+ }
160
+ if (options.follow) {
161
+ args.push('-f');
162
+ }
163
+ if (options.since) {
164
+ args.push('--since', options.since);
165
+ }
166
+ return runCommand('journalctl', args, {streamOutput: !!options.follow});
167
+ }
168
+
169
+ module.exports = {
170
+ SAFE_SERVICE_RE,
171
+ validateServiceName,
172
+ isSystemctlAvailable,
173
+ checkEnvironment,
174
+ buildResult,
175
+ detectPermissionError,
176
+ listServices,
177
+ statusService,
178
+ startService,
179
+ stopService,
180
+ restartService,
181
+ enableService,
182
+ disableService,
183
+ showLogs
184
+ };
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@holic512/plugin-systemd",
3
+ "version": "1.0.1",
4
+ "description": "Interactive systemd service management tool",
5
+ "main": "lib/systemd.js",
6
+ "bin": {
7
+ "systemd": "bin/systemd.js"
8
+ },
9
+ "slothtool": {
10
+ "interactive": true,
11
+ "interactiveFlag": "-i"
12
+ },
13
+ "scripts": {
14
+ "test": "echo \"Error: no test specified\" && exit 1"
15
+ },
16
+ "keywords": [
17
+ "slothtool",
18
+ "plugin",
19
+ "systemd",
20
+ "service",
21
+ "journalctl"
22
+ ],
23
+ "author": "holic512",
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "https://github.com/holic512/SlothTool.git",
27
+ "directory": "packages/plugin-systemd"
28
+ },
29
+ "homepage": "https://github.com/holic512/SlothTool#readme",
30
+ "bugs": {
31
+ "url": "https://github.com/holic512/SlothTool/issues"
32
+ },
33
+ "os": [
34
+ "linux"
35
+ ],
36
+ "license": "ISC",
37
+ "dependencies": {
38
+ "prompts": "^2.4.2"
39
+ }
40
+ }