unitup 0.1.5 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/.agents/AGENTS.md +35 -0
  2. package/.agents/skills/multi-runtime-management/SKILL.md +80 -0
  3. package/.agents/skills/platform-service-adapters/SKILL.md +108 -0
  4. package/.agents/skills/unitup-development-and-qa/SKILL.md +88 -0
  5. package/.agents/skills/zero-downtime-deployment/SKILL.md +135 -0
  6. package/CHANGELOG.md +5 -3
  7. package/README.md +89 -8
  8. package/biome.json +0 -1
  9. package/docs/index.html +188 -234
  10. package/index.d.ts +467 -440
  11. package/package.json +9 -3
  12. package/src/cli.js +561 -103
  13. package/src/core/drain-manager.js +70 -0
  14. package/src/core/generation-manager.js +414 -0
  15. package/src/core/index.js +8 -0
  16. package/src/core/ipc.js +236 -0
  17. package/src/core/lock.js +133 -0
  18. package/src/core/process-manager.js +313 -0
  19. package/src/core/readiness.js +243 -0
  20. package/src/core/router.js +390 -0
  21. package/src/core/supervisor.js +222 -0
  22. package/src/deploy/deployment-manager.js +407 -0
  23. package/src/deploy/index.js +9 -0
  24. package/src/deploy/rollback-manager.js +186 -0
  25. package/src/doctor.js +123 -56
  26. package/src/errors.js +131 -0
  27. package/src/index.js +186 -59
  28. package/src/logs.js +148 -0
  29. package/src/platform/base.js +211 -0
  30. package/src/platform/index.js +43 -0
  31. package/src/platform/linux.js +155 -0
  32. package/src/platform/macos.js +726 -0
  33. package/src/platform/windows-host.js +246 -0
  34. package/src/platform/windows.js +498 -0
  35. package/src/runtimes/bun.js +1 -1
  36. package/src/runtimes/common.js +24 -6
  37. package/src/runtimes/deno.js +1 -1
  38. package/src/runtimes/elixir.js +1 -1
  39. package/src/runtimes/go.js +1 -1
  40. package/src/runtimes/index.js +7 -7
  41. package/src/runtimes/native.js +2 -4
  42. package/src/runtimes/node.js +1 -1
  43. package/src/runtimes/php.js +1 -1
  44. package/src/runtimes/python.js +1 -1
  45. package/src/runtimes/ruby.js +1 -1
  46. package/src/runtimes/shell.js +1 -1
  47. package/src/schedule.js +31 -24
  48. package/src/service/config.js +63 -0
  49. package/src/service/manager.js +233 -0
  50. package/src/service/normalize.js +245 -0
  51. package/src/systemd.js +84 -38
  52. package/src/unit.js +17 -19
  53. package/src/utils.js +112 -18
  54. package/tsconfig.json +1 -1
  55. package/types-test/index.ts +158 -0
package/src/systemd.js CHANGED
@@ -3,22 +3,24 @@ import fs from 'node:fs';
3
3
  import os from 'node:os';
4
4
  import path from 'node:path';
5
5
  import {
6
- sanitizeServiceName,
7
- getUnitFilename,
8
- getTimerFilename,
9
- formatRelativeTime,
10
- readAppMetadata,
11
- readScheduleMetadata
12
- } from './utils.js';
13
- import {
14
- getUserUnitDir,
15
- getUnitPath,
16
- unitFileExists,
17
6
  deleteUnitFile,
7
+ getUnitPath,
8
+ getUserUnitDir,
18
9
  listUnitFiles,
19
- writeUnitFile,
20
- parseUnitContent
10
+ parseUnitContent,
11
+ unitFileExists,
12
+ writeUnitFile
21
13
  } from './unit.js';
14
+ import {
15
+ findProjectConfig,
16
+ formatRelativeTime,
17
+ getTimerFilename,
18
+ getUnitFilename,
19
+ readAppMetadata,
20
+ readProjectConfig,
21
+ readScheduleMetadata,
22
+ sanitizeServiceName
23
+ } from './utils.js';
22
24
 
