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/cli.js CHANGED
@@ -1,47 +1,59 @@
1
- import path from 'node:path';
2
1
  import fs from 'node:fs';
2
+ import path from 'node:path';
3
3
  import readline from 'node:readline';
4
4
  import { runDoctor } from './doctor.js';
5
+ import { getAdapter } from './platform/index.js';
6
+ import {
7
+ createSchedule,
8
+ disableSchedule,
9
+ enableSchedule,
10
+ getScheduleStatus,
11
+ listSchedules,
12
+ removeSchedule,
13
+ runSchedule
14
+ } from './schedule.js';
15
+ import { defaultManager } from './service/manager.js';
5
16
  import {
6
17
  addService,
7
- startService,
8
- stopService,
9
- restartService,
10
- removeService,
18
+ executeJournalctlMaintenance,
19
+ findNodeExecutable,
20
+ getAllServicesMemoryUsage,
21
+ getServiceFailures,
22
+ getServiceMemoryUsage,
11
23
  getServiceStatus,
12
24
  getServiceStatusRaw,
13
- listServices,
14
- runJournalctlLogs,
15
- isLinux,
16
- isSystemctlAvailable,
17
- findNodeExecutable,
18
25
  getServicesByGroup,
19
26
  inspectService,
20
- getServiceFailures,
27
+ isLinux,
28
+ isSystemctlAvailable,
29
+ listServices,
30
+ removeService,
31
+ restartService,
32
+ runJournalctlLogs,
21
33
  setServiceLimits,
22
- executeJournalctlMaintenance,
23
- getServiceMemoryUsage,
24
- getAllServicesMemoryUsage
34
+ startService,
35
+ stopService
25
36
  } from './systemd.js';
26
37
  import {
27
- createSchedule,
28
- listSchedules,
29
- getScheduleStatus,
30
- runSchedule,
31
- enableSchedule,
32
- disableSchedule,
33
- removeSchedule
34
- } from './schedule.js';
35
- import {
36
- sanitizeServiceName,
38
+ findProjectConfig,
39
+ formatDuration,
40
+ formatRelativeTime,
37
41
  formatTable,
42
+ parseDurationMs,
38
43
  readGlobalConfig,
39
- saveGlobalConfig,
40
- validateMemorySize,
41
- findProjectConfig,
42
44
  readProjectConfig,
43
- saveProjectConfig
45
+ sanitizeServiceName,
46
+ saveGlobalConfig,
47
+ saveProjectConfig,
48
+ validateMemorySize
44
49
  } from './utils.js';
50
+ import { GenerationManager } from './core/generation-manager.js';
51
+ import { IPCClient } from './core/ipc.js';
52
+ import { Supervisor } from './core/supervisor.js';
53
+ import {
54
+ defaultDeploymentManager as deploymentManager,
55
+ defaultRollbackManager as rollbackManager
56
+ } from './deploy/index.js';
45
57
 
