unitup 0.2.0 → 0.4.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.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.4.0] - 2026-10-18
11
+
12
+ ### Added
13
+ - **Multi-Port Concurrent Routing**: Enhanced `Router` to listen on multiple distinct ports concurrently using an internal server map (`servers: Map<port, { server, port, host }>`), enabling multiple zero-downtime services to operate simultaneously without port collisions or premature listener termination.
14
+ - **Port-Scoped Traffic Isolation**: Added strict port-to-service mapping (`portServices: Map<port, Set<serviceName>>`) ensuring requests on port `A` are strictly isolated from services listening on port `B`.
15
+ - **Domain Normalization in Router**: Added hostname normalization (`hostNormalized`) to resolve incoming Host headers seamlessly against registered service names (e.g., `supanote.app` accurately resolves to service `supanoteapp`).
16
+ - **Automated GitHub Actions Release Pipeline**: Added `.github/workflows/release.yml` using Litepacks reusable release workflow with OIDC Trusted Publishing and provenance.
17
+ - **TypeScript Typecheck Script**: Added `npm run typecheck` (`tsc --noEmit`) to scripts for automated type validation in CI.
18
+ - **Package Distribution Optimization**: Configured `"files"` in `package.json` to publish only essential production files, reducing package size from 2.4 MB to 85 KB.
19
+
20
+ ### Changed
21
+ - **Eliminated Blind Cross-Service Fallback**: Removed indiscriminate fallback to other active backends when a Host header does not match; the router now safely returns `503 Service Unavailable` instead of cross-routing traffic to an unrelated backend.
22
+ - **Service Metadata Persistence**: `writeUnitFile` now explicitly preserves `port` and `deploy` configurations in `saveAppMetadata`, allowing systemd supervisors to retain their target public ports across daemon restarts.
23
+ - **Supervisor Public Port Resolution**: Updated `Supervisor` to reliably resolve public ports across `options.publicPort`, `config.port`, and `config.deploy?.port`.
24
+ - **Connection Draining on Shutdown**: Enhanced `Router.close()` to invoke `server.closeAllConnections()` for immediate and clean termination of idle keep-alive sockets.
25
+
26
+
10
27
  ## [0.1.5] - 2026-07-31
11
28
 
12
29
  ### Fixed
package/index.d.ts CHANGED
@@ -85,6 +85,24 @@ export interface CreateServiceOptions {
85
85
  resetAfter?: number | null;
86
86
  };
87
87
 
88
+ /**
89
+ * Maximum log level written to systemd journal.
90
+ * @default "info"
91
+ */
92
+ logLevelMax?: string;
93
+
94
+ /**
95
+ * Rate limit interval for systemd journal logging.
96
+ * @default "10s"
97
+ */
98
+ logRateLimitIntervalSec?: string;
99
+
100
+ /**
101
+ * Maximum number of logs per interval before rate limiting kicks in.
102
+ * @default 200
103
+ */
104
+ logRateLimitBurst?: number;
105
+
88
106
  /**
89
107
  * Positional arguments to pass to the script/command execution line.
90
108
  */