23
25
  /**
24
26
  * Default runner for executing commands via child_process.execFile.
@@ -281,17 +283,44 @@ export async function resetFailed() {
281
283
  }
282
284
 
283
285
  export async function addService(opts) {
284
- const safeName = sanitizeServiceName(opts.name);
286
+ const targetCwd = opts.cwd ? path.resolve(process.cwd(), opts.cwd) : process.cwd();
287
+ const configPath = opts.config ? path.resolve(targetCwd, opts.config) : findProjectConfig(targetCwd);
288
+ const projectCfg = configPath ? readProjectConfig(configPath) : null;
289
+
290
+ let mergedOpts = { ...opts };
291
+ if (projectCfg) {
292
+ mergedOpts = {
293
+ name: opts.name || projectCfg.name,
294
+ group: opts.group || projectCfg.group || 'default',
295
+ script: opts.script || projectCfg.script,
296
+ command: opts.command || projectCfg.command,
297
+ runtime: opts.runtime || projectCfg.runtime,
298
+ runtimeArgs: opts.runtimeArgs && opts.runtimeArgs.length > 0 ? opts.runtimeArgs : projectCfg.runtimeArgs || [],
299
+ args: opts.args && opts.args.length > 0 ? opts.args : projectCfg.args || [],
300
+ envFile: opts.envFile || projectCfg.envFile,
301
+ restart: opts.restart && opts.restart !== 'on-failure' ? opts.restart : projectCfg.restart || 'on-failure',
302
+ memoryHigh: opts.memoryHigh || projectCfg?.resources?.memoryHigh || projectCfg?.memoryHigh || '',
303
+ memoryMax: opts.memoryMax || projectCfg?.resources?.memoryMax || projectCfg?.memoryMax || '',
304
+ memorySwapMax: opts.memorySwapMax || projectCfg?.resources?.memorySwapMax || projectCfg?.memorySwapMax || '',
305
+ ...opts,
306
+ env: { ...(projectCfg.env || {}), ...(opts.env || {}) },
307
+ cwd: targetCwd
308
+ };
309
+ }
310
+
311
+ const safeName = sanitizeServiceName(mergedOpts.name);
312
+ let wasActive = false;
285
313
 
286
314
  if (unitFileExists(safeName)) {
287
315
  try {
288
316
  const show = await getServiceShow(safeName);
289
317
  if (show.ActiveState === 'active') {
290
- if (!opts.force) {
318
+ wasActive = true;
319
+ if (!mergedOpts.force) {
291
320
  throw new Error(
292
321
  `Service "${safeName}" is currently running.\n` +
293
- `Use --force (-f) to overwrite running services, or stop it first:\n` +
294
- ` unitup stop ${safeName}`
322
+ `Use --force (-f) to overwrite running services, or stop it first:\n` +
323
+ ` unitup stop ${safeName}`
295
324
  );
296
325
  }
297
326
  }
@@ -302,20 +331,22 @@ export async function addService(opts) {
302
331
  }
303
332
  }
304
333
 
305
- const runtimeConfig = await resolveRuntimeConfig({ ...opts, name: safeName });
334
+ const runtimeConfig = await resolveRuntimeConfig({ ...mergedOpts, name: safeName });
306
335
 
307
336
  const { path: unitPath } = writeUnitFile({
308
- ...opts,
337
+ ...mergedOpts,
309
338
  name: safeName,
310
339
  runtime: runtimeConfig.runtime,
311
340
  command: runtimeConfig.command,
312
341
  args: runtimeConfig.args,
313
- cwd: opts.cwd
342
+ cwd: mergedOpts.cwd
314
343
  });
315
344
 
316
345
  await daemonReload();
317
346
 
318
- if (opts.start) {
347
+ if (wasActive && mergedOpts.force) {
348
+ await restartService(safeName);
349
+ } else if (mergedOpts.start) {
319
350
  await startService(safeName, true);
320
351
  }
321
352
 
@@ -423,8 +454,8 @@ export async function removeService(name, opts = {}) {
423
454
  if (!force) {
424
455
  throw new Error(
425
456
  `Service "${safeName}" is currently running.\n` +
426
- `Use --force (-f) to remove running services, or stop it first:\n` +
427
- ` unitup stop ${safeName}`
457
+ `Use --force (-f) to remove running services, or stop it first:\n` +
458
+ ` unitup stop ${safeName}`
428
459
  );
429
460
  }
430
461
  }
@@ -514,9 +545,7 @@ export async function getServiceStatus(name) {
514
545
  const pid = show.MainPID && show.MainPID !== '0' ? show.MainPID : '-';
515
546
  const restarts = show.NRestarts ?? '0';
516
547
  const startedRaw = show.ActiveEnterTimestamp;
517
- const started = startedRaw && startedRaw !== '0' && startedRaw !== 'n/a'
518
- ? formatRelativeTime(startedRaw)
519
- : 'never';
548
+ const started = startedRaw && startedRaw !== '0' && startedRaw !== 'n/a' ? formatRelativeTime(startedRaw) : 'never';
520
549
 
521
550
  const meta = readAppMetadata(safeName) || readScheduleMetadata(safeName);
522
551
  const command = meta?.command || parsed.command || meta?.node || process.execPath;
@@ -589,7 +618,7 @@ export async function listServices(filterOpts = {}) {
589
618
  } else {
590
619
  const cmdBase = path.basename(meta.command);
591
620
  const argsStr = (meta.args || [])
592
- .map(a => (a.startsWith('/') || a.startsWith('./') ? path.basename(a) : a))
621
+ .map((a) => (a.startsWith('/') || a.startsWith('./') ? path.basename(a) : a))
593
622
  .join(' ');
594
623
  commandSummary = (cmdBase + (argsStr ? ' ' + argsStr : '')).trim();
595
624
  }
@@ -641,9 +670,8 @@ export async function listServices(filterOpts = {}) {
641
670
  const pid = show.MainPID && show.MainPID !== '0' ? show.MainPID : '-';
642
671
  const restarts = show.NRestarts ?? '0';
643
672
  const startedRaw = show.ActiveEnterTimestamp;
644
- const uptime = startedRaw && startedRaw !== '0' && startedRaw !== 'n/a'
645
- ? formatRelativeTime(startedRaw)
646
- : 'never';
673
+ const uptime =
674
+ startedRaw && startedRaw !== '0' && startedRaw !== 'n/a' ? formatRelativeTime(startedRaw) : 'never';
647
675
 
648
676
  result.push({
649
677
  name: unit.name,
@@ -757,8 +785,13 @@ export async function runJournalctlLogs(name, opts = {}) {
757
785
 
758
786
  if (opts.diskUsage) {
759
787
  const res = await runCommand('journalctl', ['--disk-usage']);
760
- if (res.code !== 0 && (res.stderr.toLowerCase().includes('permission') || res.stderr.toLowerCase().includes('root'))) {
761
- throw new Error('Journal maintenance requires additional privileges on this system.\nRun the command manually with the appropriate permissions.');
788
+ if (
789
+ res.code !== 0 &&
790
+ (res.stderr.toLowerCase().includes('permission') || res.stderr.toLowerCase().includes('root'))
791
+ ) {
792
+ throw new Error(
793
+ 'Journal maintenance requires additional privileges on this system.\nRun the command manually with the appropriate permissions.'
794
+ );
762
795
  }
763
796
  return res.stdout || res.stderr;
764
797
  }
@@ -920,7 +953,7 @@ export async function executeJournalctlMaintenance(action, opts = {}) {
920
953
  }
921
954
  args.push(`--vacuum-time=${timeStr}`);
922
955
  } else if (opts.files) {
923
- const filesNum = parseInt(opts.files, 10);
956
+ const filesNum = Number.parseInt(opts.files, 10);
924
957
  if (Number.isNaN(filesNum) || filesNum <= 0) {
925
958
  throw new Error(`Invalid vacuum files count: "${opts.files}". Must be a positive integer.`);
926
959
  }
@@ -937,8 +970,15 @@ export async function executeJournalctlMaintenance(action, opts = {}) {
937
970
  }
938
971
 
939
972
  const res = await runCommand('journalctl', args);
940
- if (res.code !== 0 && (res.stderr.toLowerCase().includes('permission') || res.stderr.toLowerCase().includes('root') || res.stderr.toLowerCase().includes('access denied'))) {
941
- throw new Error('Journal maintenance requires additional privileges on this system.\nRun the command manually with the appropriate permissions.');
973
+ if (
974
+ res.code !== 0 &&
975
+ (res.stderr.toLowerCase().includes('permission') ||
976
+ res.stderr.toLowerCase().includes('root') ||
977
+ res.stderr.toLowerCase().includes('access denied'))
978
+ ) {
979
+ throw new Error(
980
+ 'Journal maintenance requires additional privileges on this system.\nRun the command manually with the appropriate permissions.'
981
+ );
942
982
  }
943
983
 
944
984
  return (res.stdout || res.stderr || 'Journal maintenance completed successfully.').trim();
@@ -978,9 +1018,15 @@ export async function getServiceMemoryUsage(name) {
978
1018
  const effective = resolveEffectiveMemoryLimits(meta || {});
979
1019
 
980
1020
  const memoryCurrentBytes = Number(props.MemoryCurrent);
981
- const rawBytes = !isNaN(memoryCurrentBytes) && memoryCurrentBytes > 0 && memoryCurrentBytes < 1e15 ? memoryCurrentBytes : 0;
1021
+ const rawBytes =
1022
+ !isNaN(memoryCurrentBytes) && memoryCurrentBytes > 0 && memoryCurrentBytes < 1e15 ? memoryCurrentBytes : 0;
982
1023
 
983
- const status = props.ActiveState === 'active' ? (props.SubState === 'running' ? 'running' : props.SubState || 'active') : (props.ActiveState || 'stopped');
1024
+ const status =
1025
+ props.ActiveState === 'active'
1026
+ ? props.SubState === 'running'
1027
+ ? 'running'
1028
+ : props.SubState || 'active'
1029
+ : props.ActiveState || 'stopped';
984
1030
 
985
1031
  return {
986
1032
  name: safeName,
@@ -1060,7 +1106,7 @@ export async function getAllServicesMemoryUsage(opts = {}) {
1060
1106
  }
1061
1107
  }
1062
1108
 
1063
- const runningCount = items.filter(i => i.status === 'running' || i.status === 'active').length;
1109
+ const runningCount = items.filter((i) => i.status === 'running' || i.status === 'active').length;
1064
1110
 
1065
1111
  return {
1066
1112
  items,
package/src/unit.js CHANGED
@@ -1,21 +1,22 @@
1
1
  import fs from 'node:fs';
2
- import path from 'node:path';
3
2
  import os from 'node:os';
3
+ import path from 'node:path';
4
4
  import {
5
- sanitizeServiceName,
6
- getUnitFilename,
7
- getTimerFilename,
8
- getServiceNameFromUnit,
9
- formatSystemdEnv,
5
+ deleteAppMetadata,
6
+ deleteScheduleMetadata,
10
7
  escapeExecArg,
8
+ formatSystemdEnv,
9
+ getServiceNameFromUnit,
10
+ getTimerFilename,
11
+ getUnitFilename,
12
+ getUnitupBinPath,
11
13
  resolveAbsolutePath,
14
+ resolveEffectiveMemoryLimits,
12
15
  resolveWorkingDirectory,
16
+ sanitizeServiceName,
13
17
  saveAppMetadata,
14
- deleteAppMetadata,
15
18
  saveScheduleMetadata,
16
- deleteScheduleMetadata,
17
- validateDuration,
18
- resolveEffectiveMemoryLimits
19
+ validateDuration
19
20
  } from './utils.js';
20
21
 
21
22
  /**
@@ -73,7 +74,10 @@ export function generateUnitContent(opts) {
73
74
  let commandExec = '';
74
75
  let execArgs = [];
75
76
 
76
- if (opts.command) {
77
+ if (opts.deploy?.zeroDowntime) {
78
+ commandExec = process.execPath;
79
+ execArgs = [getUnitupBinPath(), 'supervisor', safeName];
80
+ } else if (opts.command) {
77
81
  commandExec = resolveAbsolutePath(opts.command);
78
82
  execArgs = Array.isArray(opts.args) ? [...opts.args] : [];
79
83
  } else if (opts.script) {
@@ -241,7 +245,7 @@ export function parseUnitContent(content) {
241
245
  } else if (trimmed.startsWith('ExecStart=')) {
242
246
  const execVal = trimmed.slice(10).trim();
243
247
  const parts = execVal.match(/(?:[^\s"]+|"[^"]*")+/g) || [];
244
- const cleanParts = parts.map(p => p.replace(/^"|"$/g, ''));
248
+ const cleanParts = parts.map((p) => p.replace(/^"|"$/g, '').replace(/\\"/g, '"').replace(/\\\\/g, '\\'));
245
249
  if (cleanParts.length > 0) {
246
250
  result.command = cleanParts[0];
247
251
  result.args = cleanParts.slice(1);
@@ -368,12 +372,7 @@ export function generateScheduleServiceContent(opts) {
368
372
  export function generateTimerContent(opts) {
369
373
  const safeName = sanitizeServiceName(opts.name);
370
374
 
371
- const timerLines = [
372
- '[Unit]',
373
- `Description=unitup timer: ${safeName}`,
374
- '',
375
- '[Timer]'
376
- ];
375
+ const timerLines = ['[Unit]', `Description=unitup timer: ${safeName}`, '', '[Timer]'];
377
376
 
378
377
  if (opts.every) {
379
378
  const validEvery = validateDuration(opts.every, '--every');
@@ -482,4 +481,3 @@ export function deleteScheduleUnitFiles(name) {
482
481
  }
483
482
  return removed;
484
483
  }
485
-
package/src/utils.js CHANGED
@@ -1,6 +1,16 @@
1
- import path from 'node:path';
2
- import os from 'node:os';
3
1
  import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+
6
+ /**
7
+ * Returns the absolute path to the unitup CLI binary.
8
+ * @returns {string}
9
+ */
10
+ export function getUnitupBinPath() {
11
+ const currentDir = path.dirname(fileURLToPath(import.meta.url));
12
+ return path.resolve(currentDir, '../bin/unitup.js');
13
+ }
4
14
 