46
58
  /**
47
59
  * Custom light CLI argument parser without runtime dependencies.
@@ -75,6 +87,8 @@ export function parseArgs(argv) {
75
87
  help: false,
76
88
  version: false,
77
89
  force: false,
90
+ system: false,
91
+ verbose: false,
78
92
  memoryHigh: '',
79
93
  memoryMax: '',
80
94
  memorySwapMax: '',
@@ -98,7 +112,13 @@ export function parseArgs(argv) {
98
112
  onBoot: '',
99
113
  onActive: '',
100
114
  randomDelay: '',
101
- persistent: false
115
+ persistent: false,
116
+ port: '',
117
+ zeroDowntime: false,
118
+ ready: '',
119
+ drain: '',
120
+ canary: false,
121
+ weight: ''
102
122
  }
103
123
  };
104
124
 
@@ -112,6 +132,12 @@ export function parseArgs(argv) {
112
132
  } else if (arg === '-v' || arg === '--version') {
113
133
  result.flags.version = true;
114
134
  i++;
135
+ } else if (arg === '--verbose') {
136
+ result.flags.verbose = true;
137
+ i++;
138
+ } else if (arg === '--system') {
139
+ result.flags.system = true;
140
+ i++;
115
141
  } else if (arg === '--runtime') {
116
142
  result.flags.runtime = argv[i + 1] || '';
117
143
  i += 2;
@@ -191,10 +217,10 @@ export function parseArgs(argv) {
191
217
  result.flags.args.push(arg.slice(7));
192
218
  i++;
193
219
  } else if (arg === '--lines' || arg === '-n') {
194
- result.flags.lines = parseInt(argv[i + 1] || '100', 10);
220
+ result.flags.lines = Number.parseInt(argv[i + 1] || '100', 10);
195
221
  i += 2;
196
222
  } else if (arg.startsWith('--lines=')) {
197
- result.flags.lines = parseInt(arg.slice(8), 10);
223
+ result.flags.lines = Number.parseInt(arg.slice(8), 10);
198
224
  i++;
199
225
  } else if (arg === '--start') {
200
226
  result.flags.start = true;
@@ -344,6 +370,36 @@ export function parseArgs(argv) {
344
370
  } else if (arg === '--persistent') {
345
371
  result.flags.persistent = true;
346
372
  i++;
373
+ } else if (arg === '--port') {
374
+ result.flags.port = argv[i + 1] || '';
375
+ i += 2;
376
+ } else if (arg.startsWith('--port=')) {
377
+ result.flags.port = arg.slice(7);
378
+ i++;
379
+ } else if (arg === '--zero-downtime') {
380
+ result.flags.zeroDowntime = true;
381
+ i++;
382
+ } else if (arg === '--ready') {
383
+ result.flags.ready = argv[i + 1] || '';
384
+ i += 2;
385
+ } else if (arg.startsWith('--ready=')) {
386
+ result.flags.ready = arg.slice(8);
387
+ i++;
388
+ } else if (arg === '--drain') {
389
+ result.flags.drain = argv[i + 1] || '';
390
+ i += 2;
391
+ } else if (arg.startsWith('--drain=')) {
392
+ result.flags.drain = arg.slice(8);
393
+ i++;
394
+ } else if (arg === '--canary') {
395
+ result.flags.canary = true;
396
+ i++;
397
+ } else if (arg === '--weight') {
398
+ result.flags.weight = argv[i + 1] || '';
399
+ i += 2;
400
+ } else if (arg.startsWith('--weight=')) {
401
+ result.flags.weight = arg.slice(9);
402
+ i++;
347
403
  } else if (!arg.startsWith('-')) {
348
404
  if (!result.command) {
349
405
  result.command = arg;
@@ -352,7 +408,6 @@ export function parseArgs(argv) {
352
408
  }
353
409
  i++;
354
410
  } else {
355
- // Unknown flag, treat as positional or ignore
356
411
  i++;
357
412
  }
358
413
  }
@@ -362,29 +417,37 @@ export function parseArgs(argv) {
362
417
 
363
418
  export function printHelp() {
364
419
  console.log(`
365
- unitup - Minimal systemd user service wrapper for any executable & runtime
420
+ unitup - Minimal systemd user service wrapper & cross-platform service manager (Linux, macOS, Windows)
366
421
 
367
422
  Usage:
368
423
  unitup doctor Run system readiness and runtime check
369
424
  unitup init [script] Create local project config file (unitup.config.json)
370
- unitup add <script> [options] Add script or executable as systemd user service
425
+ unitup install / add <script> Install script or executable as native service (--dry-run)
371
426
  unitup start <name|@group> Start a service (--enable to enable on boot)
372
427
  unitup stop <name|@group> Stop a service
373
428
  unitup restart <name|@group> Restart a service
374
- unitup status <name|@group> Show status summary (--raw for full status)
375
- unitup logs <name|@group> Show journalctl logs (-f/--follow, -n/--lines N, -c/--cat)
429
+ unitup deploy <name> Perform zero-downtime deployment (--canary, --weight 10%)
430
+ unitup rollback <name> Roll back a service to its previous generation or abort canary
431
+ unitup promote <name> Promote a canary generation to 100% active
432
+ unitup generations <name> List all runtime generations and ports for a service
433
+ unitup status <name|@group> Show status summary (--raw, --verbose)
434
+ unitup enable <name> Enable service on startup/boot
435
+ unitup disable <name> Disable service on startup/boot
436
+ unitup logs <name|@group> Show service logs (-f/--follow, -n/--lines N, -c/--cat)
376
437
  unitup inspect <name> View detailed app configuration and status
377
438
  unitup failures List all failed services with exit code & restarts
378
- unitup remove <name|@group> Stop, disable and delete a service
439
+ unitup uninstall / remove Stop, disable and delete a service (--force)
379
440
  unitup list / unitup ls List all services (--group <group>)
380
441
  unitup memory / unitup top Show memory usage for all apps or a specific app
381
442
  unitup config Manage global configuration (--default-memory 1G)
382
443
 
383
444
  Config Options:
384
445
  --config <path> Path to custom project config file (defaults to unitup.config.json)
446
+ --system Install as system-wide service (LaunchDaemons on macOS / root systemd)
447
+ --dry-run Display generated service configuration without installing
385
448
 
386
- Schedule Commands:
387
- unitup schedule <script> [options] Create a systemd timer schedule
449
+ Schedule Commands (systemd):
450
+ unitup schedule <script> [options] Create a timer schedule
388
451
  unitup schedules / unitup timers List all schedule timers
389
452
  unitup schedule-status <name> Show detailed schedule timer status
390
453
  unitup schedule-run <name> Manually run schedule service unit once
@@ -398,21 +461,13 @@ Memory Options:
398
461
  --memory-max <size> Hard memory limit (e.g. 512M, 1G)
399
462
  --default-memory [sz] Apply default memory limit (defaults to 1G)
400
463
 
401
- Schedule Options:
402
- --every <duration> Execution interval (e.g., 30s, 10m, 2h, 1d)
403
- --calendar <expr> Calendar expression (e.g., daily, "Mon..Fri 09:00")
404
- --on-boot <duration> Delay relative to system boot (e.g., 5m)
405
- --on-active <duration> Delay relative to timer activation
406
- --random-delay <dur> Random delay to prevent stampedes (e.g., 10m)
407
- --persistent Run immediately if a scheduled run was missed
408
- --start Enable and start timer immediately after creation
409
-
410
464
  Examples:
411
- unitup add server.js --runtime node --default-memory 1G
412
- unitup config --default-memory 1G
413
- unitup schedule cleanup.js --every 30m --default-memory --start
414
- unitup schedule backup.py --calendar daily --random-delay 10m --start
415
- unitup schedule report.js --calendar "Mon..Fri 09:00" --start
465
+ unitup install server.js --name api --start
466
+ unitup install ./app.js --dry-run
467
+ unitup start api
468
+ unitup status api --verbose
469
+ unitup logs api --follow
470
+ unitup uninstall api
416
471
  `);
417
472
  }
418
473
 
@@ -468,12 +523,12 @@ export async function runCli(argv = process.argv.slice(2)) {
468
523
  if (fs.existsSync(targetConfigPath) && !flags.force) {
469
524
  throw new Error(
470
525
  `Configuration file "${path.basename(targetConfigPath)}" already exists at ${targetConfigPath}.\n` +
471
- `Use --force (-f) to overwrite.`
526
+ `Use --force (-f) to overwrite.`
472
527
  );
473
528
  }
474
529
 
475
530
  const scriptArg = positionals[0];
476
- let scriptPath = scriptArg || '';
531
+ const scriptPath = scriptArg || '';
477
532
  let name = flags.name;
478
533
  if (!name) {
479
534
  if (scriptPath) {
@@ -521,6 +576,7 @@ export async function runCli(argv = process.argv.slice(2)) {
521
576
  break;
522
577
  }
523
578
 
579
+ case 'install':
524
580
  case 'add': {
525
581
  const targetCwd = flags.cwd ? path.resolve(process.cwd(), flags.cwd) : process.cwd();
526
582
  const configPath = flags.config ? path.resolve(targetCwd, flags.config) : findProjectConfig(targetCwd);
@@ -528,18 +584,23 @@ export async function runCli(argv = process.argv.slice(2)) {
528
584
 
529
585
  const scriptArg = positionals[0] || (projectCfg ? projectCfg.script : undefined);
530
586
  let name = flags.name || (projectCfg ? projectCfg.name : undefined);
531
- let cmdFlag = flags.command || (projectCfg ? projectCfg.command : undefined);
532
- let runtimeFlag = flags.runtime || (projectCfg ? projectCfg.runtime : undefined);
533
- let groupFlag = flags.group || (projectCfg ? projectCfg.group : undefined) || 'default';
534
- let restartFlag = flags.restart !== 'on-failure' ? flags.restart : ((projectCfg && projectCfg.restart) || 'on-failure');
535
- let envFileFlag = flags.envFile || (projectCfg ? projectCfg.envFile : undefined);
536
- let argsFlag = (flags.args && flags.args.length > 0) ? flags.args : ((projectCfg && projectCfg.args) || []);
537
- let runtimeArgsFlag = (flags.runtimeArgs && flags.runtimeArgs.length > 0) ? flags.runtimeArgs : ((projectCfg && projectCfg.runtimeArgs) || []);
538
- let memoryHighFlag = flags.memoryHigh || (projectCfg?.resources?.memoryHigh || projectCfg?.memoryHigh || '');
539
- let memoryMaxFlag = flags.memoryMax || (projectCfg?.resources?.memoryMax || projectCfg?.memoryMax || '');
540
- let memorySwapMaxFlag = flags.memorySwapMax || (projectCfg?.resources?.memorySwapMax || projectCfg?.memorySwapMax || '');
541
-
542
- let absScriptPath = scriptArg ? path.resolve(targetCwd, scriptArg) : undefined;
587
+ const cmdFlag = flags.command || (projectCfg ? projectCfg.command : undefined);
588
+ const runtimeFlag = flags.runtime || (projectCfg ? projectCfg.runtime : undefined);
589
+ const groupFlag = flags.group || (projectCfg ? projectCfg.group : undefined) || 'default';
590
+ const restartFlag =
591
+ flags.restart !== 'on-failure' ? flags.restart : (projectCfg && projectCfg.restart) || 'on-failure';
592
+ const envFileFlag = flags.envFile || (projectCfg ? projectCfg.envFile : undefined);
593
+ const argsFlag = flags.args && flags.args.length > 0 ? flags.args : (projectCfg && projectCfg.args) || [];
594
+ const runtimeArgsFlag =
595
+ flags.runtimeArgs && flags.runtimeArgs.length > 0
596
+ ? flags.runtimeArgs
597
+ : (projectCfg && projectCfg.runtimeArgs) || [];
598
+ const memoryHighFlag = flags.memoryHigh || projectCfg?.resources?.memoryHigh || projectCfg?.memoryHigh || '';
599
+ const memoryMaxFlag = flags.memoryMax || projectCfg?.resources?.memoryMax || projectCfg?.memoryMax || '';
600
+ const memorySwapMaxFlag =
601
+ flags.memorySwapMax || projectCfg?.resources?.memorySwapMax || projectCfg?.memorySwapMax || '';
602
+
603
+ const absScriptPath = scriptArg ? path.resolve(targetCwd, scriptArg) : undefined;
543
604
 
544
605
  if (flags.node) {
545
606
  const resolvedNode = await findNodeExecutable(flags.node);
@@ -573,7 +634,7 @@ export async function runCli(argv = process.argv.slice(2)) {
573
634
  }
574
635
  }
575
636
 
576
- const envObj = (projectCfg && projectCfg.env && typeof projectCfg.env === 'object') ? { ...projectCfg.env } : {};
637
+ const envObj = projectCfg && projectCfg.env && typeof projectCfg.env === 'object' ? { ...projectCfg.env } : {};
577
638
  for (const e of flags.env) {
578
639
  const idx = e.indexOf('=');
579
640
  if (idx !== -1) {
@@ -583,7 +644,7 @@ export async function runCli(argv = process.argv.slice(2)) {
583
644
  }
584
645
  }
585
646
 
586
- const res = await addService({
647
+ const installOptions = {
587
648
  name,
588
649
  group: groupFlag,
589
650
  script: absScriptPath,
@@ -597,14 +658,34 @@ export async function runCli(argv = process.argv.slice(2)) {
597
658
  restart: restartFlag,
598
659
  args: argsFlag,
599
660
  start: flags.start,
661
+ system: flags.system,
600
662
  memoryHigh: memoryHighFlag,
601
663
  memoryMax: memoryMaxFlag,
602
664
  memorySwapMax: memorySwapMaxFlag,
603
665
  defaultMemory: flags.defaultMemory,
604
- force: flags.force
605
- });
666
+ force: flags.force,
667
+ port: flags.port ? Number(flags.port) : projectCfg?.port,
668
+ deploy: {
669
+ ...(projectCfg?.deploy || {}),
670
+ ...(flags.zeroDowntime ? { zeroDowntime: true } : {}),
671
+ ...(flags.ready ? { ready: flags.ready } : {}),
672
+ ...(flags.drain ? { drain: flags.drain } : {})
673
+ }
674
+ };
606
675
 
607
- console.log(`✓ Service "${res.name}" created at ${res.unitPath}`);
676
+ if (flags.dryRun) {
677
+ const generated = await defaultManager.generate(installOptions);
678
+ if (typeof generated === 'string') {
679
+ console.log(generated);
680
+ } else {
681
+ console.log(JSON.stringify(generated, null, 2));
682
+ }
683
+ break;
684
+ }
685
+
686
+ const res = await defaultManager.install(installOptions);
687
+
688
+ console.log(`✓ Service "${res.name}" created at ${res.unitPath || res.serviceName || res.name}`);
608
689
  if (flags.start) {
609
690
  console.log(`✓ Service "${res.name}" enabled and started.`);
610
691
  } else {
@@ -638,7 +719,7 @@ export async function runCli(argv = process.argv.slice(2)) {
638
719
  }
639
720
  const targetNames = await resolveTargetNames(nameArg);
640
721
  for (const name of targetNames) {
641
- await startService(name, flags.enable);
722
+ await defaultManager.start(name, { enable: flags.enable, system: flags.system });
642
723
  console.log(`✓ Service "${sanitizeServiceName(name)}" ${flags.enable ? 'enabled & ' : ''}started.`);
643
724
  }
644
725
  break;
@@ -651,7 +732,7 @@ export async function runCli(argv = process.argv.slice(2)) {
651
732
  }
652
733
  const targetNames = await resolveTargetNames(nameArg);
653
734
  for (const name of targetNames) {
654
- await stopService(name);
735
+ await defaultManager.stop(name, { system: flags.system });
655
736
  console.log(`✓ Service "${sanitizeServiceName(name)}" stopped.`);
656
737
  }
657
738
  break;
@@ -660,20 +741,294 @@ export async function runCli(argv = process.argv.slice(2)) {
660
741
  case 'restart': {
661
742
  const nameArg = positionals[0];
662
743
  if (!nameArg) {
663
- throw new Error('Service name or @group is required.\nExample: unitup restart api or unitup restart @myproject');
744
+ throw new Error(
745
+ 'Service name or @group is required.\nExample: unitup restart api or unitup restart @myproject'
746
+ );
664
747
  }
665
748
  const targetNames = await resolveTargetNames(nameArg);
666
749
  for (const name of targetNames) {
667
- await restartService(name);
750
+ await defaultManager.restart(name, { system: flags.system });
668
751
  console.log(`✓ Service "${sanitizeServiceName(name)}" restarted.`);
669
752
  }
670
753
  break;
671
754
  }
672
755
 
756
+ case 'deploy': {
757
+ const nameArg = positionals[0];
758
+ if (!nameArg) {
759
+ throw new Error('Service name is required.\nExample: unitup deploy api');
760
+ }
761
+ const safeName = sanitizeServiceName(nameArg);
762
+ console.log(`${safeName}\n`);
763
+
764
+ const isCanary = flags.canary;
765
+ const weight = flags.weight ? Number(flags.weight.replace('%', '')) : undefined;
766
+
767
+ const onProgress = (evt) => {
768
+ if (evt.state === 'STARTING') {
769
+ console.log(`✓ generation #${evt.generation} started`);
770
+ } else if (evt.state === 'WAITING_READY') {
771
+ // waiting for readiness
772
+ } else if (evt.state === 'SETTING_CANARY') {
773
+ const pct = Math.round(evt.weight > 1 ? evt.weight : evt.weight * 100);
774
+ console.log('✓ ready');
775
+ console.log(`✓ canary traffic set: ${pct}% to generation #${evt.generation}`);
776
+ } else if (evt.state === 'SWITCHING') {
777
+ console.log('✓ ready');
778
+ console.log('✓ traffic switched');
779
+ } else if (evt.state === 'STOPPING_PREVIOUS') {
780
+ console.log('✓ previous generation drained\n');
781
+ }
782
+ };
783
+
784
+ const res = await deploymentManager.deploy(
785
+ safeName,
786
+ {},
787
+ {
788
+ publicPort: flags.port ? Number(flags.port) : undefined,
789
+ readyPath: flags.ready || undefined,
790
+ drainTimeout: flags.drain ? parseDurationMs(flags.drain) : undefined,
791
+ canary: isCanary,
792
+ weight,
793
+ onProgress
794
+ }
795
+ );
796
+
797
+ if (res.isCanary) {
798
+ const pct = Math.round(res.canaryWeight > 1 ? res.canaryWeight : res.canaryWeight * 100);
799
+ console.log(
800
+ `\ncanary: v${res.canaryGeneration} (${pct}%) active alongside v${res.activeGeneration} (${100 - pct}%)`
801
+ );
802
+ console.log(`Run "unitup promote ${safeName}" to shift 100% traffic to v${res.canaryGeneration}.`);
803
+ } else {
804
+ const prev = res.previousGeneration ? `v${res.previousGeneration}` : 'none';
805
+ const curr = `v${res.currentGeneration}`;
806
+ console.log(`${prev} -> ${curr}`);
807
+ console.log(`downtime: ${res.downtimeMs ?? 0}ms`);
808
+ }
809
+ break;
810
+ }
811
+
812
+ case 'promote': {
813
+ const nameArg = positionals[0];
814
+ if (!nameArg) {
815
+ throw new Error('Service name is required.\nExample: unitup promote api');
816
+ }
817
+ const safeName = sanitizeServiceName(nameArg);
818
+ console.log(`Promoting canary for "${safeName}"...\n`);
819
+
820
+ const onProgress = (evt) => {
821
+ if (evt.state === 'PROMOTING') {
822
+ console.log(`✓ Promoting generation #${evt.generation} to 100%`);
823
+ } else if (evt.state === 'DRAINING') {
824
+ console.log(`✓ Draining previous generation #${evt.generation}`);
825
+ }
826
+ };
827
+
828
+ const res = await deploymentManager.promote(
829
+ safeName,
830
+ {},
831
+ {
832
+ drainTimeout: flags.drain ? parseDurationMs(flags.drain) : undefined,
833
+ onProgress
834
+ }
835
+ );
836
+
837
+ console.log(`\n✓ Promoted generation #${res.promotedGeneration} to 100% active.`);
838
+ if (res.previousGeneration) {
839
+ console.log(`v${res.previousGeneration} -> v${res.promotedGeneration}`);
840
+ }
841
+ break;
842
+ }
843
+
844
+ case 'rollback': {
845
+ const nameArg = positionals[0];
846
+ if (!nameArg) {
847
+ throw new Error('Service name is required.\nExample: unitup rollback api');
848
+ }
849
+ const safeName = sanitizeServiceName(nameArg);
850
+ console.log(`Rolling back "${safeName}"...\n`);
851
+
852
+ const onProgress = (evt) => {
853
+ if (evt.state === 'ABORTING_CANARY') {
854
+ console.log(`✓ Aborting and draining canary generation #${evt.canaryGeneration}`);
855
+ } else if (evt.state === 'SWITCHING') {
856
+ console.log(`✓ Traffic switched back to generation #${evt.targetGeneration}`);
857
+ } else if (evt.state === 'DRAINING_CURRENT') {
858
+ console.log(`✓ Draining generation #${evt.generation}`);
859
+ }
860
+ };
861
+
862
+ const res = await rollbackManager.rollback(
863
+ safeName,
864
+ {},
865
+ {
866
+ readyPath: flags.ready || undefined,
867
+ drainTimeout: flags.drain ? parseDurationMs(flags.drain) : undefined,
868
+ onProgress
869
+ }
870
+ );
871
+
872
+ if (res.abortedCanary) {
873
+ console.log(
874
+ `\n✓ Aborted canary generation #${res.abortedCanary}. 100% traffic retained on generation #${res.activeGeneration}.`
875
+ );
876
+ } else {
877
+ console.log(`\n✓ Rolled back service "${safeName}" to generation #${res.activeGeneration}.`);
878
+ }
879
+ break;
880
+ }
881
+
882
+ case 'supervisor': {
883
+ const nameArg = positionals[0];
884
+ if (!nameArg) {
885
+ throw new Error('Service name is required for supervisor mode.\nExample: unitup supervisor api');
886
+ }
887
+ await Supervisor.run(nameArg);
888
+ break;
889
+ }
890
+
891
+ case 'history':
892
+ case 'generations': {
893
+ const nameArg = positionals[0];
894
+ if (!nameArg) {
895
+ throw new Error('Service name is required.\nExample: unitup generations api');
896
+ }
897
+ const safeName = sanitizeServiceName(nameArg);
898
+ const gm = new GenerationManager();
899
+ let list = gm.listGenerations(safeName);
900
+
901
+ // Check if supervisor is running via IPC to enrich with live in-flight data
902
+ let liveStatus = null;
903
+ const ipcClient = new IPCClient(safeName);
904
+ if (await ipcClient.isAlive()) {
905
+ try {
906
+ liveStatus = await ipcClient.send({ action: 'status' }, null, 1000);
907
+ if (liveStatus?.generations) {
908
+ list = liveStatus.generations;
909
+ }
910
+ } catch {
911
+ // fallback to local state
912
+ }
913
+ }
914
+
915
+ if (!list || list.length === 0) {
916
+ if (flags.json) {
917
+ console.log(JSON.stringify([], null, 2));
918
+ } else {
919
+ console.log(`No generations recorded for service "${safeName}".`);
920
+ }
921
+ break;
922
+ }
923
+
924
+ const tableData = list.map((g) => {
925
+ let activeFor = '-';
926
+ if (g.status === 'active') {
927
+ const start = g.activatedAt
928
+ ? new Date(g.activatedAt).getTime()
929
+ : g.createdAt
930
+ ? new Date(g.createdAt).getTime()
931
+ : null;
932
+ if (start && !Number.isNaN(start)) {
933
+ activeFor = formatDuration(Date.now() - start);
934
+ }
935
+ } else if (g.activatedAt) {
936
+ const start = new Date(g.activatedAt).getTime();
937
+ const end = g.deactivatedAt
938
+ ? new Date(g.deactivatedAt).getTime()
939
+ : g.stoppedAt
940
+ ? new Date(g.stoppedAt).getTime()
941
+ : g.drainingAt
942
+ ? new Date(g.drainingAt).getTime()
943
+ : null;
944
+ if (start && end && !Number.isNaN(start) && !Number.isNaN(end) && end >= start) {
945
+ activeFor = formatDuration(end - start);
946
+ }
947
+ }
948
+
949
+ return {
950
+ gen: `#${g.id}`,
951
+ status: g.status || 'unknown',
952
+ pid: g.pid ? String(g.pid) : '-',
953
+ port: g.internalPort ? String(g.internalPort) : '-',
954
+ created: formatRelativeTime(g.createdAt),
955
+ activeFor,
956
+ raw: g
957
+ };
958
+ });
959
+
960
+ if (flags.json) {
961
+ console.log(
962
+ JSON.stringify(
963
+ tableData.map((d) => ({
964
+ id: d.raw.id,
965
+ status: d.raw.status,
966
+ pid: d.raw.pid,
967
+ internalPort: d.raw.internalPort,
968
+ createdAt: d.raw.createdAt,
969
+ activatedAt: d.raw.activatedAt || null,
970
+ stoppedAt: d.raw.stoppedAt || null,
971
+ deactivatedAt: d.raw.deactivatedAt || null,
972
+ activeFor: d.activeFor
973
+ })),
974
+ null,
975
+ 2
976
+ )
977
+ );
978
+ break;
979
+ }
980
+
981
+ console.log(`=== Generations: ${safeName} ===\n`);
982
+ if (liveStatus?.routerPort) {
983
+ console.log(`Public Router: :${liveStatus.routerPort}`);
984
+ console.log(`In-Flight Requests: ${liveStatus.inFlightRequests || 0}\n`);
985
+ }
986
+
987
+ const table = formatTable(tableData, [
988
+ { key: 'gen', label: 'GEN' },
989
+ { key: 'status', label: 'STATUS' },
990
+ { key: 'pid', label: 'PID' },
991
+ { key: 'port', label: 'PORT' },
992
+ { key: 'created', label: 'CREATED' },
993
+ { key: 'activeFor', label: 'ACTIVE FOR' }
994
+ ]);
995
+ console.log(table);
996
+ break;
997
+ }
998
+
999
+ case 'enable': {
1000
+ const nameArg = positionals[0];
1001
+ if (!nameArg) {
1002
+ throw new Error('Service name or @group is required.\nExample: unitup enable api');
1003
+ }
1004
+ const targetNames = await resolveTargetNames(nameArg);
1005
+ for (const name of targetNames) {
1006
+ await defaultManager.enable(name, { system: flags.system });
1007
+ console.log(`✓ Service "${sanitizeServiceName(name)}" enabled on startup.`);
1008
+ }
1009
+ break;
1010
+ }
1011
+
1012
+ case 'disable': {
1013
+ const nameArg = positionals[0];
1014
+ if (!nameArg) {
1015
+ throw new Error('Service name or @group is required.\nExample: unitup disable api');
1016
+ }
1017
+ const targetNames = await resolveTargetNames(nameArg);
1018
+ for (const name of targetNames) {
1019
+ await defaultManager.disable(name, { system: flags.system });
1020
+ console.log(`✓ Service "${sanitizeServiceName(name)}" disabled on startup.`);
1021
+ }
1022
+ break;
1023
+ }
1024
+
1025
+ case 'ps':
673
1026
  case 'status': {
674
1027
  const nameArg = positionals[0];
675
1028
  if (!nameArg) {
676
- throw new Error('Service name or @group is required.\nExample: unitup status api or unitup status @myproject');
1029
+ throw new Error(
1030
+ 'Service name or @group is required.\nExample: unitup status api or unitup status @myproject'
1031
+ );
677
1032
  }
678
1033
 
679
1034
  const targetNames = await resolveTargetNames(nameArg);
@@ -682,25 +1037,103 @@ export async function runCli(argv = process.argv.slice(2)) {
682
1037
  const raw = await getServiceStatusRaw(name);
683
1038
  console.log(raw);
684
1039
  } else {
685
- const status = await getServiceStatus(name);
1040
+ const status = await defaultManager.status(name, { system: flags.system });
1041
+
1042
+ // Enrich with generation info if available
1043
+ const gm = new GenerationManager();
1044
+ const genState = gm.loadState(name);
1045
+ let genDetails = null;
1046
+ if (genState?.activeGeneration || genState?.canaryGeneration) {
1047
+ const activeGen = gm.getActive(name);
1048
+ const canaryGen = gm.getCanary(name);
1049
+ genDetails = {
1050
+ activeGeneration: genState.activeGeneration,
1051
+ internalPort: activeGen?.internalPort || null,
1052
+ canaryGeneration: canaryGen?.id || null,
1053
+ canaryPort: canaryGen?.internalPort || null,
1054
+ canaryWeight: canaryGen?.canaryWeight || null,
1055
+ routerPort: null,
1056
+ inFlightRequests: 0
1057
+ };
1058
+
1059
+ const ipcClient = new IPCClient(name);
1060
+ if (await ipcClient.isAlive()) {
1061
+ try {
1062
+ const live = await ipcClient.send({ action: 'status' }, null, 500);
1063
+ if (live?.routerPort) {
1064
+ genDetails.routerPort = live.routerPort;
1065
+ }
1066
+ if (live?.inFlightRequests !== undefined) {
1067
+ genDetails.inFlightRequests = live.inFlightRequests;
1068
+ }
1069
+ if (live?.canaryGeneration) {
1070
+ genDetails.canaryGeneration = live.canaryGeneration.id;
1071
+ genDetails.canaryPort = live.canaryGeneration.internalPort;
1072
+ }
1073
+ if (live?.canaryWeight !== undefined) {
1074
+ genDetails.canaryWeight = live.canaryWeight;
1075
+ }
1076
+ } catch {
1077
+ // ignore
1078
+ }
1079
+ }
1080
+ }
1081
+
1082
+ if (flags.json) {
1083
+ const jsonObj = {
1084
+ ...status,
1085
+ ...(genDetails ? { deployment: genDetails } : {})
1086
+ };
1087
+ console.log(JSON.stringify(jsonObj, null, 2));
1088
+ continue;
1089
+ }
1090
+
686
1091
  console.log(`${status.name}\n`);
687
- console.log(`Status: ${status.status}`);
1092
+ console.log(`Status: ${status.status || status.state}`);
688
1093
  console.log(`PID: ${status.pid}`);
689
1094
  console.log(`Started: ${status.started}`);
690
1095
  console.log(`Restarts: ${status.restarts}`);
691
1096
  console.log(`Command: ${status.command}`);
692
1097
  console.log(`Arguments: ${status.arguments}`);
693
1098
  console.log(`Working directory: ${status.cwd}`);
694
- console.log(`Memory: ${status.memory}`);
695
- console.log(`Memory Peak: ${status.memoryPeak}`);
696
- console.log(`Memory High: ${status.memoryHigh}`);
697
- console.log(`Memory Max: ${status.memoryMax}`);
698
- console.log(`Swap Max: ${status.memorySwapMax}`);
1099
+
1100
+ if (genDetails) {
1101
+ if (genDetails.activeGeneration) {
1102
+ console.log(`Active Generation: #${genDetails.activeGeneration}`);
1103
+ }
1104
+ if (genDetails.canaryGeneration) {
1105
+ const pct = Math.round(
1106
+ genDetails.canaryWeight > 1 ? genDetails.canaryWeight : genDetails.canaryWeight * 100
1107
+ );
1108
+ console.log(
1109
+ `Canary: #${genDetails.canaryGeneration} (${pct}% traffic) -> :${genDetails.canaryPort || 'none'}`
1110
+ );
1111
+ }
1112
+ if (genDetails.routerPort) {
1113
+ const targetPort = genDetails.internalPort ? `:${genDetails.internalPort}` : 'none';
1114
+ console.log(`Router: :${genDetails.routerPort} -> ${targetPort}`);
1115
+ console.log(`In-Flight Requests: ${genDetails.inFlightRequests}`);
1116
+ } else if (genDetails.internalPort) {
1117
+ console.log(`Internal Port: ${genDetails.internalPort}`);
1118
+ }
1119
+ }
1120
+
1121
+ if (status.memory) console.log(`Memory: ${status.memory}`);
1122
+ if (status.memoryPeak) console.log(`Memory Peak: ${status.memoryPeak}`);
1123
+ if (status.memoryHigh) console.log(`Memory High: ${status.memoryHigh}`);
1124
+ if (status.memoryMax) console.log(`Memory Max: ${status.memoryMax}`);
1125
+ if (status.memorySwapMax) console.log(`Swap Max: ${status.memorySwapMax}`);
1126
+ if (flags.verbose) {
1127
+ console.log(`Platform: ${status.platform}`);
1128
+ console.log(`Manager: ${status.manager}`);
1129
+ if (status.unitPath) console.log(`Unit Path: ${status.unitPath}`);
1130
+ }
699
1131
  }
700
1132
  }
701
1133
  break;
702
1134
  }
703
1135
 
1136
+ case 'log':
704
1137
  case 'logs': {
705
1138
  const nameArg = positionals[0];
706
1139
  if (!nameArg) {
@@ -712,7 +1145,7 @@ export async function runCli(argv = process.argv.slice(2)) {
712
1145
  if (targetNames.length > 1) {
713
1146
  console.log(`=== Logs for ${name} ===`);
714
1147
  }
715
- const output = await runJournalctlLogs(name, {
1148
+ const output = await defaultManager.logs(name, {
716
1149
  follow: flags.follow,
717
1150
  lines: flags.lines,
718
1151
  cat: flags.cat,
@@ -723,9 +1156,24 @@ export async function runCli(argv = process.argv.slice(2)) {
723
1156
  grep: flags.grep,
724
1157
  boot: flags.boot,
725
1158
  json: flags.json,
726
- diskUsage: flags.diskUsage
1159
+ diskUsage: flags.diskUsage,
1160
+ system: flags.system
727
1161
  });
728
- if (typeof output === 'string') {
1162
+ if (output && typeof output.on === 'function') {
1163
+ await new Promise((resolve) => {
1164
+ const cleanup = () => {
1165
+ if (output.stop) output.stop();
1166
+ resolve();
1167
+ };
1168
+ process.on('SIGINT', cleanup);
1169
+ process.on('SIGTERM', cleanup);
1170
+ output.on('close', resolve);
1171
+ output.on('error', (err) => {
1172
+ console.error(err.message);
1173
+ cleanup();
1174
+ });
1175
+ });
1176
+ } else if (typeof output === 'string') {
729
1177
  console.log(output);
730
1178
  }
731
1179
  }
@@ -735,16 +1183,21 @@ export async function runCli(argv = process.argv.slice(2)) {
735
1183
  case 'journal': {
736
1184
  const action = positionals[0];
737
1185
  if (!action) {
738
- throw new Error('Journal action is required (disk-usage, rotate, vacuum).\nExample: unitup journal vacuum --size 500M');
1186
+ throw new Error(
1187
+ 'Journal action is required (disk-usage, rotate, vacuum).\nExample: unitup journal vacuum --size 500M'
1188
+ );
739
1189
  }
740
1190
 
741
1191
  if (action === 'vacuum' && !flags.yes && !flags.dryRun) {
742
1192
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
743
1193
  const answer = await new Promise((resolve) => {
744
- rl.question('This operation affects archived journal logs system-wide,\nnot only services managed by unitup.\n\nContinue? [y/N] ', (ans) => {
745
- rl.close();
746
- resolve(ans.trim());
747
- });
1194
+ rl.question(
1195
+ 'This operation affects archived journal logs system-wide,\nnot only services managed by unitup.\n\nContinue? [y/N] ',
1196
+ (ans) => {
1197
+ rl.close();
1198
+ resolve(ans.trim());
1199
+ }
1200
+ );
748
1201
  });
749
1202
  if (answer.toLowerCase() !== 'y' && answer.toLowerCase() !== 'yes') {
750
1203
  console.log('Aborted.');
@@ -763,14 +1216,19 @@ export async function runCli(argv = process.argv.slice(2)) {
763
1216
  break;
764
1217
  }
765
1218
 
1219
+ case 'uninstall':
1220
+ case 'rm':
1221
+ case 'delete':
766
1222
  case 'remove': {
767
1223
  const nameArg = positionals[0];
768
1224
  if (!nameArg) {
769
- throw new Error('Service name or @group is required.\nExample: unitup remove api or unitup remove @myproject');
1225
+ throw new Error(
1226
+ 'Service name or @group is required.\nExample: unitup remove api or unitup remove @myproject'
1227
+ );
770
1228
  }
771
1229
  const targetNames = await resolveTargetNames(nameArg);
772
1230
  for (const name of targetNames) {
773
- await removeService(name, { force: flags.force });
1231
+ await defaultManager.uninstall(name, { force: flags.force, system: flags.system });
774
1232
  console.log(`✓ Service "${sanitizeServiceName(name)}" removed.`);
775
1233
  }
776
1234
  break;
@@ -778,7 +1236,7 @@ export async function runCli(argv = process.argv.slice(2)) {
778
1236
 
779
1237
  case 'ls':
780
1238
  case 'list': {
781
- const services = await listServices({ group: flags.group });
1239
+ const services = await defaultManager.list({ group: flags.group });
782
1240
  if (services.length === 0) {
783
1241
  if (flags.group) {
784
1242
  console.log(`No services found in group "${flags.group}".`);
@@ -804,7 +1262,7 @@ export async function runCli(argv = process.argv.slice(2)) {
804
1262
  if (!nameArg) {
805
1263
  throw new Error('Service name is required.\nExample: unitup inspect api');
806
1264
  }
807
- const info = await inspectService(nameArg);
1265
+ const info = await defaultManager.inspect(nameArg, { system: flags.system });
808
1266
  console.log(`Name: ${info.name}`);
809
1267
  console.log(`Runtime: ${info.runtime}`);
810
1268
  console.log(`Group: ${info.group}`);
@@ -812,17 +1270,17 @@ export async function runCli(argv = process.argv.slice(2)) {
812
1270
  console.log(`Command: ${info.command}`);
813
1271
  console.log(`Arguments: ${info.arguments}`);
814
1272
  console.log(`Working directory: ${info.cwd}`);
815
- console.log(`Unit: ${info.unit}`);
816
- console.log(`Memory: ${info.memory}`);
817
- console.log(`Memory Peak: ${info.memoryPeak}`);
818
- console.log(`Memory High: ${info.memoryHigh}`);
819
- console.log(`Memory Max: ${info.memoryMax}`);
820
- console.log(`Swap Max: ${info.memorySwapMax}`);
1273
+ if (info.unit || info.unitFile) console.log(`Unit: ${info.unit || info.unitFile}`);
1274
+ if (info.memory) console.log(`Memory: ${info.memory}`);
1275
+ if (info.memoryPeak) console.log(`Memory Peak: ${info.memoryPeak}`);
1276
+ if (info.memoryHigh) console.log(`Memory High: ${info.memoryHigh}`);
1277
+ if (info.memoryMax) console.log(`Memory Max: ${info.memoryMax}`);
1278
+ if (info.memorySwapMax) console.log(`Swap Max: ${info.memorySwapMax}`);
821
1279
  break;
822
1280
  }
823
1281
 
824
1282
  case 'failures': {
825
- const failures = await getServiceFailures();
1283
+ const failures = await defaultManager.failures();
826
1284
  if (failures.length === 0) {
827
1285
  console.log('✓ No failed services.');
828
1286
  break;