@@ -337,6 +355,20 @@ export class InvalidServiceConfigError extends UnitupError {
337
355
  export class ExecutableNotFoundError extends UnitupError {
338
356
  executable: string;
339
357
  }
358
+ export class ReadinessTimeoutError extends Error {
359
+ generation: GenerationRecord;
360
+ }
361
+ export class ProcessStartupError extends Error {
362
+ generation: GenerationRecord;
363
+ }
364
+ export class PortMismatchError extends Error {
365
+ generation: GenerationRecord;
366
+ detectedPort: number;
367
+ }
368
+ export class DeploymentLockError extends Error {
369
+ service: string;
370
+ lockedPid?: number;
371
+ }
340
372
 
341
373
  // Adapters
342
374
  export abstract class ServiceAdapter {
@@ -443,6 +475,7 @@ export function formatTable(
443
475
  columns: Array<{ key: string; label: string }>
444
476
  ): string;
445
477
  export function formatMemoryBytes(bytes: number | string): string;
478
+ export function formatDuration(ms: number): string;
446
479
  export function validateMemorySize(val: string | number, paramName?: string): string;
447
480
  export function validateDuration(val: string | number, paramName?: string): string;
448
481
 
@@ -515,4 +548,154 @@ export function readScheduleMetadata(name: string): Record<string, unknown> | nu
515
548
  export function getScheduleMetadataPath(name: string): string;
516
549
  export function getSchedulesDir(): string;
517
550
 
551
+ // Zero-Downtime Deployment Primitives & Orchestration
552
+ export interface GenerationRecord {
553
+ id: number;
554
+ service: string;
555
+ pid: number;
556
+ internalPort: number;
557
+ status: 'starting' | 'active' | 'previous' | 'draining' | 'stopped' | 'canary' | string;
558
+ createdAt: string;
559
+ activatedAt?: string;
560
+ drainingAt?: string;
561
+ stoppedAt?: string;
562
+ canaryWeight?: number;
563
+ }
564
+
565
+ export interface DeploymentResult {
566
+ service: string;
567
+ previousGeneration: number | null;
568
+ currentGeneration: number;
569
+ downtimeMs: number;
570
+ status: string;
571
+ canaryWeight?: number;
572
+ }
573
+
574
+ export interface PromoteResult {
575
+ service: string;
576
+ promotedGeneration: number;
577
+ previousGeneration: number | null;
578
+ downtimeMs: number;
579
+ status: string;
580
+ }
581
+
582
+ export interface RollbackResult {
583
+ service: string;
584
+ rolledBackFrom: number | null;
585
+ activeGeneration: number;
586
+ status: string;
587
+ }
588
+
589
+ export function deploy(name: string, options?: Record<string, unknown>): Promise<DeploymentResult>;
590
+ export function promote(name: string, options?: Record<string, unknown>): Promise<PromoteResult>;
591
+ export function rollback(name: string, options?: Record<string, unknown>): Promise<RollbackResult>;
592
+ export function generations(name: string, options?: Record<string, unknown>): GenerationRecord[];
593
+
594
+ export class ProcessManager {
595
+ constructor(options?: Record<string, unknown>);
596
+ start(
597
+ command: string,
598
+ args?: string[],
599
+ options?: Record<string, unknown>
600
+ ): { pid: number; child: any; exitPromise: Promise<any> };
601
+ stop(processOrPid: number | object, options?: { timeout?: number; signal?: string }): Promise<boolean>;
602
+ signal(processOrPid: number | object, signal?: string): boolean;
603
+ isAlive(processOrPid: number | object): boolean;
604
+ waitForExit(
605
+ processOrPid: number | object,
606
+ timeout?: number
607
+ ): Promise<{ exited: boolean; code?: number | null; signal?: string | null }>;
608
+ }
609
+
610
+ export class GenerationManager {
611
+ constructor(options?: Record<string, unknown>);
612
+ create(
613
+ serviceName: string,
614
+ config?: Record<string, unknown>,
615
+ options?: Record<string, unknown>
616
+ ): Promise<GenerationRecord>;
617
+ getActive(serviceName: string): GenerationRecord | null;
618
+ getPrevious(serviceName: string): GenerationRecord | null;
619
+ getCanary(serviceName: string): GenerationRecord | null;
620
+ getGeneration(serviceName: string, id: number | string): GenerationRecord | null;
621
+ markActive(generation: GenerationRecord): void;
622
+ markCanary(generation: GenerationRecord, weight?: number | string): void;
623
+ markDraining(generation: GenerationRecord): void;
624
+ remove(generation: GenerationRecord, purge?: boolean): void;
625
+ cleanupStale(serviceName: string): number;
626
+ listGenerations(serviceName: string): GenerationRecord[];
627
+ }
628
+
629
+ export class Router {
630
+ constructor(options?: Record<string, unknown>);
631
+ listen(publicPort: number, host?: string): Promise<{ port: number; host: string }>;
632
+ switchBackend(serviceName: string, generation: { id: number; internalPort: number }): void;
633
+ getActiveBackend(serviceName: string): GenerationRecord | null;
634
+ setCanary(serviceName: string, generation: { id: number; internalPort: number }, weight?: number | string): void;
635
+ clearCanary(serviceName: string): void;
636
+ promoteCanary(serviceName: string): void;
637
+ getCanary(serviceName: string): { generation: { id: number; internalPort: number }; weight: number } | null;
638
+ resolveBackend(serviceName: string, req?: any): { id: number; internalPort: number } | null;
639
+ getInFlightCount(generationId: number | string): number;
640
+ close(): Promise<void>;
641
+ }
642
+
643
+ export class ReadinessChecker {
644
+ constructor(options?: Record<string, unknown>);
645
+ checkTcp(port: number, host?: string, timeout?: number): Promise<boolean>;
646
+ checkHttp(port: number, path?: string, timeout?: number): Promise<boolean>;
647
+ ensure(generation: GenerationRecord, options?: { path?: string }): Promise<boolean>;
648
+ wait(
649
+ generation: GenerationRecord,
650
+ options?: { timeout?: number; interval?: number; path?: string }
651
+ ): Promise<boolean>;
652
+ }
653
+
654
+ export class DrainManager {
655
+ constructor(options?: Record<string, unknown>);
656
+ drain(
657
+ generation: GenerationRecord,
658
+ options?: { timeout?: number; pollInterval?: number; stopProcess?: boolean }
659
+ ): Promise<{ drained: boolean; inFlightRemaining: number; timedOut: boolean }>;
660
+ }
661
+
662
+ export class DeploymentLock {
663
+ constructor(options?: Record<string, unknown>);
664
+ acquire(serviceName: string): Promise<boolean>;
665
+ release(serviceName: string): Promise<boolean>;
666
+ }
667
+
668
+ export class DeploymentManager {
669
+ constructor(options?: Record<string, unknown>);
670
+ deploy(
671
+ serviceName: string,
672
+ serviceConfig?: Record<string, unknown>,
673
+ options?: Record<string, unknown>
674
+ ): Promise<DeploymentResult>;
675
+ promote(
676
+ serviceName: string,
677
+ serviceConfig?: Record<string, unknown>,
678
+ options?: Record<string, unknown>
679
+ ): Promise<PromoteResult>;
680
+ }
681
+
682
+ export class RollbackManager {
683
+ constructor(options?: Record<string, unknown>);
684
+ rollback(
685
+ serviceName: string,
686
+ serviceConfig?: Record<string, unknown>,
687
+ options?: Record<string, unknown>
688
+ ): Promise<RollbackResult>;
689
+ }
690
+
691
+ export class Supervisor {
692
+ constructor(serviceName: string, serviceConfig?: Record<string, unknown>, options?: Record<string, unknown>);
693
+ start(): Promise<Supervisor>;
694
+ stop(): Promise<void>;
695
+ static run(serviceName: string): Promise<void>;
696
+ }
697
+
698
+ export const defaultDeploymentManager: DeploymentManager;
699
+ export const defaultRollbackManager: RollbackManager;
700
+
518
701
  export default defaultManager;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "unitup",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "Minimal, zero-dependency cross-platform native service manager (systemd, launchd, Windows Services) for any runtime, script, or executable",
5
5
  "main": "src/index.js",
6
6
  "types": "./index.d.ts",
@@ -12,7 +12,8 @@
12
12
  "test": "node --test",
13
13
  "lint": "biome check .",
14
14
  "format": "biome format --write .",
15
- "check": "biome check ."
15
+ "check": "biome check .",
16
+ "typecheck": "tsc --noEmit"
16
17
  },
17
18
  "devDependencies": {
18
19
  "@biomejs/biome": "^1.9.4",
@@ -56,5 +57,6 @@
56
57
  "homepage": "https://github.com/litepacks/unitup#readme",
57
58
  "engines": {
58
59
  "node": ">=20.0.0"
59
- }
60
+ },
61
+ "files": ["bin", "src", "index.d.ts", "README.md", "LICENSE", "CHANGELOG.md"]
60
62
  }