5
15
  /**
6
16
  * Returns the base unitup config directory (~/.config/unitup).
@@ -75,7 +85,14 @@ export function validateMemorySize(val, paramName = 'Memory limit') {
75
85
  * @returns {string} Formatted memory string e.g. "284 MB", "infinity", "unavailable"
76
86
  */
77
87
  export function formatMemoryBytes(bytes) {
78
- if (bytes === null || bytes === undefined || bytes === '' || bytes === 'unavailable' || bytes === '[unavailable]' || bytes === '-') {
88
+ if (
89
+ bytes === null ||
90
+ bytes === undefined ||
91
+ bytes === '' ||
92
+ bytes === 'unavailable' ||
93
+ bytes === '[unavailable]' ||
94
+ bytes === '-'
95
+ ) {
79
96
  return 'unavailable';
80
97
  }
81
98
 
@@ -137,10 +154,16 @@ export function saveAppMetadata(meta) {
137
154
  }
138
155
  const filePath = getAppMetadataPath(safeName);
139
156
 
140
- const command = meta.command ? resolveAbsolutePath(meta.command) : (meta.node ? resolveAbsolutePath(meta.node) : process.execPath);
141
- const rawArgs = Array.isArray(meta.args) ? meta.args : (meta.script ? [meta.script] : []);
142
- const args = rawArgs.map(a => (a.startsWith('/') || a.startsWith('./') || a.startsWith('../') || a.startsWith('~/')) ? resolveAbsolutePath(a) : a);
143
- const scriptPath = meta.script ? resolveAbsolutePath(meta.script) : (args[0] ? resolveAbsolutePath(args[0]) : command);
157
+ const command = meta.command
158
+ ? resolveAbsolutePath(meta.command)
159
+ : meta.node
160
+ ? resolveAbsolutePath(meta.node)
161
+ : process.execPath;
162
+ const rawArgs = Array.isArray(meta.args) ? meta.args : meta.script ? [meta.script] : [];
163
+ const args = rawArgs.map((a) =>
164
+ a.startsWith('/') || a.startsWith('./') || a.startsWith('../') || a.startsWith('~/') ? resolveAbsolutePath(a) : a
165
+ );
166
+ const scriptPath = meta.script ? resolveAbsolutePath(meta.script) : args[0] ? resolveAbsolutePath(args[0]) : command;
144
167
  const cwd = resolveWorkingDirectory(meta);
145
168
 
146
169
  // Extract memory resources if present
@@ -157,6 +180,8 @@ export function saveAppMetadata(meta) {
157
180
  args,
158
181
  cwd,
159
182
  group: meta.group || 'default',
183
+ ...(meta.port !== undefined ? { port: meta.port } : {}),
184
+ ...(meta.deploy ? { deploy: meta.deploy } : {}),
160
185
  // Optional resources section
161
186
  ...(Object.keys(resources).length > 0 ? { resources } : {}),
162
187
  // Backward compatibility fields
@@ -402,6 +427,32 @@ export function formatRelativeTime(timestamp) {
402
427
  return `${days} day${days === 1 ? '' : 's'} ago`;
403
428
  }
404
429
 
430
+ /**
431
+ * Formats duration in milliseconds into a concise human-readable string (e.g. "23s", "2 mins", "3 hours", "2 days").
432
+ *
433
+ * @param {number} ms
434
+ * @returns {string}
435
+ */
436
+ export function formatDuration(ms) {
437
+ if (typeof ms !== 'number' || isNaN(ms) || ms < 0) {
438
+ return '-';
439
+ }
440
+ const seconds = Math.floor(ms / 1000);
441
+ if (seconds < 60) {
442
+ return `${seconds}s`;
443
+ }
444
+ const minutes = Math.floor(seconds / 60);
445
+ if (minutes < 60) {
446
+ return `${minutes} min${minutes === 1 ? '' : 's'}`;
447
+ }
448
+ const hours = Math.floor(minutes / 60);
449
+ if (hours < 24) {
450
+ return `${hours} hour${hours === 1 ? '' : 's'}`;
451
+ }
452
+ const days = Math.floor(hours / 24);
453
+ return `${days} day${days === 1 ? '' : 's'}`;
454
+ }
455
+
405
456
  /**
406
457
  * Formats data rows into a text table.
407
458
  *
@@ -414,7 +465,7 @@ export function formatTable(data, columns) {
414
465
  return 'No services found.';
415
466
  }
416
467
 
417
- const widths = columns.map(col => col.label.length);
468
+ const widths = columns.map((col) => col.label.length);
418
469
 
419
470
  for (const row of data) {
420
471
  columns.forEach((col, idx) => {
@@ -429,9 +480,7 @@ export function formatTable(data, columns) {
429
480
  const lines = [headerLine];
430
481
 
431
482
  for (const row of data) {
432
- const line = columns
433
- .map((col, idx) => String(row[col.key] ?? '').padEnd(widths[idx]))
434
- .join(' ');
483
+ const line = columns.map((col, idx) => String(row[col.key] ?? '').padEnd(widths[idx])).join(' ');
435
484
  lines.push(line);
436
485
  }
437
486
 
@@ -471,7 +520,9 @@ export function saveScheduleMetadata(meta) {
471
520
 
472
521
  const command = meta.command ? resolveAbsolutePath(meta.command) : process.execPath;
473
522
  const rawArgs = Array.isArray(meta.args) ? meta.args : [];
474
- const args = rawArgs.map(a => (a.startsWith('/') || a.startsWith('./') || a.startsWith('../') || a.startsWith('~/')) ? resolveAbsolutePath(a) : a);
523
+ const args = rawArgs.map((a) =>
524
+ a.startsWith('/') || a.startsWith('./') || a.startsWith('../') || a.startsWith('~/') ? resolveAbsolutePath(a) : a
525
+ );
475
526
 
476
527
  const resources = meta.resources || {};
477
528
  if (meta.memoryHigh) resources.memoryHigh = validateMemorySize(meta.memoryHigh, 'MemoryHigh');
@@ -578,7 +629,8 @@ export function validateDuration(val, paramName = 'Duration') {
578
629
  }
579
630
 
580
631
  const tokens = str.split(/\s+/);
581
- const tokenRegex = /^(\d+(?:\.\d+)?)(us|usec|ms|msec|s|sec|seconds?|m|min|minutes?|h|hr|hours?|d|day|days?|w|week|weeks?|mth|months?|y|year|years?)$/i;
632
+ const tokenRegex =
633
+ /^(\d+(?:\.\d+)?)(us|usec|ms|msec|s|sec|seconds?|m|min|minutes?|h|hr|hours?|d|day|days?|w|week|weeks?|mth|months?|y|year|years?)$/i;
582
634
 
583
635
  for (const token of tokens) {
584
636
  if (!tokenRegex.test(token)) {
@@ -589,6 +641,48 @@ export function validateDuration(val, paramName = 'Duration') {
589
641
  return str;
590
642
  }
591
643
 
644
+ /**
645
+ * Parses a duration string (e.g. '30s', '500ms', '1m') into milliseconds.
646
+ * @param {string|number} val
647
+ * @param {number} [defaultMs=10000]
648
+ * @returns {number}
649
+ */
650
+ export function parseDurationMs(val, defaultMs = 10000) {
651
+ if (typeof val === 'number') return val;
652
+ if (!val || typeof val !== 'string') return defaultMs;
653
+ const str = val.trim().toLowerCase();
654
+ const match = str.match(/^(\d+(?:\.\d+)?)\s*(ms|msec|s|sec|seconds?|m|min|minutes?|h|hr|hours?|d|day|days?)?$/);
655
+ if (!match) return defaultMs;
656
+ const num = Number.parseFloat(match[1]);
657
+ const unit = match[2] || 's';
658
+ switch (unit) {
659
+ case 'ms':
660
+ case 'msec':
661
+ return num;
662
+ case 's':
663
+ case 'sec':
664
+ case 'second':
665
+ case 'seconds':
666
+ return num * 1000;
667
+ case 'm':
668
+ case 'min':
669
+ case 'minute':
670
+ case 'minutes':
671
+ return num * 60 * 1000;
672
+ case 'h':
673
+ case 'hr':
674
+ case 'hour':
675
+ case 'hours':
676
+ return num * 60 * 60 * 1000;
677
+ case 'd':
678
+ case 'day':
679
+ case 'days':
680
+ return num * 24 * 60 * 60 * 1000;
681
+ default:
682
+ return num * 1000;
683
+ }
684
+ }
685
+
592
686
  /**
593
687
  * Formats a future date/timestamp into relative human readable string (e.g. "in 12 minutes", "tomorrow").
594
688
  * @param {string|number|Date} dateVal
@@ -598,7 +692,9 @@ export function formatFutureTime(dateVal) {
598
692
  if (!dateVal || dateVal === 'n/a' || dateVal === '0' || dateVal === 'infinity') {
599
693
  return 'n/a';
600
694
  }
601
- const date = new Date(typeof dateVal === 'number' && dateVal > 1e12 ? dateVal : (typeof dateVal === 'number' ? dateVal / 1000 : dateVal));
695
+ const date = new Date(
696
+ typeof dateVal === 'number' && dateVal > 1e12 ? dateVal : typeof dateVal === 'number' ? dateVal / 1000 : dateVal
697
+ );
602
698
  if (isNaN(date.getTime())) {
603
699
  return String(dateVal);
604
700
  }
@@ -691,7 +787,8 @@ export function resolveEffectiveMemoryLimits(opts = {}) {
691
787
  }
692
788
 
693
789
  if (defaultMem) {
694
- const sizeStr = (typeof defaultMem === 'boolean' || defaultMem === 'true' || defaultMem === '') ? '1G' : String(defaultMem);
790
+ const sizeStr =
791
+ typeof defaultMem === 'boolean' || defaultMem === 'true' || defaultMem === '' ? '1G' : String(defaultMem);
695
792
  const validSize = validateMemorySize(sizeStr, 'Default memory limit');
696
793
  return { memoryMax: validSize };
697
794
  }
@@ -757,6 +854,3 @@ export function saveProjectConfig(dirPathOrFile = process.cwd(), config = {}) {
757
854
  fs.writeFileSync(filePath, JSON.stringify(config, null, 2), 'utf8');
758
855
  return filePath;
759
856
  }
760
-
761
-
762
-
package/tsconfig.json CHANGED
@@ -9,5 +9,5 @@
9
9
  "allowJs": true,
10
10
  "checkJs": false
11
11
  },
12
- "include": ["index.d.ts"]
12
+ "include": ["index.d.ts", "types-test/index.ts"]
13
13
  }
@@ -0,0 +1,158 @@
1
+ import type {
2
+ AppMetadata,
3
+ CreateServiceOptions,
4
+ DoctorInfo,
5
+ InstallServiceOptions,
6
+ ListServiceItem,
7
+ LogOptions,
8
+ NodeDiagnostics,
9
+ NormalizedServiceConfig,
10
+ PlatformCapabilities,
11
+ ServiceFailure,
12
+ ServiceStatus
13
+ } from '../index.d.ts';
14
+
15
+ // Compile-time type assertion checks for all exported interfaces
16
+ function typeCheckTest() {
17
+ const options: CreateServiceOptions = {
18
+ name: 'test-app',
19
+ script: './server.js',
20
+ runtime: 'python',
21
+ runtimeArgs: ['-u'],
22
+ command: '/usr/bin/python3',
23
+ cwd: '/path/to/cwd',
24
+ nodePath: '/usr/bin/node',
25
+ env: { NODE_ENV: 'production' },
26
+ envFile: '.env',
27
+ restart: 'always',
28
+ args: ['--port', '3000'],
29
+ start: true,
30
+ memoryMax: '512M',
31
+ memoryHigh: '256M'
32
+ };
33
+
34
+ const installOpts: InstallServiceOptions = {
35
+ ...options,
36
+ system: false,
37
+ force: true
38
+ };
39
+
40
+ const status: ServiceStatus = {
41
+ name: 'test-app',
42
+ installed: true,
43
+ state: 'running',
44
+ status: 'active (running)',
45
+ enabled: true,
46
+ pid: '1234',
47
+ restarts: '0',
48
+ started: '2026-08-23T12:00:00Z',
49
+ command: '/usr/bin/node',
50
+ arguments: 'server.js',
51
+ args: ['server.js'],
52
+ cwd: '/path/to/cwd',
53
+ platform: 'darwin',
54
+ manager: 'launchd'
55
+ };
56
+
57
+ const doctor: DoctorInfo = {
58
+ platform: 'darwin',
59
+ systemdAvailable: false,
60
+ systemctlAvailable: false,
61
+ linux: false,
62
+ systemdRunning: false,
63
+ userSystemdAvailable: false,
64
+ lingering: false,
65
+ nodePath: '/usr/bin/node',
66
+ nodeVersion: 'v20.0.0',
67
+ nodeValid: true,
68
+ username: 'user',
69
+ unitDir: '/tmp',
70
+ unitDirWritable: true
71
+ };
72
+
73
+ const nodeDiag: NodeDiagnostics = {
74
+ found: true,
75
+ path: '/usr/bin/node',
76
+ version: '20.0.0',
77
+ valid: true
78
+ };
79
+
80
+ const list: ListServiceItem[] = [{ name: 'test-app', status: 'running', pid: '1234' }];
81
+
82
+ const failures: ServiceFailure[] = [
83
+ {
84
+ name: 'api',
85
+ group: 'default',
86
+ status: 'failed',
87
+ exitCode: '1',
88
+ restarts: '3',
89
+ uptime: '10s'
90
+ }
91
+ ];
92
+
93
+ const caps: PlatformCapabilities = {
94
+ serviceManager: 'launchd',
95
+ supports: {
96
+ install: true,
97
+ uninstall: true,
98
+ start: true,
99
+ stop: true,
100
+ restart: true,
101
+ enable: true,
102
+ disable: true,
103
+ status: true,
104
+ logs: true,
105
+ restartPolicy: true,
106
+ userServices: true,
107
+ systemServices: true,
108
+ memoryLimits: false,
109
+ schedule: false
110
+ }
111
+ };
112
+
113
+ const logs: LogOptions = {
114
+ follow: true,
115
+ lines: 50,
116
+ grep: 'ERROR',
117
+ json: true
118
+ };
119
+
120
+ const meta: AppMetadata = {
121
+ name: 'test-app',
122
+ runtime: 'node',
123
+ command: '/usr/bin/node',
124
+ args: ['app.js'],
125
+ cwd: '/tmp'
126
+ };
127
+
128
+ const norm: NormalizedServiceConfig = {
129
+ name: 'test-app',
130
+ displayName: 'Test App',
131
+ description: 'Unitup test',
132
+ runtime: 'node',
133
+ command: '/usr/bin/node',
134
+ args: ['app.js'],
135
+ cwd: '/tmp',
136
+ env: { FOO: 'bar' },
137
+ autostart: true,
138
+ restart: {
139
+ enabled: true,
140
+ policy: 'on-failure',
141
+ delay: 3000
142
+ },
143
+ logs: {
144
+ stdout: '/tmp/test.log',
145
+ stderr: '/tmp/test-error.log'
146
+ },
147
+ group: 'default',
148
+ system: false,
149
+ shutdownTimeout: 10000,
150
+ resources: {
151
+ memoryMax: '512M'
152
+ }
153
+ };
154
+
155
+ console.log(options, installOpts, status, doctor, nodeDiag, list, failures, caps, logs, meta, norm);
156
+ }
157
+
158
+ typeCheckTest();