unitup 0.2.0 → 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.
@@ -13,6 +13,7 @@ import {
13
13
  deleteAppMetadata,
14
14
  formatRelativeTime,
15
15
  getAppsDir,
16
+ getUnitupBinPath,
16
17
  getUnitupDir,
17
18
  readAppMetadata,
18
19
  sanitizeServiceName,
@@ -175,7 +176,9 @@ export class MacOSAdapter extends ServiceAdapter {
175
176
  const safeName = sanitizeServiceName(config.name);
176
177
  const label = this.getLabel(safeName);
177
178
 
178
- const programArguments = [config.command, ...(config.args || [])];
179
+ const programArguments = config.deploy?.zeroDowntime
180
+ ? [process.execPath, getUnitupBinPath(), 'supervisor', safeName]
181
+ : [config.command, ...(config.args || [])];
179
182
 
180
183
  // Ensure log directory exists
181
184
  const stdoutLog = config.logs?.stdout || path.join(getUnitupDir(), 'logs', `${safeName}.log`);
@@ -14,6 +14,7 @@ import {
14
14
  deleteAppMetadata,
15
15
  formatRelativeTime,
16
16
  getAppsDir,
17
+ getUnitupBinPath,
17
18
  getUnitupDir,
18
19
  readAppMetadata,
19
20
  sanitizeServiceName,
@@ -75,7 +76,9 @@ export class WindowsAdapter extends ServiceAdapter {
75
76
  const safeName = sanitizeServiceName(config.name);
76
77
  const serviceName = this.getServiceName(safeName);
77
78
  const nodeExec = process.execPath;
78
- const binPath = `"${nodeExec}" "${WINDOWS_HOST_PATH}" "${safeName}"`;
79
+ const hostScript = config.deploy?.zeroDowntime ? getUnitupBinPath() : WINDOWS_HOST_PATH;
80
+ const hostArg = config.deploy?.zeroDowntime ? `supervisor "${safeName}"` : `"${safeName}"`;
81
+ const binPath = `"${nodeExec}" "${hostScript}" ${hostArg}`;
79
82
  const startType = config.autostart !== false ? 'auto' : 'demand';
80
83
 
81
84
  return {
@@ -48,6 +48,14 @@ export function mergeConfigWithOptions(opts = {}) {
48
48
  memoryHigh: opts.memoryHigh || projectCfg?.resources?.memoryHigh || projectCfg?.memoryHigh || '',
49
49
  memoryMax: opts.memoryMax || projectCfg?.resources?.memoryMax || projectCfg?.memoryMax || '',
50
50
  memorySwapMax: opts.memorySwapMax || projectCfg?.resources?.memorySwapMax || projectCfg?.memorySwapMax || '',
51
+ port: opts.port !== undefined ? opts.port : projectCfg.port,
52
+ deploy: {
53
+ ...(projectCfg.deploy && typeof projectCfg.deploy === 'object' ? projectCfg.deploy : {}),
54
+ ...(opts.deploy && typeof opts.deploy === 'object' ? opts.deploy : {}),
55
+ ...(opts.zeroDowntime !== undefined ? { zeroDowntime: !!opts.zeroDowntime } : {}),
56
+ ...(opts.ready !== undefined ? { ready: opts.ready } : {}),
57
+ ...(opts.drain !== undefined ? { drain: opts.drain } : {})
58
+ },
51
59
  ...opts,
52
60
  env: { ...(projectCfg.env || {}), ...(opts.env || {}) },
53
61
  cwd: targetCwd
@@ -2,7 +2,13 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { ExecutableNotFoundError, InvalidServiceConfigError } from '../errors.js';
4
4
  import { detectRuntime, resolveRuntimeConfig } from '../runtimes/index.js';
5
- import { getUnitupDir, resolveEffectiveMemoryLimits, resolveWorkingDirectory, sanitizeServiceName } from '../utils.js';
5
+ import {
6
+ getUnitupDir,
7
+ parseDurationMs,
8
+ resolveEffectiveMemoryLimits,
9
+ resolveWorkingDirectory,
10
+ sanitizeServiceName
11
+ } from '../utils.js';
6
12
 
7
13
  /**
8
14
  * Resolves a binary or command name to an absolute executable path.
@@ -187,6 +193,27 @@ export async function normalizeServiceConfig(rawOpts = {}) {
187
193
  // Memory and resource limits
188
194
  const resources = resolveEffectiveMemoryLimits(rawOpts);
189
195
 
196
+ // Port and Deploy normalization
197
+ const port =
198
+ rawOpts.port !== undefined && rawOpts.port !== null && rawOpts.port !== '' ? Number(rawOpts.port) : undefined;
199
+
200
+ const rawDeploy = rawOpts.deploy && typeof rawOpts.deploy === 'object' ? rawOpts.deploy : {};
201
+ const zeroDowntime = !!(rawOpts.deploy === true || rawDeploy.zeroDowntime || rawOpts.zeroDowntime);
202
+ const readyPath =
203
+ typeof rawDeploy.ready === 'string'
204
+ ? rawDeploy.ready
205
+ : typeof rawOpts.ready === 'string'
206
+ ? rawOpts.ready
207
+ : undefined;
208
+ const drainRaw = rawDeploy.drain !== undefined ? rawDeploy.drain : rawOpts.drain;
209
+ const drainTimeout = drainRaw !== undefined ? parseDurationMs(drainRaw) : 10000;
210
+ const deploy = {
211
+ zeroDowntime,
212
+ ready: readyPath,
213
+ drain: drainRaw,
214
+ drainTimeout
215
+ };
216
+
190
217
  return {
191
218
  name,
192
219
  displayName,
@@ -196,6 +223,8 @@ export async function normalizeServiceConfig(rawOpts = {}) {
196
223
  args,
197
224
  script: rawOpts.script ? path.resolve(cwd, rawOpts.script) : undefined,
198
225
  cwd,
226
+ port,
227
+ deploy,
199
228
  env,
200
229
  envFile: rawOpts.envFile ? path.resolve(cwd, rawOpts.envFile) : undefined,
201
230
  autostart,
package/src/unit.js CHANGED
@@ -9,6 +9,7 @@ import {
9
9
  getServiceNameFromUnit,
10
10
  getTimerFilename,
11
11
  getUnitFilename,
12
+ getUnitupBinPath,
12
13
  resolveAbsolutePath,
13
14
  resolveEffectiveMemoryLimits,
14
15
  resolveWorkingDirectory,
@@ -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) {
package/src/utils.js CHANGED
@@ -1,6 +1,16 @@
1
1
  import fs from 'node:fs';
2
2
  import os from 'node:os';
3
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).
@@ -170,6 +180,8 @@ export function saveAppMetadata(meta) {
170
180
  args,
171
181
  cwd,
172
182
  group: meta.group || 'default',
183
+ ...(meta.port !== undefined ? { port: meta.port } : {}),
184
+ ...(meta.deploy ? { deploy: meta.deploy } : {}),
173
185
  // Optional resources section
174
186
  ...(Object.keys(resources).length > 0 ? { resources } : {}),
175
187
  // Backward compatibility fields
@@ -415,6 +427,32 @@ export function formatRelativeTime(timestamp) {
415
427
  return `${days} day${days === 1 ? '' : 's'} ago`;
416
428
  }
417
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
+
418
456
  /**
419
457
  * Formats data rows into a text table.
420
458
  *
@@ -603,6 +641,48 @@ export function validateDuration(val, paramName = 'Duration') {
603
641
  return str;
604
642
  }
605
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
+
606
686
  /**
607
687
  * Formats a future date/timestamp into relative human readable string (e.g. "in 12 minutes", "tomorrow").
608
688
  * @param {string|number|Date} dateVal