unitup 0.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/src/systemd.js ADDED
@@ -0,0 +1,506 @@
1
+ import { execFile, spawn } from 'node:child_process';
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import {
6
+ sanitizeServiceName,
7
+ getUnitFilename,
8
+ formatRelativeTime
9
+ } from './utils.js';
10
+ import {
11
+ getUserUnitDir,
12
+ getUnitPath,
13
+ unitFileExists,
14
+ deleteUnitFile,
15
+ listUnitFiles,
16
+ writeUnitFile,
17
+ parseUnitContent
18
+ } from './unit.js';
19
+
20
+ /**
21
+ * Default runner for executing commands via child_process.execFile.
22
+ * Can be mocked during testing.
23
+ *
24
+ * @type {(cmd: string, args: string[], opts?: any) => Promise<{ stdout: string, stderr: string, code: number }>}
25
+ */
26
+ let commandRunner = defaultExecFileRunner;
27
+
28
+ function defaultExecFileRunner(cmd, args, opts = {}) {
29
+ return new Promise((resolve, reject) => {
30
+ execFile(cmd, args, { ...opts, encoding: 'utf8' }, (err, stdout, stderr) => {
31
+ const code = err ? (typeof err.code === 'number' ? err.code : 1) : 0;
32
+ resolve({ stdout: stdout || '', stderr: stderr || '', code });
33
+ });
34
+ });
35
+ }
36
+
37
+ /**
38
+ * Overrides the internal command runner for test mocking.
39
+ *
40
+ * @param {typeof commandRunner} runner
41
+ */
42
+ export function setCommandRunner(runner) {
43
+ commandRunner = runner;
44
+ }
45
+
46
+ /**
47
+ * Resets the command runner to default child_process.execFile implementation.
48
+ */
49
+ export function resetCommandRunner() {
50
+ commandRunner = defaultExecFileRunner;
51
+ }
52
+
53
+ /**
54
+ * Executes a command safely without invoking a shell.
55
+ *
56
+ * @param {string} cmd
57
+ * @param {string[]} args
58
+ * @param {any} [opts]
59
+ */
60
+ export async function runCommand(cmd, args, opts) {
61
+ return commandRunner(cmd, args, opts);
62
+ }
63
+
64
+ // ---------------------------------------------------------------------------
65
+ // System Diagnostics & Doctor checks
66
+ // ---------------------------------------------------------------------------
67
+
68
+ export function isLinux() {
69
+ return process.platform === 'linux';
70
+ }
71
+
72
+ export async function isSystemctlAvailable() {
73
+ try {
74
+ const res = await runCommand('systemctl', ['--version']);
75
+ return res.code === 0;
76
+ } catch {
77
+ return false;
78
+ }
79
+ }
80
+
81
+ export async function isSystemdPID1() {
82
+ if (!isLinux()) return false;
83
+ try {
84
+ if (fs.existsSync('/run/systemd/system')) {
85
+ return true;
86
+ }
87
+ if (fs.existsSync('/proc/1/comm')) {
88
+ const comm = fs.readFileSync('/proc/1/comm', 'utf8').trim();
89
+ return comm === 'systemd' || comm === 'init';
90
+ }
91
+ } catch {
92
+ // ignore
93
+ }
94
+ return false;
95
+ }
96
+
97
+ export async function isUserSystemdAvailable() {
98
+ try {
99
+ const res = await runCommand('systemctl', ['--user', 'is-system-running']);
100
+ // exit code 0 (running), 1 (degraded) or stderr non-fatal means user bus is accessible
101
+ if (res.stderr && res.stderr.includes('Failed to connect to bus')) {
102
+ return false;
103
+ }
104
+ return res.code === 0 || res.code === 1 || res.stdout.length > 0;
105
+ } catch {
106
+ return false;
107
+ }
108
+ }
109
+
110
+ export async function isUserUnitDirWritable() {
111
+ const dir = getUserUnitDir();
112
+ try {
113
+ if (!fs.existsSync(dir)) {
114
+ fs.mkdirSync(dir, { recursive: true });
115
+ }
116
+ fs.accessSync(dir, fs.constants.W_OK);
117
+ return true;
118
+ } catch {
119
+ return false;
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Finds and validates Node.js executable path safely using process.execPath, which, or command -v.
125
+ *
126
+ * @param {string} [customPath]
127
+ * @returns {Promise<string|null>}
128
+ */
129
+ export async function findNodeExecutable(customPath) {
130
+ if (customPath) {
131
+ const absPath = path.resolve(process.cwd(), customPath);
132
+ try {
133
+ if (fs.existsSync(absPath)) {
134
+ fs.accessSync(absPath, fs.constants.X_OK);
135
+ return absPath;
136
+ }
137
+ } catch {
138
+ return null;
139
+ }
140
+ return null;
141
+ }
142
+
143
+ // 1. Try process.execPath
144
+ if (process.execPath && fs.existsSync(process.execPath)) {
145
+ try {
146
+ fs.accessSync(process.execPath, fs.constants.X_OK);
147
+ return process.execPath;
148
+ } catch {
149
+ // ignore
150
+ }
151
+ }
152
+
153
+ // 2. Try which node
154
+ try {
155
+ const whichCmd = process.platform === 'win32' ? 'where' : 'which';
156
+ const res = await runCommand(whichCmd, ['node']);
157
+ if (res.code === 0 && res.stdout.trim()) {
158
+ const p = res.stdout.trim().split('\n')[0].trim();
159
+ if (fs.existsSync(p)) {
160
+ return p;
161
+ }
162
+ }
163
+ } catch {
164
+ // ignore
165
+ }
166
+
167
+ return null;
168
+ }
169
+
170
+ /**
171
+ * Checks detailed Node.js runtime diagnostics.
172
+ *
173
+ * @param {string} [customNodePath]
174
+ * @returns {Promise<Object>}
175
+ */
176
+ export async function checkNodeDiagnostics(customNodePath) {
177
+ const result = {
178
+ found: false,
179
+ executable: false,
180
+ execPath: '',
181
+ whichPath: '',
182
+ version: '',
183
+ inPath: false,
184
+ error: null,
185
+ solution: null
186
+ };
187
+
188
+ // Check which node
189
+ try {
190
+ const whichCmd = process.platform === 'win32' ? 'where' : 'which';
191
+ const res = await runCommand(whichCmd, ['node']);
192
+ if (res.code === 0 && res.stdout.trim()) {
193
+ result.whichPath = res.stdout.trim().split('\n')[0].trim();
194
+ result.inPath = true;
195
+ }
196
+ } catch {
197
+ result.inPath = false;
198
+ }
199
+
200
+ const resolvedNode = await findNodeExecutable(customNodePath);
201
+ if (!resolvedNode) {
202
+ if (result.whichPath && fs.existsSync(result.whichPath)) {
203
+ result.execPath = result.whichPath;
204
+ result.found = true;
205
+ result.executable = false;
206
+ result.error = 'Node.js binary exists but is not executable';
207
+ result.solution = `Check permissions:\n chmod +x $(which node)`;
208
+ return result;
209
+ }
210
+
211
+ result.found = false;
212
+ result.error = 'Node.js not found in PATH';
213
+ result.solution = `Install Node.js:\n https://nodejs.org\nor via package manager:\n sudo apt install nodejs`;
214
+ return result;
215
+ }
216
+
217
+ result.execPath = resolvedNode;
218
+ result.found = true;
219
+
220
+ // Test executing node -v
221
+ try {
222
+ const res = await runCommand(resolvedNode, ['-v']);
223
+ if (res.code === 0 && res.stdout.trim()) {
224
+ result.version = res.stdout.trim();
225
+ result.executable = true;
226
+ } else {
227
+ result.executable = false;
228
+ result.error = 'Node.js binary exists but fails execution';
229
+ result.solution = `Check Node.js installation or explicit path:\n unitup add app.js --node ${resolvedNode}`;
230
+ }
231
+ } catch (err) {
232
+ result.executable = false;
233
+ result.error = `Node.js execution failed: ${err.message}`;
234
+ result.solution = `Try setting explicit path:\n unitup add app.js --node ${resolvedNode}`;
235
+ }
236
+
237
+ return result;
238
+ }
239
+
240
+ export async function checkUserLinger() {
241
+ const username = os.userInfo().username;
242
+ // Method 1: check /var/lib/systemd/linger/<user>
243
+ try {
244
+ if (fs.existsSync(`/var/lib/systemd/linger/${username}`)) {
245
+ return true;
246
+ }
247
+ } catch {
248
+ // ignore
249
+ }
250
+ // Method 2: loginctl show-user $USER --property=Linger
251
+ try {
252
+ const res = await runCommand('loginctl', ['show-user', username, '--property=Linger']);
253
+ if (res.code === 0 && res.stdout.includes('Linger=yes')) {
254
+ return true;
255
+ }
256
+ } catch {
257
+ // ignore
258
+ }
259
+ return false;
260
+ }
261
+
262
+ // ---------------------------------------------------------------------------
263
+ // Systemd Operations
264
+ // ---------------------------------------------------------------------------
265
+
266
+ export async function daemonReload() {
267
+ const res = await runCommand('systemctl', ['--user', 'daemon-reload']);
268
+ if (res.code !== 0) {
269
+ throw new Error(`Failed to reload systemd daemon: ${res.stderr || res.stdout}`);
270
+ }
271
+ return true;
272
+ }
273
+
274
+ export async function resetFailed() {
275
+ await runCommand('systemctl', ['--user', 'reset-failed']);
276
+ }
277
+
278
+ export async function addService(opts) {
279
+ const safeName = sanitizeServiceName(opts.name);
280
+ const { path: unitPath } = writeUnitFile({ ...opts, name: safeName });
281
+
282
+ await daemonReload();
283
+
284
+ if (opts.start) {
285
+ await startService(safeName, true);
286
+ }
287
+
288
+ return { name: safeName, unitPath };
289
+ }
290
+
291
+ export async function startService(name, enable = false) {
292
+ const safeName = sanitizeServiceName(name);
293
+ if (!unitFileExists(safeName)) {
294
+ throw new Error(`Service "${safeName}" does not exist.\nCreate it with: unitup add <script> --name ${safeName}`);
295
+ }
296
+
297
+ const args = enable
298
+ ? ['--user', 'enable', '--now', getUnitFilename(safeName)]
299
+ : ['--user', 'start', getUnitFilename(safeName)];
300
+
301
+ const res = await runCommand('systemctl', args);
302
+ if (res.code !== 0) {
303
+ throw new Error(`Failed to start service "${safeName}": ${res.stderr || res.stdout}`);
304
+ }
305
+ return true;
306
+ }
307
+
308
+ export async function stopService(name) {
309
+ const safeName = sanitizeServiceName(name);
310
+ if (!unitFileExists(safeName)) {
311
+ throw new Error(`Service "${safeName}" does not exist.`);
312
+ }
313
+
314
+ const res = await runCommand('systemctl', ['--user', 'stop', getUnitFilename(safeName)]);
315
+ if (res.code !== 0) {
316
+ throw new Error(`Failed to stop service "${safeName}": ${res.stderr || res.stdout}`);
317
+ }
318
+ return true;
319
+ }
320
+
321
+ export async function restartService(name) {
322
+ const safeName = sanitizeServiceName(name);
323
+ if (!unitFileExists(safeName)) {
324
+ throw new Error(`Service "${safeName}" does not exist.`);
325
+ }
326
+
327
+ const res = await runCommand('systemctl', ['--user', 'restart', getUnitFilename(safeName)]);
328
+ if (res.code !== 0) {
329
+ throw new Error(`Failed to restart service "${safeName}": ${res.stderr || res.stdout}`);
330
+ }
331
+ return true;
332
+ }
333
+
334
+ export async function removeService(name) {
335
+ const safeName = sanitizeServiceName(name);
336
+ if (!unitFileExists(safeName)) {
337
+ throw new Error(`Service "${safeName}" does not exist.`);
338
+ }
339
+
340
+ // Attempt disable and stop
341
+ await runCommand('systemctl', ['--user', 'disable', '--now', getUnitFilename(safeName)]);
342
+
343
+ deleteUnitFile(safeName);
344
+
345
+ await daemonReload();
346
+ await resetFailed();
347
+ return true;
348
+ }
349
+
350
+ export async function getServiceShow(name) {
351
+ const safeName = sanitizeServiceName(name);
352
+ const unitFilename = getUnitFilename(safeName);
353
+
354
+ const res = await runCommand('systemctl', ['--user', 'show', unitFilename]);
355
+ const info = {};
356
+
357
+ if (res.stdout) {
358
+ const lines = res.stdout.split('\n');
359
+ for (const line of lines) {
360
+ const idx = line.indexOf('=');
361
+ if (idx !== -1) {
362
+ const key = line.slice(0, idx).trim();
363
+ const val = line.slice(idx + 1).trim();
364
+ info[key] = val;
365
+ }
366
+ }
367
+ }
368
+
369
+ return info;
370
+ }
371
+
372
+ export async function getServiceStatusRaw(name) {
373
+ const safeName = sanitizeServiceName(name);
374
+ const res = await runCommand('systemctl', ['--user', 'status', getUnitFilename(safeName)]);
375
+ return res.stdout || res.stderr;
376
+ }
377
+
378
+ export async function getServiceStatus(name) {
379
+ const safeName = sanitizeServiceName(name);
380
+ if (!unitFileExists(safeName)) {
381
+ throw new Error(`Service "${safeName}" does not exist.`);
382
+ }
383
+
384
+ const show = await getServiceShow(safeName);
385
+
386
+ // Read unit file content for script and working dir
387
+ let script = 'unknown';
388
+ let cwd = 'unknown';
389
+
390
+ const unitPath = getUnitPath(safeName);
391
+ try {
392
+ const content = fs.readFileSync(unitPath, 'utf8');
393
+ const parsed = parseUnitContent(content);
394
+ if (parsed.script) script = parsed.script;
395
+ if (parsed.cwd) cwd = parsed.cwd;
396
+ } catch {
397
+ // ignore
398
+ }
399
+
400
+ let status = 'stopped';
401
+ const activeState = show.ActiveState || 'unknown';
402
+ const subState = show.SubState || 'unknown';
403
+
404
+ if (activeState === 'active' && subState === 'running') {
405
+ status = 'running';
406
+ } else if (activeState === 'active') {
407
+ status = subState;
408
+ } else if (activeState === 'failed') {
409
+ status = 'failed';
410
+ } else if (activeState === 'inactive') {
411
+ status = 'stopped';
412
+ } else {
413
+ status = activeState;
414
+ }
415
+
416
+ const pid = show.MainPID && show.MainPID !== '0' ? show.MainPID : '-';
417
+ const restarts = show.NRestarts ?? '0';
418
+ const startedRaw = show.ActiveEnterTimestamp;
419
+ const started = startedRaw && startedRaw !== '0' && startedRaw !== 'n/a'
420
+ ? formatRelativeTime(startedRaw)
421
+ : 'never';
422
+
423
+ return {
424
+ name: safeName,
425
+ unitFile: getUnitFilename(safeName),
426
+ status,
427
+ activeState,
428
+ subState,
429
+ pid,
430
+ restarts,
431
+ started,
432
+ startedRaw,
433
+ script,
434
+ cwd
435
+ };
436
+ }
437
+
438
+ export async function listServices() {
439
+ const units = listUnitFiles();
440
+ const result = [];
441
+
442
+ for (const unit of units) {
443
+ try {
444
+ const show = await getServiceShow(unit.name);
445
+ const activeState = show.ActiveState || 'inactive';
446
+ const subState = show.SubState || 'dead';
447
+ const unitFileState = show.UnitFileState || 'unknown';
448
+
449
+ let status = 'stopped';
450
+ if (activeState === 'active' && subState === 'running') {
451
+ status = 'running';
452
+ } else if (activeState === 'failed') {
453
+ status = 'failed';
454
+ } else if (activeState === 'active') {
455
+ status = subState;
456
+ } else {
457
+ status = 'stopped';
458
+ }
459
+
460
+ const enabled = unitFileState.startsWith('enabled') ? 'yes' : 'no';
461
+
462
+ result.push({
463
+ name: unit.name,
464
+ status,
465
+ enabled
466
+ });
467
+ } catch {
468
+ result.push({
469
+ name: unit.name,
470
+ status: 'unknown',
471
+ enabled: 'unknown'
472
+ });
473
+ }
474
+ }
475
+
476
+ return result;
477
+ }
478
+
479
+ export async function runJournalctlLogs(name, opts = {}) {
480
+ const safeName = sanitizeServiceName(name);
481
+ const unitFilename = getUnitFilename(safeName);
482
+
483
+ const args = ['--user', '-u', unitFilename];
484
+
485
+ if (opts.follow) {
486
+ args.push('-f');
487
+ } else {
488
+ args.push('--no-pager');
489
+ const lines = opts.lines ? String(opts.lines) : '100';
490
+ args.push('-n', lines);
491
+ }
492
+
493
+ if (opts.follow) {
494
+ // Spawn stream for follow mode
495
+ try {
496
+ const child = spawn('journalctl', args, { stdio: 'inherit', shell: false });
497
+ child.on('error', () => {});
498
+ return child;
499
+ } catch {
500
+ return null;
501
+ }
502
+ } else {
503
+ const res = await runCommand('journalctl', args);
504
+ return res.stdout || res.stderr || 'No logs found.';
505
+ }
506
+ }
package/src/unit.js ADDED
@@ -0,0 +1,207 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import os from 'node:os';
4
+ import {
5
+ sanitizeServiceName,
6
+ getUnitFilename,
7
+ formatSystemdEnv,
8
+ escapeExecArg,
9
+ resolveAbsolutePath
10
+ } from './utils.js';
11
+
12
+ /**
13
+ * Returns the path to the systemd user service directory (~/.config/systemd/user).
14
+ *
15
+ * @returns {string}
16
+ */
17
+ export function getUserUnitDir() {
18
+ const xdgConfig = process.env.XDG_CONFIG_HOME;
19
+ if (xdgConfig) {
20
+ return path.join(xdgConfig, 'systemd', 'user');
21
+ }
22
+ return path.join(os.homedir(), '.config', 'systemd', 'user');
23
+ }
24
+
25
+ /**
26
+ * Returns the full file path for a given service unit.
27
+ *
28
+ * @param {string} name
29
+ * @returns {string}
30
+ */
31
+ export function getUnitPath(name) {
32
+ const filename = getUnitFilename(name);
33
+ return path.join(getUserUnitDir(), filename);
34
+ }
35
+
36
+ /**
37
+ * Checks if a unit file exists for the given service name.
38
+ *
39
+ * @param {string} name
40
+ * @returns {boolean}
41
+ */
42
+ export function unitFileExists(name) {
43
+ const unitPath = getUnitPath(name);
44
+ return fs.existsSync(unitPath);
45
+ }
46
+
47
+ /**
48
+ * Generates the text content of a systemd unit file.
49
+ *
50
+ * @param {Object} opts
51
+ * @param {string} opts.name - Service name
52
+ * @param {string} opts.script - Absolute path to script
53
+ * @param {string} [opts.cwd] - Working directory path
54
+ * @param {string} [opts.nodePath] - Absolute path to Node executable
55
+ * @param {Record<string, string>} [opts.env] - Environment variables object
56
+ * @param {string} [opts.envFile] - Path to environment file
57
+ * @param {string} [opts.restart] - Restart policy (default: 'on-failure')
58
+ * @param {Array<string>} [opts.args] - Extra script arguments
59
+ * @returns {string}
60
+ */
61
+ export function generateUnitContent(opts) {
62
+ const safeName = sanitizeServiceName(opts.name);
63
+ const scriptPath = resolveAbsolutePath(opts.script);
64
+ const cwd = opts.cwd ? resolveAbsolutePath(opts.cwd) : path.dirname(scriptPath);
65
+ const nodeExec = opts.nodePath ? resolveAbsolutePath(opts.nodePath) : process.execPath;
66
+ const restartPolicy = opts.restart || 'on-failure';
67
+
68
+ const execArgs = [nodeExec, scriptPath];
69
+ if (Array.isArray(opts.args) && opts.args.length > 0) {
70
+ execArgs.push(...opts.args);
71
+ }
72
+
73
+ const execStartLine = execArgs.map(escapeExecArg).join(' ');
74
+
75
+ const nodeBinDir = path.dirname(nodeExec);
76
+ const defaultPath = [nodeBinDir, '/usr/local/bin', '/usr/bin', '/bin']
77
+ .filter((p, i, self) => p && self.indexOf(p) === i)
78
+ .join(':');
79
+
80
+ const serviceLines = [
81
+ '[Unit]',
82
+ `Description=unitup service: ${safeName}`,
83
+ 'After=network.target',
84
+ '',
85
+ '[Service]',
86
+ 'Type=simple',
87
+ `WorkingDirectory=${cwd}`,
88
+ `ExecStart=${execStartLine}`,
89
+ `Restart=${restartPolicy}`,
90
+ 'RestartSec=3'
91
+ ];
92
+
93
+ const envObj = opts.env && typeof opts.env === 'object' ? { ...opts.env } : {};
94
+ if (!envObj.PATH) {
95
+ envObj.PATH = defaultPath;
96
+ }
97
+
98
+ if (opts.envFile) {
99
+ const absEnvFile = resolveAbsolutePath(opts.envFile);
100
+ serviceLines.push(`EnvironmentFile=${absEnvFile}`);
101
+ }
102
+
103
+ for (const [key, val] of Object.entries(envObj)) {
104
+ serviceLines.push(`Environment=${formatSystemdEnv(key, val)}`);
105
+ }
106
+
107
+ serviceLines.push('', '[Install]', 'WantedBy=default.target', '');
108
+
109
+ return serviceLines.join('\n');
110
+ }
111
+
112
+ /**
113
+ * Creates or updates a systemd unit file on disk.
114
+ *
115
+ * @param {Object} opts - Same options as generateUnitContent
116
+ * @returns {{ path: string, content: string }}
117
+ */
118
+ export function writeUnitFile(opts) {
119
+ const safeName = sanitizeServiceName(opts.name);
120
+ const unitDir = getUserUnitDir();
121
+
122
+ if (!fs.existsSync(unitDir)) {
123
+ fs.mkdirSync(unitDir, { recursive: true });
124
+ }
125
+
126
+ const unitPath = getUnitPath(safeName);
127
+ const content = generateUnitContent({ ...opts, name: safeName });
128
+
129
+ fs.writeFileSync(unitPath, content, 'utf8');
130
+
131
+ return { path: unitPath, content };
132
+ }
133
+
134
+ /**
135
+ * Removes a unit file from disk.
136
+ *
137
+ * @param {string} name
138
+ * @returns {boolean} True if file existed and was removed
139
+ */
140
+ export function deleteUnitFile(name) {
141
+ const unitPath = getUnitPath(name);
142
+ if (fs.existsSync(unitPath)) {
143
+ fs.unlinkSync(unitPath);
144
+ return true;
145
+ }
146
+ return false;
147
+ }
148
+
149
+ /**
150
+ * Lists all unitup-*.service files in the user unit directory.
151
+ *
152
+ * @returns {Array<{ name: string, filename: string, path: string }>}
153
+ */
154
+ export function listUnitFiles() {
155
+ const unitDir = getUserUnitDir();
156
+ if (!fs.existsSync(unitDir)) {
157
+ return [];
158
+ }
159
+
160
+ const files = fs.readdirSync(unitDir);
161
+ const units = [];
162
+
163
+ for (const filename of files) {
164
+ if (filename.startsWith('unitup-') && filename.endsWith('.service')) {
165
+ const name = filename.slice(7, -8);
166
+ units.push({
167
+ name,
168
+ filename,
169
+ path: path.join(unitDir, filename)
170
+ });
171
+ }
172
+ }
173
+
174
+ return units;
175
+ }
176
+
177
+ /**
178
+ * Parses basic information (Script, WorkingDirectory) from a unit file content.
179
+ *
180
+ * @param {string} content
181
+ * @returns {{ script?: string, cwd?: string, restart?: string }}
182
+ */
183
+ export function parseUnitContent(content) {
184
+ const result = {};
185
+ if (!content) return result;
186
+
187
+ const lines = content.split('\n');
188
+ for (const line of lines) {
189
+ const trimmed = line.trim();
190
+ if (trimmed.startsWith('WorkingDirectory=')) {
191
+ result.cwd = trimmed.slice(17).trim();
192
+ } else if (trimmed.startsWith('ExecStart=')) {
193
+ const execVal = trimmed.slice(10).trim();
194
+ // Extract script from ExecStart (usually second token if node executable is first)
195
+ const parts = execVal.match(/(?:[^\s"]+|"[^"]*")+/g) || [];
196
+ if (parts.length >= 2) {
197
+ result.script = parts[1].replace(/^"|"$/g, '');
198
+ } else if (parts.length === 1) {
199
+ result.script = parts[0].replace(/^"|"$/g, '');
200
+ }
201
+ } else if (trimmed.startsWith('Restart=')) {
202
+ result.restart = trimmed.slice(8).trim();
203
+ }
204
+ }
205
+
206
+ return result;
207
+ }