@ebowwa/pkg-ops 0.1.17 → 0.1.19

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.
@@ -0,0 +1,204 @@
1
+ /**
2
+ * Type definitions for PkgOps multi-version support.
3
+ *
4
+ * @module @ebowwa/pkg-ops/types
5
+ */
6
+ /**
7
+ * Metadata for an installed package version.
8
+ */
9
+ export interface VersionInfo {
10
+ /** Semver version string */
11
+ version: string;
12
+ /** ISO timestamp when this version was installed */
13
+ installedAt: string;
14
+ /** Size of the dist directory in bytes */
15
+ distSizeBytes: number | null;
16
+ /** Number of files in the dist directory */
17
+ fileCount: number | null;
18
+ /** Whether this is the currently active version */
19
+ active: boolean;
20
+ }
21
+ /**
22
+ * Configuration for a managed package with multi-version support.
23
+ */
24
+ export interface PackageConfig {
25
+ /** Currently active version */
26
+ version: string;
27
+ /** All installed versions with metadata */
28
+ versions: Record<string, VersionMetadata>;
29
+ /** Associated systemd service name (without .service suffix) */
30
+ service?: string;
31
+ /** Whether to auto-start the service after install */
32
+ autoStart?: boolean;
33
+ /** Custom environment variables for the service */
34
+ environment?: Record<string, string>;
35
+ /** Maximum number of versions to keep (default: 3) */
36
+ maxVersions?: number;
37
+ }
38
+ /**
39
+ * Metadata stored for each installed version.
40
+ */
41
+ export interface VersionMetadata {
42
+ /** ISO timestamp when this version was installed */
43
+ installedAt: string;
44
+ /** Size of the dist directory in bytes */
45
+ distSizeBytes: number | null;
46
+ /** Number of files in the dist directory */
47
+ fileCount: number | null;
48
+ }
49
+ /**
50
+ * Result of listing versions for a package.
51
+ */
52
+ export interface VersionsListResult {
53
+ packageName: string;
54
+ versions: VersionInfo[];
55
+ activeVersion: string;
56
+ maxVersions: number;
57
+ }
58
+ /**
59
+ * Result of switching to a different version.
60
+ */
61
+ export interface SwitchVersionResult {
62
+ success: boolean;
63
+ packageName: string;
64
+ previousVersion: string;
65
+ newVersion: string;
66
+ serviceRestarted: boolean;
67
+ message: string;
68
+ }
69
+ /**
70
+ * Result of pruning old versions.
71
+ */
72
+ export interface PruneResult {
73
+ success: boolean;
74
+ packageName: string;
75
+ removedVersions: string[];
76
+ keptVersions: string[];
77
+ freedBytes: number;
78
+ message: string;
79
+ }
80
+ /**
81
+ * Result of installing a package (updated for multi-version).
82
+ */
83
+ export interface InstallResult {
84
+ success: boolean;
85
+ version: string;
86
+ previousVersion?: string;
87
+ /** Whether this was a new version install or reinstall */
88
+ isNew: boolean;
89
+ /** Total versions now installed */
90
+ totalVersions: number;
91
+ message: string;
92
+ }
93
+ /**
94
+ * Result of rolling back a package.
95
+ */
96
+ export interface RollbackResult {
97
+ success: boolean;
98
+ previousVersion: string;
99
+ currentVersion: string;
100
+ /** Available versions to rollback to */
101
+ availableVersions: string[];
102
+ message: string;
103
+ }
104
+ /**
105
+ * Package info with version details.
106
+ */
107
+ export interface PackageInfo {
108
+ name: string;
109
+ version: string;
110
+ installed: boolean;
111
+ service?: string;
112
+ /** Total installed versions */
113
+ totalVersions?: number;
114
+ /** All installed versions */
115
+ versions?: VersionInfo[];
116
+ }
117
+ /**
118
+ * Main configuration structure.
119
+ */
120
+ export interface PkgOpsConfig {
121
+ /** Managed packages */
122
+ packages: Record<string, PackageConfig>;
123
+ /** Health check HTTP port (default: 8914) */
124
+ healthPort?: number;
125
+ /** Working directory for installations (default: /root) */
126
+ workDir?: string;
127
+ /** Log level (default: "info") */
128
+ logLevel?: "debug" | "info" | "warn" | "error";
129
+ /** Default max versions to keep per package */
130
+ defaultMaxVersions?: number;
131
+ }
132
+ /**
133
+ * JSON-RPC request structure.
134
+ */
135
+ export interface JsonRpcRequest {
136
+ jsonrpc: "2.0";
137
+ id: string;
138
+ method: string;
139
+ params?: Record<string, unknown>;
140
+ }
141
+ /**
142
+ * JSON-RPC response structure.
143
+ */
144
+ export interface JsonRpcResponse {
145
+ jsonrpc: "2.0";
146
+ id: string;
147
+ result?: unknown;
148
+ error?: {
149
+ code: number;
150
+ message: string;
151
+ data?: unknown;
152
+ };
153
+ }
154
+ /**
155
+ * Vulnerability audit result.
156
+ */
157
+ export interface AuditResult {
158
+ packageName: string;
159
+ severity: string;
160
+ vulnerability: string;
161
+ description: string;
162
+ }
163
+ /**
164
+ * Package verification result.
165
+ */
166
+ export interface VerifyResult {
167
+ packageName: string;
168
+ version: string;
169
+ success: boolean;
170
+ distExists: boolean;
171
+ checksum: string | null;
172
+ message: string;
173
+ }
174
+ /**
175
+ * Bundle size info.
176
+ */
177
+ export interface BundleSize {
178
+ packageName: string;
179
+ version: string;
180
+ distSizeBytes: number;
181
+ fileCount: number;
182
+ }
183
+ /**
184
+ * Installed package detailed info.
185
+ */
186
+ export interface InstalledPackageInfo {
187
+ packageName: string;
188
+ version: string;
189
+ distSizeBytes: number | null;
190
+ installedAt: string | null;
191
+ totalVersions?: number;
192
+ }
193
+ /**
194
+ * Health check result.
195
+ */
196
+ export interface HealthCheckResult {
197
+ healthy: boolean;
198
+ services: Array<{
199
+ name: string;
200
+ status: string;
201
+ pid: number;
202
+ }>;
203
+ }
204
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ebowwa/pkg-ops",
3
- "version": "0.1.17",
3
+ "version": "0.1.19",
4
4
  "type": "module",