package/src/cli.js CHANGED
@@ -1,6 +1,13 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import readline from 'node:readline';
4
+ import { GenerationManager } from './core/generation-manager.js';
5
+ import { IPCClient } from './core/ipc.js';
6
+ import { Supervisor } from './core/supervisor.js';
7
+ import {
8
+ defaultDeploymentManager as deploymentManager,
9
+ defaultRollbackManager as rollbackManager
10
+ } from './deploy/index.js';
4
11
  import { runDoctor } from './doctor.js';
5
12
  import { getAdapter } from './platform/index.js';
6
13
  import {
@@ -36,7 +43,10 @@ import {
36
43
  } from './systemd.js';
37
44
  import {
38
45
  findProjectConfig,
46
+ formatDuration,
47
+ formatRelativeTime,
39
48
  formatTable,
49
+ parseDurationMs,
40
50
  readGlobalConfig,
41
51
  readProjectConfig,
42
52
  sanitizeServiceName,
@@ -102,7 +112,13 @@ export function parseArgs(argv) {
102
112
  onBoot: '',
103
113
  onActive: '',
104
114
  randomDelay: '',
105
- persistent: false
115
+ persistent: false,
116
+ port: '',
117
+ zeroDowntime: false,
118
+ ready: '',
119
+ drain: '',
120
+ canary: false,
121
+ weight: ''
106
122
  }
107
123
  };
108
124
 
@@ -354,6 +370,36 @@ export function parseArgs(argv) {
354
370
  } else if (arg === '--persistent') {
355
371
  result.flags.persistent = true;
356
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++;
357
403
  } else if (!arg.startsWith('-')) {
358
404
  if (!result.command) {
359
405
  result.command = arg;
@@ -380,6 +426,10 @@ Usage:
380
426
  unitup start <name|@group> Start a service (--enable to enable on boot)
381
427
  unitup stop <name|@group> Stop a service
382
428
  unitup restart <name|@group> Restart a service
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
383
433
  unitup status <name|@group> Show status summary (--raw, --verbose)
384
434
  unitup enable <name> Enable service on startup/boot
385
435
  unitup disable <name> Disable service on startup/boot
@@ -613,7 +663,14 @@ export async function runCli(argv = process.argv.slice(2)) {
613
663
  memoryMax: memoryMaxFlag,
614
664
  memorySwapMax: memorySwapMaxFlag,
615
665
  defaultMemory: flags.defaultMemory,
616
- force: flags.force
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
+ }
617
674
  };
618
675
 
619
676
  if (flags.dryRun) {
@@ -696,6 +753,249 @@ export async function runCli(argv = process.argv.slice(2)) {
696
753
  break;
697
754
  }
698
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
+
699
999
  case 'enable': {
700
1000
  const nameArg = positionals[0];
701
1001
  if (!nameArg) {
@@ -738,6 +1038,56 @@ export async function runCli(argv = process.argv.slice(2)) {
738
1038
  console.log(raw);
739
1039
  } else {
740
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
+
741
1091
  console.log(`${status.name}\n`);
742
1092
  console.log(`Status: ${status.status || status.state}`);
743
1093
  console.log(`PID: ${status.pid}`);
@@ -746,6 +1096,28 @@ export async function runCli(argv = process.argv.slice(2)) {
746
1096
  console.log(`Command: ${status.command}`);
747
1097
  console.log(`Arguments: ${status.arguments}`);
748
1098
  console.log(`Working directory: ${status.cwd}`);
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
+
749
1121
  if (status.memory) console.log(`Memory: ${status.memory}`);
750
1122
  if (status.memoryPeak) console.log(`Memory Peak: ${status.memoryPeak}`);
751
1123
  if (status.memoryHigh) console.log(`Memory High: ${status.memoryHigh}`);