5
5
  "description": "Package operations CLI - installs @ebowwa/* npm packages and manages systemd services",
6
6
  "bin": {
@@ -21,7 +21,7 @@
21
21
  "README.md"
22
22
  ],
23
23
  "scripts": {
24
- "build": "bun build src/index.ts --outdir dist --target node --sourcemap --minify && tsc --emitDeclarationOnly --declaration",
24
+ "build": "bun build src/index.ts --outdir dist --target bun --sourcemap --minify && tsc --emitDeclarationOnly --declaration",
25
25
  "build:rust": "cd rust && cargo build --release",
26
26
  "dev": "bun run src/index.ts",
27
27
  "typecheck": "tsc --noEmit",
package/rust/src/lib.rs CHANGED
@@ -94,6 +94,52 @@ pub struct InstalledPackageInfo {
94
94
  pub version: String,
95
95
  pub dist_size_bytes: Option<u64>,
96
96
  pub installed_at: Option<String>, // ISO timestamp
97
+ pub total_versions: Option<u32>,
98
+ pub versions: Option<Vec<VersionInfo>>,
99
+ }
100
+
101
+ /// Version information for multi-version support
102
+ #[derive(Debug, Clone, Serialize, Deserialize)]
103
+ #[serde(rename_all = "camelCase")]
104
+ pub struct VersionInfo {
105
+ pub version: String,
106
+ pub installed_at: String,
107
+ pub dist_size_bytes: Option<u64>,
108
+ pub file_count: Option<u32>,
109
+ pub active: bool,
110
+ }
111
+
112
+ /// Result of switching versions
113
+ #[derive(Debug, Clone, Serialize, Deserialize)]
114
+ #[serde(rename_all = "camelCase")]
115
+ pub struct SwitchResult {
116
+ pub success: bool,
117
+ pub package_name: String,
118
+ pub from_version: String,
119
+ pub to_version: String,
120
+ pub message: String,
121
+ }
122
+
123
+ /// Result of pruning old versions
124
+ #[derive(Debug, Clone, Serialize, Deserialize)]
125
+ #[serde(rename_all = "camelCase")]
126
+ pub struct PruneResult {
127
+ pub success: bool,
128
+ pub package_name: String,
129
+ pub removed_versions: Vec<String>,
130
+ pub kept_versions: Vec<String>,
131
+ pub freed_bytes: u64,
132
+ pub message: String,
133
+ }
134
+
135
+ /// Multi-version package summary
136
+ #[derive(Debug, Clone, Serialize, Deserialize)]
137
+ #[serde(rename_all = "camelCase")]
138
+ pub struct MultiVersionPackage {
139
+ pub package_name: String,
140
+ pub active_version: String,
141
+ pub total_versions: u32,
142
+ pub versions: Vec<String>,
97
143
  }
98
144
 
99
145
  // ---------------------------------------------------------------------------
@@ -797,3 +843,395 @@ pub fn get_installed_info() -> Vec<InstalledPackageInfo> {
797
843
 
798
844
  info_list
799
845
  }
846
+
847
+ // ---------------------------------------------------------------------------
848
+ // Multi-Version Operations
849
+ // ---------------------------------------------------------------------------
850
+
851
+ /// Directory for storing multiple package versions
852
+ const VERSIONS_DIR: &str = "/opt/pkg-ops/versions";
853
+
854
+ /// List all installed versions of a package
855
+ pub fn list_versions(package_name: &str) -> Vec<VersionInfo> {
856
+ info!("Listing versions for {}", package_name);
857
+
858
+ let mut versions = Vec::new();
859
+
860
+ // Check the multi-version directory
861
+ let package_versions_dir = Path::new(VERSIONS_DIR)
862
+ .join(package_name.replace("@", "").replace("/", "-"));
863
+
864
+ if package_versions_dir.exists() {
865
+ if let Ok(entries) = fs::read_dir(&package_versions_dir) {
866
+ for entry in entries.flatten() {
867
+ let version_dir = entry.path();
868
+ if !version_dir.is_dir() {
869
+ continue;
870
+ }
871
+
872
+ let version = entry.file_name().to_string_lossy().to_string();
873
+ let dist_path = version_dir.join("dist");
874
+
875
+ // Get installed timestamp from directory mtime
876
+ let installed_at = fs::metadata(&version_dir)
877
+ .ok()
878
+ .and_then(|m| m.modified().ok())
879
+ .map(|t| {
880
+ let datetime: chrono::DateTime<chrono::Utc> = t.into();
881
+ datetime.to_rfc3339()
882
+ })
883
+ .unwrap_or_else(|| "unknown".to_string());
884
+
885
+ let (dist_size, file_count) = if dist_path.exists() {
886
+ let (size, count) = calculate_dir_size(&dist_path);
887
+ (Some(size), Some(count))
888
+ } else {
889
+ (None, None)
890
+ };
891
+
892
+ versions.push(VersionInfo {
893
+ version,
894
+ installed_at,
895
+ dist_size_bytes: dist_size,
896
+ file_count,
897
+ active: false, // Will be set below
898
+ });
899
+ }
900
+ }
901
+ }
902
+
903
+ // Also check the active version in node_modules
904
+ let active_version = get_installed_version(package_name);
905
+ if let Some(ref active) = active_version {
906
+ // Mark active version
907
+ for v in &mut versions {
908
+ if &v.version == active {
909
+ v.active = true;
910
+ }
911
+ }
912
+
913
+ // If active version not in versions list, add it
914
+ if !versions.iter().any(|v| &v.version == active) {
915
+ let package_path = Path::new(WORK_DIR)
916
+ .join("node_modules")
917
+ .join(package_name);
918
+
919
+ let dist_path = package_path.join("dist");
920
+ let (dist_size, file_count) = if dist_path.exists() {
921
+ let (size, count) = calculate_dir_size(&dist_path);
922
+ (Some(size), Some(count))
923
+ } else {
924
+ (None, None)
925
+ };
926
+
927
+ let installed_at = package_path.join("package.json")
928
+ .exists()
929
+ .then(|| {
930
+ fs::metadata(package_path.join("package.json"))
931
+ .ok()
932
+ .and_then(|m| m.modified().ok())
933
+ .map(|t| {
934
+ let datetime: chrono::DateTime<chrono::Utc> = t.into();
935
+ datetime.to_rfc3339()
936
+ })
937
+ })
938
+ .flatten()
939
+ .unwrap_or_else(|| "unknown".to_string());
940
+
941
+ versions.push(VersionInfo {
942
+ version: active.clone(),
943
+ installed_at,
944
+ dist_size_bytes: dist_size,
945
+ file_count,
946
+ active: true,
947
+ });
948
+ }
949
+ }
950
+
951
+ // Sort by version descending (newest first)
952
+ versions.sort_by(|a, b| {
953
+ // Simple semver comparison
954
+ let a_parts: Vec<u32> = a.version
955
+ .split('.')
956
+ .filter_map(|s| s.parse().ok())
957
+ .collect();
958
+ let b_parts: Vec<u32> = b.version
959
+ .split('.')
960
+ .filter_map(|s| s.parse().ok())
961
+ .collect();
962
+
963
+ for i in 0..std::cmp::min(a_parts.len(), b_parts.len()) {
964
+ match b_parts.get(i).cmp(&a_parts.get(i)) {
965
+ std::cmp::Ordering::Equal => continue,
966
+ other => return other,
967
+ }
968
+ }
969
+ b_parts.len().cmp(&a_parts.len())
970
+ });
971
+
972
+ versions
973
+ }
974
+
975
+ /// Switch to a specific installed version
976
+ pub fn switch_version(package_name: &str, target_version: &str) -> SwitchResult {
977
+ info!("Switching {} to version {}", package_name, target_version);
978
+
979
+ let current_version = match get_installed_version(package_name) {
980
+ Some(v) => v,
981
+ None => {
982
+ return SwitchResult {
983
+ success: false,
984
+ package_name: package_name.to_string(),
985
+ from_version: "none".to_string(),
986
+ to_version: target_version.to_string(),
987
+ message: "Package not installed".to_string(),
988
+ };
989
+ }
990
+ };
991
+
992
+ if current_version == target_version {
993
+ return SwitchResult {
994
+ success: true,
995
+ package_name: package_name.to_string(),
996
+ from_version: current_version.clone(),
997
+ to_version: target_version.to_string(),
998
+ message: "Already on target version".to_string(),
999
+ };
1000
+ }
1001
+
1002
+ // Check if target version exists in versions directory
1003
+ let version_dir = Path::new(VERSIONS_DIR)
1004
+ .join(package_name.replace("@", "").replace("/", "-"))
1005
+ .join(target_version);
1006
+
1007
+ if !version_dir.exists() {
1008
+ // Try installing from npm
1009
+ let package_spec = format!("{}@{}", package_name, target_version);
1010
+ let output = match Command::new("bun")
1011
+ .args(["add", &package_spec])
1012
+ .current_dir(WORK_DIR)
1013
+ .output()
1014
+ {
1015
+ Ok(o) => o,
1016
+ Err(e) => {
1017
+ return SwitchResult {
1018
+ success: false,
1019
+ package_name: package_name.to_string(),
1020
+ from_version: current_version,
1021
+ to_version: target_version.to_string(),
1022
+ message: format!("Failed to run bun add: {}", e),
1023
+ };
1024
+ }
1025
+ };
1026
+
1027
+ if !output.status.success() {
1028
+ let stderr = String::from_utf8_lossy(&output.stderr);
1029
+ return SwitchResult {
1030
+ success: false,
1031
+ package_name: package_name.to_string(),
1032
+ from_version: current_version,
1033
+ to_version: target_version.to_string(),
1034
+ message: format!("Version {} not found locally or on npm: {}", target_version, stderr),
1035
+ };
1036
+ }
1037
+
1038
+ return SwitchResult {
1039
+ success: true,
1040
+ package_name: package_name.to_string(),
1041
+ from_version: current_version,
1042
+ to_version: target_version.to_string(),
1043
+ message: format!("Installed and switched to {}", target_version),
1044
+ };
1045
+ }
1046
+
1047
+ // Backup current version to versions directory
1048
+ let current_version_dir = Path::new(VERSIONS_DIR)
1049
+ .join(package_name.replace("@", "").replace("/", "-"))
1050
+ .join(&current_version);
1051
+
1052
+ let current_package_path = Path::new(WORK_DIR)
1053
+ .join("node_modules")
1054
+ .join(package_name);
1055
+
1056
+ if current_package_path.exists() {
1057
+ // Create versions directory if needed
1058
+ let _ = fs::create_dir_all(current_version_dir.parent().unwrap());
1059
+
1060
+ // Copy current version to versions dir (if not already there)
1061
+ if !current_version_dir.exists() {
1062
+ if let Err(e) = copy_dir_all(&current_package_path, &current_version_dir) {
1063
+ error!("Failed to backup current version: {}", e);
1064
+ }
1065
+ }
1066
+ }
1067
+
1068
+ // Remove current version from node_modules
1069
+ if let Err(e) = fs::remove_dir_all(&current_package_path) {
1070
+ error!("Failed to remove current version: {}", e);
1071
+ }
1072
+
1073
+ // Copy target version to node_modules
1074
+ if let Err(e) = copy_dir_all(&version_dir, &current_package_path) {
1075
+ return SwitchResult {
1076
+ success: false,
1077
+ package_name: package_name.to_string(),
1078
+ from_version: current_version,
1079
+ to_version: target_version.to_string(),
1080
+ message: format!("Failed to copy target version: {}", e),
1081
+ };
1082
+ }
1083
+
1084
+ info!("Switched {} from {} to {}", package_name, current_version, target_version);
1085
+
1086
+ SwitchResult {
1087
+ success: true,
1088
+ package_name: package_name.to_string(),
1089
+ from_version: current_version,
1090
+ to_version: target_version.to_string(),
1091
+ message: format!("Switched from {} to {}", current_version, target_version),
1092
+ }
1093
+ }
1094
+
1095
+ /// Prune old versions, keeping only N most recent
1096
+ pub fn prune_versions(package_name: &str, keep_count: u32) -> PruneResult {
1097
+ info!("Pruning {} versions, keeping {}", package_name, keep_count);
1098
+
1099
+ let versions = list_versions(package_name);
1100
+
1101
+ if versions.len() <= keep_count as usize {
1102
+ return PruneResult {
1103
+ success: true,
1104
+ package_name: package_name.to_string(),
1105
+ removed_versions: vec![],
1106
+ kept_versions: versions.iter().map(|v| v.version.clone()).collect(),
1107
+ freed_bytes: 0,
1108
+ message: format!("Only {} versions installed, nothing to prune", versions.len()),
1109
+ };
1110
+ }
1111
+
1112
+ let package_versions_dir = Path::new(VERSIONS_DIR)
1113
+ .join(package_name.replace("@", "").replace("/", "-"));
1114
+
1115
+ // Sort by installed date (newest first) and keep top N
1116
+ let mut sorted_versions: Vec<_> = versions.into_iter().collect();
1117
+ sorted_versions.sort_by(|a, b| b.installed_at.cmp(&a.installed_at));
1118
+
1119
+ let kept: Vec<_> = sorted_versions.iter()
1120
+ .take(keep_count as usize)
1121
+ .map(|v| v.version.clone())
1122
+ .collect();
1123
+
1124
+ let mut removed = Vec::new();
1125
+ let mut freed_bytes = 0u64;
1126
+
1127
+ for version_info in sorted_versions.iter().skip(keep_count as usize) {
1128
+ // Don't remove active version
1129
+ if version_info.active {
1130
+ continue;
1131
+ }
1132
+
1133
+ let version_dir = package_versions_dir.join(&version_info.version);
1134
+ if version_dir.exists() {
1135
+ // Calculate size before removing
1136
+ let (size, _) = calculate_dir_size(&version_dir);
1137
+ freed_bytes += size;
1138
+
1139
+ if let Err(e) = fs::remove_dir_all(&version_dir) {
1140
+ error!("Failed to remove version {}: {}", version_info.version, e);
1141
+ } else {
1142
+ removed.push(version_info.version.clone());
1143
+ }
1144
+ }
1145
+ }
1146
+
1147
+ PruneResult {
1148
+ success: true,
1149
+ package_name: package_name.to_string(),
1150
+ removed_versions: removed.clone(),
1151
+ kept_versions: kept,
1152
+ freed_bytes,
1153
+ message: if removed.is_empty() {
1154
+ "No versions to remove".to_string()
1155
+ } else {
1156
+ format!("Removed {} version(s), freed {} bytes", removed.len(), freed_bytes)
1157
+ },
1158
+ }
1159
+ }
1160
+
1161
+ /// Remove a specific version
1162
+ pub fn remove_version(package_name: &str, version: &str) -> Result<String, String> {
1163
+ info!("Removing {}@{}", package_name, version);
1164
+
1165
+ // Check if this is the active version
1166
+ let active_version = get_installed_version(package_name);
1167
+ if active_version.as_deref() == Some(version) {
1168
+ return Err("Cannot remove active version. Switch to another version first.".to_string());
1169
+ }
1170
+
1171
+ let version_dir = Path::new(VERSIONS_DIR)
1172
+ .join(package_name.replace("@", "").replace("/", "-"))
1173
+ .join(version);
1174
+
1175
+ if !version_dir.exists() {
1176
+ return Err(format!("Version {} not found", version));
1177
+ }
1178
+
1179
+ let (size, _) = calculate_dir_size(&version_dir);
1180
+
1181
+ if let Err(e) = fs::remove_dir_all(&version_dir) {
1182
+ return Err(format!("Failed to remove version: {}", e));
1183
+ }
1184
+
1185
+ Ok(format!("Removed {}@{} (freed {} bytes)", package_name, version, size))
1186
+ }
1187
+
1188
+ /// Get packages with multiple versions installed
1189
+ pub fn get_multi_version_packages() -> Vec<MultiVersionPackage> {
1190
+ info!("Getting packages with multiple versions");
1191
+
1192
+ let packages = list_packages();
1193
+ let mut result = Vec::new();
1194
+
1195
+ for pkg in packages {
1196
+ if !pkg.installed {
1197
+ continue;
1198
+ }
1199
+
1200
+ let versions = list_versions(&pkg.name);
1201
+ if versions.len() > 1 {
1202
+ let active_version = versions.iter()
1203
+ .find(|v| v.active)
1204
+ .map(|v| v.version.clone())
1205
+ .unwrap_or_else(|| pkg.version.clone());
1206
+
1207
+ result.push(MultiVersionPackage {
1208
+ package_name: pkg.name.clone(),
1209
+ active_version,
1210
+ total_versions: versions.len() as u32,
1211
+ versions: versions.iter().map(|v| v.version.clone()).collect(),
1212
+ });
1213
+ }
1214
+ }
1215
+
1216
+ result
1217
+ }
1218
+
1219
+ /// Helper to recursively copy a directory
1220
+ fn copy_dir_all(src: &Path, dst: &Path) -> std::io::Result<()> {
1221
+ fs::create_dir_all(dst)?;
1222
+
1223
+ for entry in fs::read_dir(src)? {
1224
+ let entry = entry?;
1225
+ let ty = entry.file_type()?;
1226
+ let src_path = entry.path();
1227
+ let dst_path = dst.join(entry.file_name());
1228
+
1229
+ if ty.is_dir() {
1230
+ copy_dir_all(&src_path, &dst_path)?;
1231
+ } else {
1232
+ fs::copy(&src_path, &dst_path)?;
1233
+ }
1234
+ }
1235
+
1236
+ Ok(())
1237
+ }