@camstack/server 1.0.0 → 1.0.1

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 (234) hide show
  1. package/{src/agent-status-page.ts → dist/agent-status-page.js} +30 -45
  2. package/dist/api/addon-upload.js +441 -0
  3. package/dist/api/addons-custom.router.js +91 -0
  4. package/dist/api/auth-whoami.js +55 -0
  5. package/dist/api/bridge-addons.router.js +109 -0
  6. package/dist/api/capabilities.router.js +229 -0
  7. package/dist/api/core/addon-settings.router.js +117 -0
  8. package/dist/api/core/agents.router.js +73 -0
  9. package/dist/api/core/auth.router.js +286 -0
  10. package/dist/api/core/bulk-update-coordinator.js +229 -0
  11. package/dist/api/core/cap-providers.js +1124 -0
  12. package/dist/api/core/capabilities.router.js +138 -0
  13. package/dist/api/core/collection-preference.js +17 -0
  14. package/dist/api/core/event-bus-proxy.router.js +45 -0
  15. package/dist/api/core/hwaccel.router.js +91 -0
  16. package/dist/api/core/live-events.router.js +61 -0
  17. package/dist/api/core/logs.router.js +172 -0
  18. package/dist/api/core/notifications.router.js +67 -0
  19. package/dist/api/core/repl.router.js +35 -0
  20. package/dist/api/core/settings-backend.router.js +121 -0
  21. package/dist/api/core/stream-probe.router.js +58 -0
  22. package/dist/api/core/system-events.router.js +100 -0
  23. package/dist/api/health/health.routes.js +68 -0
  24. package/{src/api/oauth2/consent-page.ts → dist/api/oauth2/consent-page.js} +11 -20
  25. package/dist/api/oauth2/oauth2-routes.js +219 -0
  26. package/dist/api/trpc/cap-mount-helpers.js +194 -0
  27. package/dist/api/trpc/cap-route-error-formatter.js +133 -0
  28. package/dist/api/trpc/client-ip.js +147 -0
  29. package/dist/api/trpc/core-cap-bridge.js +115 -0
  30. package/dist/api/trpc/generated-cap-mounts.js +388 -0
  31. package/dist/api/trpc/generated-cap-routers.js +7635 -0
  32. package/dist/api/trpc/scope-access.js +93 -0
  33. package/dist/api/trpc/trpc.context.js +184 -0
  34. package/dist/api/trpc/trpc.middleware.js +139 -0
  35. package/dist/api/trpc/trpc.router.js +188 -0
  36. package/dist/auth/session-cookie.js +47 -0
  37. package/dist/boot/boot-config.js +241 -0
  38. package/dist/boot/integration-id-backfill.js +76 -0
  39. package/dist/boot/post-boot.service.js +85 -0
  40. package/dist/core/addon/addon-call-gateway.js +99 -0
  41. package/dist/core/addon/addon-package.service.js +1560 -0
  42. package/dist/core/addon/addon-registry.service.js +2739 -0
  43. package/{src/core/addon/addon-row-manifest.ts → dist/core/addon/addon-row-manifest.js} +5 -5
  44. package/dist/core/addon/addon-search.service.js +62 -0
  45. package/dist/core/addon/addon-settings-provider.js +102 -0
  46. package/dist/core/addon/addon.tokens.js +5 -0
  47. package/dist/core/addon-bridge/addon-bridge.service.js +145 -0
  48. package/dist/core/addon-pages/addon-pages.service.js +107 -0
  49. package/dist/core/addon-widgets/addon-widgets.service.js +120 -0
  50. package/dist/core/agent/agent-registry.service.js +477 -0
  51. package/dist/core/auth/auth.service.js +10 -0
  52. package/dist/core/capability/capability.service.js +58 -0
  53. package/dist/core/config/config.schema.js +7 -0
  54. package/dist/core/config/config.service.js +10 -0
  55. package/dist/core/events/event-bus.service.js +83 -0
  56. package/dist/core/feature/feature.service.js +10 -0
  57. package/dist/core/lifecycle/lifecycle-state-machine.js +6 -0
  58. package/dist/core/logging/log-ring-buffer.js +6 -0
  59. package/dist/core/logging/logging.service.js +130 -0
  60. package/dist/core/logging/scoped-logger.js +6 -0
  61. package/dist/core/moleculer/cap-call-fn.js +50 -0
  62. package/dist/core/moleculer/cap-route-authority.js +122 -0
  63. package/dist/core/moleculer/moleculer.service.js +898 -0
  64. package/dist/core/network/network-quality.service.js +7 -0
  65. package/dist/core/notification/notification-wrapper.service.js +33 -0
  66. package/dist/core/notification/toast-wrapper.service.js +25 -0
  67. package/dist/core/provider/provider.tokens.js +4 -0
  68. package/dist/core/repl/repl-engine.service.js +140 -0
  69. package/dist/core/storage/fs-storage-backend.js +6 -0
  70. package/dist/core/storage/storage-location-manager.js +6 -0
  71. package/dist/core/storage/storage.service.js +7 -0
  72. package/dist/core/streaming/stream-probe.service.js +209 -0
  73. package/dist/core/topology/topology-emitter.service.js +106 -0
  74. package/dist/launcher.js +325 -0
  75. package/dist/main.js +1098 -0
  76. package/dist/manual-boot.js +227 -0
  77. package/package.json +5 -1
  78. package/src/__tests__/addon-install-e2e.test.ts +0 -74
  79. package/src/__tests__/addon-pages-e2e.test.ts +0 -200
  80. package/src/__tests__/addon-route-session.test.ts +0 -17
  81. package/src/__tests__/addon-settings-router.spec.ts +0 -67
  82. package/src/__tests__/addon-upload.spec.ts +0 -475
  83. package/src/__tests__/agent-registry.spec.ts +0 -179
  84. package/src/__tests__/agent-status-page.spec.ts +0 -82
  85. package/src/__tests__/auth-session-cookie.test.ts +0 -48
  86. package/src/__tests__/bulk-update-coordinator.spec.ts +0 -303
  87. package/src/__tests__/cap-ownership-authority.spec.ts +0 -431
  88. package/src/__tests__/cap-providers/cap-providers-location-import.spec.ts +0 -206
  89. package/src/__tests__/cap-providers/cap-usage-graph.spec.ts +0 -37
  90. package/src/__tests__/cap-providers/compute-topology-categories.spec.ts +0 -110
  91. package/src/__tests__/cap-providers/integrations-delete-cascade.spec.ts +0 -292
  92. package/src/__tests__/cap-providers-bulk-update.spec.ts +0 -408
  93. package/src/__tests__/cap-route-adapter.spec.ts +0 -302
  94. package/src/__tests__/cap-routers/_meta.spec.ts +0 -199
  95. package/src/__tests__/cap-routers/addon-settings.router.spec.ts +0 -115
  96. package/src/__tests__/cap-routers/broker-routing.router.spec.ts +0 -177
  97. package/src/__tests__/cap-routers/cap-route-error-formatter.spec.ts +0 -125
  98. package/src/__tests__/cap-routers/capabilities-node.spec.ts +0 -68
  99. package/src/__tests__/cap-routers/device-link-overlay.spec.ts +0 -137
  100. package/src/__tests__/cap-routers/device-manager-aggregate.router.spec.ts +0 -194
  101. package/src/__tests__/cap-routers/harness.ts +0 -163
  102. package/src/__tests__/cap-routers/metrics-provider.router.spec.ts +0 -133
  103. package/src/__tests__/cap-routers/null-provider-guard.spec.ts +0 -64
  104. package/src/__tests__/cap-routers/pipeline-executor.router.spec.ts +0 -159
  105. package/src/__tests__/cap-routers/settings-store.router.spec.ts +0 -291
  106. package/src/__tests__/capability-e2e.test.ts +0 -384
  107. package/src/__tests__/cli-e2e.test.ts +0 -150
  108. package/src/__tests__/core-cap-bridge.spec.ts +0 -91
  109. package/src/__tests__/dev-bootstrap-shm-ring.spec.ts +0 -40
  110. package/src/__tests__/device-settings-contribution-dispatch.spec.ts +0 -280
  111. package/src/__tests__/embedded-deps-e2e.test.ts +0 -125
  112. package/src/__tests__/event-bus-proxy-router.spec.ts +0 -75
  113. package/src/__tests__/fixtures/mock-analysis-addon-a.ts +0 -37
  114. package/src/__tests__/fixtures/mock-analysis-addon-b.ts +0 -37
  115. package/src/__tests__/fixtures/mock-log-addon.ts +0 -37
  116. package/src/__tests__/fixtures/mock-storage-addon.ts +0 -40
  117. package/src/__tests__/framework-allowlist.spec.ts +0 -96
  118. package/src/__tests__/framework-installer-defer-restart.spec.ts +0 -165
  119. package/src/__tests__/https-e2e.test.ts +0 -124
  120. package/src/__tests__/lifecycle-e2e.test.ts +0 -189
  121. package/src/__tests__/live-events-subscription.spec.ts +0 -149
  122. package/src/__tests__/moleculer/uds-readiness.spec.ts +0 -150
  123. package/src/__tests__/moleculer/uds-topology.spec.ts +0 -418
  124. package/src/__tests__/moleculer/uds-unowned-call.spec.ts +0 -383
  125. package/src/__tests__/moleculer-register-node-idempotency.spec.ts +0 -273
  126. package/src/__tests__/native-cap-route.spec.ts +0 -427
  127. package/src/__tests__/oauth2-account-linking.spec.ts +0 -867
  128. package/src/__tests__/post-boot-restart.spec.ts +0 -161
  129. package/src/__tests__/singleton-contention.test.ts +0 -499
  130. package/src/__tests__/streaming-diagnostic.test.ts +0 -615
  131. package/src/__tests__/streaming-scale.test.ts +0 -314
  132. package/src/__tests__/uds-addon-call-wiring.spec.ts +0 -242
  133. package/src/__tests__/uds-log-ingest.spec.ts +0 -183
  134. package/src/api/__tests__/addons-custom.spec.ts +0 -148
  135. package/src/api/__tests__/capabilities.router.test.ts +0 -56
  136. package/src/api/addon-upload.ts +0 -529
  137. package/src/api/addons-custom.router.ts +0 -101
  138. package/src/api/auth-whoami.ts +0 -101
  139. package/src/api/bridge-addons.router.ts +0 -122
  140. package/src/api/capabilities.router.ts +0 -265
  141. package/src/api/core/__tests__/auth-router-totp.spec.ts +0 -297
  142. package/src/api/core/__tests__/integration-markers.spec.ts +0 -10
  143. package/src/api/core/addon-settings.router.ts +0 -127
  144. package/src/api/core/agents.router.ts +0 -86
  145. package/src/api/core/auth.router.ts +0 -322
  146. package/src/api/core/bulk-update-coordinator.ts +0 -305
  147. package/src/api/core/cap-providers.ts +0 -1339
  148. package/src/api/core/capabilities.router.ts +0 -149
  149. package/src/api/core/collection-preference.ts +0 -40
  150. package/src/api/core/event-bus-proxy.router.ts +0 -45
  151. package/src/api/core/hwaccel.router.ts +0 -108
  152. package/src/api/core/live-events.router.ts +0 -67
  153. package/src/api/core/logs.router.ts +0 -195
  154. package/src/api/core/notifications.router.ts +0 -66
  155. package/src/api/core/repl.router.ts +0 -39
  156. package/src/api/core/settings-backend.router.ts +0 -140
  157. package/src/api/core/stream-probe.router.ts +0 -57
  158. package/src/api/core/system-events.router.ts +0 -125
  159. package/src/api/health/health.routes.ts +0 -117
  160. package/src/api/oauth2/__tests__/oauth2-routes.spec.ts +0 -62
  161. package/src/api/oauth2/oauth2-routes.ts +0 -281
  162. package/src/api/trpc/__tests__/client-ip.spec.ts +0 -146
  163. package/src/api/trpc/__tests__/scope-access-device.spec.ts +0 -268
  164. package/src/api/trpc/__tests__/scope-access.spec.ts +0 -102
  165. package/src/api/trpc/__tests__/webrtc-session-ua-enrich.spec.ts +0 -136
  166. package/src/api/trpc/cap-mount-helpers.ts +0 -245
  167. package/src/api/trpc/cap-route-error-formatter.ts +0 -171
  168. package/src/api/trpc/client-ip.ts +0 -147
  169. package/src/api/trpc/core-cap-bridge.ts +0 -154
  170. package/src/api/trpc/generated-cap-mounts.ts +0 -1240
  171. package/src/api/trpc/generated-cap-routers.ts +0 -11523
  172. package/src/api/trpc/scope-access.ts +0 -110
  173. package/src/api/trpc/trpc.context.ts +0 -258
  174. package/src/api/trpc/trpc.middleware.ts +0 -146
  175. package/src/api/trpc/trpc.router.ts +0 -389
  176. package/src/auth/session-cookie.ts +0 -54
  177. package/src/boot/__tests__/integration-id-backfill.spec.ts +0 -131
  178. package/src/boot/boot-config.ts +0 -259
  179. package/src/boot/integration-id-backfill.ts +0 -109
  180. package/src/boot/post-boot.service.ts +0 -105
  181. package/src/core/addon/__tests__/addon-registry-capability.test.ts +0 -62
  182. package/src/core/addon/__tests__/addon-row-manifest.spec.ts +0 -62
  183. package/src/core/addon/addon-call-gateway.ts +0 -171
  184. package/src/core/addon/addon-package.service.ts +0 -1787
  185. package/src/core/addon/addon-registry.service.ts +0 -3130
  186. package/src/core/addon/addon-search.service.ts +0 -91
  187. package/src/core/addon/addon-settings-provider.ts +0 -220
  188. package/src/core/addon/addon.tokens.ts +0 -2
  189. package/src/core/addon-bridge/addon-bridge.service.ts +0 -130
  190. package/src/core/addon-pages/addon-pages.service.spec.ts +0 -117
  191. package/src/core/addon-pages/addon-pages.service.ts +0 -82
  192. package/src/core/addon-widgets/addon-widgets.service.ts +0 -95
  193. package/src/core/agent/agent-registry.service.ts +0 -529
  194. package/src/core/auth/auth.service.spec.ts +0 -86
  195. package/src/core/auth/auth.service.ts +0 -8
  196. package/src/core/capability/capability.service.ts +0 -66
  197. package/src/core/config/config.schema.ts +0 -3
  198. package/src/core/config/config.service.spec.ts +0 -175
  199. package/src/core/config/config.service.ts +0 -7
  200. package/src/core/events/event-bus.service.spec.ts +0 -235
  201. package/src/core/events/event-bus.service.ts +0 -89
  202. package/src/core/feature/feature.service.spec.ts +0 -99
  203. package/src/core/feature/feature.service.ts +0 -8
  204. package/src/core/lifecycle/lifecycle-state-machine.spec.ts +0 -166
  205. package/src/core/lifecycle/lifecycle-state-machine.ts +0 -3
  206. package/src/core/logging/log-ring-buffer.ts +0 -3
  207. package/src/core/logging/logging.service.spec.ts +0 -287
  208. package/src/core/logging/logging.service.ts +0 -143
  209. package/src/core/logging/scoped-logger.ts +0 -3
  210. package/src/core/moleculer/cap-call-fn.spec.ts +0 -173
  211. package/src/core/moleculer/cap-call-fn.ts +0 -107
  212. package/src/core/moleculer/cap-route-authority.ts +0 -194
  213. package/src/core/moleculer/moleculer.service.ts +0 -1072
  214. package/src/core/network/network-quality.service.spec.ts +0 -53
  215. package/src/core/network/network-quality.service.ts +0 -5
  216. package/src/core/notification/notification-wrapper.service.ts +0 -34
  217. package/src/core/notification/toast-wrapper.service.ts +0 -27
  218. package/src/core/provider/provider.tokens.ts +0 -1
  219. package/src/core/repl/repl-engine.service.spec.ts +0 -444
  220. package/src/core/repl/repl-engine.service.ts +0 -155
  221. package/src/core/storage/fs-storage-backend.spec.ts +0 -70
  222. package/src/core/storage/fs-storage-backend.ts +0 -3
  223. package/src/core/storage/storage-location-manager.spec.ts +0 -130
  224. package/src/core/storage/storage-location-manager.ts +0 -3
  225. package/src/core/storage/storage.service.spec.ts +0 -73
  226. package/src/core/storage/storage.service.ts +0 -3
  227. package/src/core/streaming/stream-probe.service.ts +0 -221
  228. package/src/core/topology/topology-emitter.service.ts +0 -105
  229. package/src/launcher.ts +0 -314
  230. package/src/main.ts +0 -1245
  231. package/src/manual-boot.ts +0 -301
  232. package/tsconfig.build.json +0 -8
  233. package/tsconfig.json +0 -33
  234. package/vitest.config.ts +0 -26
@@ -0,0 +1,1560 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.AddonPackageService = exports.FRAMEWORK_PACKAGE_ALLOWLIST = void 0;
37
+ exports.isFrameworkPackage = isFrameworkPackage;
38
+ const fs = __importStar(require("node:fs"));
39
+ const path = __importStar(require("node:path"));
40
+ const os = __importStar(require("node:os"));
41
+ const node_child_process_1 = require("node:child_process");
42
+ const node_util_1 = require("node:util");
43
+ const node_crypto_1 = require("node:crypto");
44
+ const types_1 = require("@camstack/types");
45
+ const kernel_1 = require("@camstack/kernel");
46
+ const execFileAsync = (0, node_util_1.promisify)(node_child_process_1.execFile);
47
+ /**
48
+ * Framework packages (manifest `camstack.system: true`) that the
49
+ * `updateFrameworkPackage` cap method is allowed to update. Any other
50
+ * `packageName` is rejected.
51
+ *
52
+ * Mirror of `camstack.system: true` in
53
+ * packages/{types,kernel,core,sdk,ui-library,shm-ring}/package.json
54
+ */
55
+ exports.FRAMEWORK_PACKAGE_ALLOWLIST = [
56
+ '@camstack/types',
57
+ '@camstack/kernel',
58
+ '@camstack/core',
59
+ '@camstack/sdk',
60
+ '@camstack/ui-library',
61
+ // Phase 5 / D9 — the cross-platform shared-memory frame plane. A system
62
+ // package (native N-API module + `FrameRing`) the stream-broker / decoder /
63
+ // frame consumers all depend on; it can't be uninstalled.
64
+ '@camstack/shm-ring',
65
+ ];
66
+ /** Test-only: exported for spec parity check against the manifest. */
67
+ function isFrameworkPackage(packageName) {
68
+ return exports.FRAMEWORK_PACKAGE_ALLOWLIST.includes(packageName);
69
+ }
70
+ // ---------------------------------------------------------------------------
71
+ // Typed JSON helpers
72
+ //
73
+ // JSON.parse returns `any`, which sprays `no-unsafe-*` violations all over
74
+ // ESLint. These tiny wrappers keep the `any` contained to one place and
75
+ // return the `unknown` that callers must then narrow structurally.
76
+ // ---------------------------------------------------------------------------
77
+ function parseJsonUnknown(text) {
78
+ // Isolate the `any` from JSON.parse to this one assignment.
79
+ const parsed = JSON.parse(text);
80
+ return parsed;
81
+ }
82
+ function readJsonObject(filePath) {
83
+ try {
84
+ const parsed = parseJsonUnknown(fs.readFileSync(filePath, 'utf-8'));
85
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
86
+ return { ...parsed };
87
+ }
88
+ }
89
+ catch {
90
+ /* ignore */
91
+ }
92
+ return null;
93
+ }
94
+ async function fetchJsonObject(response) {
95
+ const parsed = await response.json();
96
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
97
+ return {};
98
+ }
99
+ return { ...parsed };
100
+ }
101
+ function asString(value, fallback = '') {
102
+ return typeof value === 'string' ? value : fallback;
103
+ }
104
+ function asRecord(value) {
105
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
106
+ return { ...value };
107
+ }
108
+ return {};
109
+ }
110
+ // ---------------------------------------------------------------------------
111
+ // Constants
112
+ // ---------------------------------------------------------------------------
113
+ /** Core packages that live in the server's own node_modules */
114
+ const CORE_MANAGED_PACKAGES = ['@camstack/core', '@camstack/types'];
115
+ class AddonPackageService {
116
+ loggingService;
117
+ eventBusService;
118
+ configService;
119
+ addonRegistry;
120
+ notificationService;
121
+ toastService;
122
+ logger;
123
+ /** AddonInstaller from @camstack/kernel (may be null if kernel unavailable) */
124
+ installer = null;
125
+ /** AddonLoader for reloadPackages (may be null) */
126
+ loader = null;
127
+ // -- Caches ---------------------------------------------------------------
128
+ cachedUpdates = null;
129
+ searchCache = null;
130
+ versionCache = new Map();
131
+ // -- Auto-update state ----------------------------------------------------
132
+ autoUpdateConfig = {
133
+ global: { channel: 'off', intervalSeconds: 21600 },
134
+ overrides: {},
135
+ };
136
+ autoUpdateTimer = null;
137
+ // -- Timing constants -----------------------------------------------------
138
+ // Short TTL — operators expect to see freshly-published versions
139
+ // within minutes of `npm publish`, not hours. The Addons page kicks
140
+ // a force-refresh on mount so the first navigation after publish
141
+ // always picks up the new version regardless of TTL state.
142
+ static UPDATE_CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes
143
+ static SEARCH_CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
144
+ static VERSION_CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes
145
+ static NPM_REGISTRY = 'https://registry.npmjs.org';
146
+ static REGISTRY_TIMEOUT_MS = 10_000;
147
+ constructor(loggingService, eventBusService, configService, addonRegistry, notificationService, toastService) {
148
+ this.loggingService = loggingService;
149
+ this.eventBusService = eventBusService;
150
+ this.configService = configService;
151
+ this.addonRegistry = addonRegistry;
152
+ this.notificationService = notificationService;
153
+ this.toastService = toastService;
154
+ this.logger = this.loggingService.createLogger('AddonPackageService');
155
+ // Initialize installer eagerly (no async needed).
156
+ // Ensures install/uninstall works before full module init completes.
157
+ try {
158
+ const dataDir = process.env.CAMSTACK_DATA ?? 'camstack-data';
159
+ const addonsDir = path.resolve(dataDir, 'addons');
160
+ const workspacePackagesDir = (0, kernel_1.detectWorkspacePackagesDir)(__dirname);
161
+ // `CAMSTACK_NPM_REGISTRY` override exists primarily for the e2e
162
+ // harness — it points the installer at a sandboxed verdaccio so
163
+ // the CLI-vs-npm version-interaction tests don't hit the public
164
+ // registry. In production this stays unset and the installer
165
+ // falls back to `https://registry.npmjs.org` (the default in
166
+ // AddonInstaller). Both the `npm install` shell-out AND the
167
+ // metadata HTTPS fetch honour this value.
168
+ const registry = process.env['CAMSTACK_NPM_REGISTRY'];
169
+ this.installer = new kernel_1.AddonInstaller({
170
+ addonsDir,
171
+ workspacePackagesDir: workspacePackagesDir ?? undefined,
172
+ ...(registry ? { registry } : {}),
173
+ });
174
+ (0, kernel_1.ensureDir)(addonsDir);
175
+ }
176
+ catch (error) {
177
+ const msg = (0, types_1.errMsg)(error);
178
+ this.logger.warn('Installer init failed', { meta: { error: msg } });
179
+ }
180
+ // Load auto-update config from disk
181
+ this.autoUpdateConfig = this.loadAutoUpdateConfig();
182
+ // Start auto-update timer after a delay (give server time to boot)
183
+ setTimeout(() => this.scheduleAutoUpdate(), 30_000);
184
+ }
185
+ // =========================================================================
186
+ // Install / Uninstall
187
+ // =========================================================================
188
+ /** Install an addon from npm registry */
189
+ async installFromNpm(name, version) {
190
+ this.requireInstaller();
191
+ this.logger.info(`installFromNpm: ${name}@${version ?? 'latest'}`, {
192
+ meta: { addonsDir: this.installer['addonsDir'] },
193
+ });
194
+ const result = await this.installer.installFromNpm(name, version);
195
+ this.logger.info('installFromNpm result', {
196
+ meta: { name: result.name, version: result.version },
197
+ });
198
+ return result;
199
+ }
200
+ /** Install an addon from the local workspace (dev mode) */
201
+ async installFromWorkspace(name) {
202
+ this.requireInstaller();
203
+ const result = await this.installer.install(name);
204
+ this.logger.info('Installed from workspace', {
205
+ meta: { name: result.name, version: result.version },
206
+ });
207
+ return result;
208
+ }
209
+ /** Install an addon from an uploaded tgz file */
210
+ async installFromUpload(tgzPath) {
211
+ this.requireInstaller();
212
+ const result = await this.installer.installFromTgz(tgzPath);
213
+ // Mark install source as 'upload' in both legacy marker + central manifest
214
+ const addonsDir = this.resolveAddonsDir();
215
+ const targetDir = path.join(addonsDir, result.name);
216
+ try {
217
+ fs.writeFileSync(path.join(targetDir, '.install-source'), 'upload');
218
+ }
219
+ catch (err) {
220
+ this.logger.debug('Non-fatal: failed to write .install-source marker', {
221
+ meta: { error: (0, types_1.errMsg)(err) },
222
+ });
223
+ }
224
+ this.installer.manifest.upsert(result.name, {
225
+ version: result.version,
226
+ source: 'upload',
227
+ });
228
+ this.logger.info('Installed from upload', {
229
+ meta: { name: result.name, version: result.version },
230
+ });
231
+ return result;
232
+ }
233
+ /**
234
+ * Roll an addon back to the version it had before the most recent
235
+ * `updatePackage` call. The backup directory pointer lives in the
236
+ * AddonInstaller manifest; this method restores it, refreshes the
237
+ * registry, restarts the addon, and notifies the user via toast.
238
+ *
239
+ * Returns `{ rolledBackTo }` — null when there's no backup available
240
+ * (the previous update either succeeded its health check or never
241
+ * happened), the restored version string otherwise.
242
+ */
243
+ async rollbackPackage(name) {
244
+ this.requireInstaller();
245
+ if (!this.isAllowedPackage(name)) {
246
+ throw new Error(`Package "${name}" is not an allowed @camstack/* package`);
247
+ }
248
+ const previousVersion = this.getInstalledPackageVersion(name);
249
+ const rolledBackTo = await this.installer.rollbackAddon(name);
250
+ if (rolledBackTo == null) {
251
+ this.logger.info('No backup available to roll back', { meta: { name } });
252
+ return { rolledBackTo: null };
253
+ }
254
+ // Refresh registry + emit a synthetic 'updated' lifecycle event
255
+ // (the version went BACK, but downstream consumers care about the
256
+ // version change itself, not the direction).
257
+ this.addonRegistry.refreshPackageVersion(name, rolledBackTo);
258
+ this.addonRegistry.emitUpdateEvent(name, previousVersion, rolledBackTo);
259
+ const addonId = this.extractAddonId(name);
260
+ if (addonId) {
261
+ try {
262
+ await this.addonRegistry.restartAddon(addonId);
263
+ this.logger.info('Addon restarted after rollback', {
264
+ tags: { addonId },
265
+ meta: { rolledBackTo },
266
+ });
267
+ }
268
+ catch (reloadError) {
269
+ this.logger.warn('Restart failed after rollback', {
270
+ tags: { addonId },
271
+ meta: { error: (0, types_1.errMsg)(reloadError) },
272
+ });
273
+ }
274
+ }
275
+ // Clear update cache so the UI reflects the new state.
276
+ this.cachedUpdates = null;
277
+ return { rolledBackTo };
278
+ }
279
+ /** Uninstall an addon (refuses to uninstall protected/required packages) */
280
+ async uninstall(name) {
281
+ this.requireInstaller();
282
+ if (this.isProtected(name)) {
283
+ throw new Error(`Cannot uninstall protected package "${name}"`);
284
+ }
285
+ await this.installer.uninstall(name);
286
+ this.logger.info('Uninstalled', { meta: { name } });
287
+ }
288
+ // =========================================================================
289
+ // Full install orchestration (npm install → reload → load addons → toast)
290
+ // =========================================================================
291
+ /**
292
+ * Full install-from-npm orchestration:
293
+ * 1. npm install
294
+ * 2. Reload packages
295
+ * 3. Load new addons into registry
296
+ * 4. Broadcast toast notification
297
+ *
298
+ * Returns loaded/failed addon ids.
299
+ */
300
+ async installAndLoad(packageName, version) {
301
+ const versionLabel = version ? `@${version}` : '@latest';
302
+ this.logger.info('Installing package...', { meta: { packageName, version: versionLabel } });
303
+ try {
304
+ const installResult = await this.installFromNpm(packageName, version);
305
+ this.logger.info('npm install complete', {
306
+ meta: { name: installResult.name, version: installResult.version },
307
+ });
308
+ }
309
+ catch (err) {
310
+ const msg = (0, types_1.errMsg)(err);
311
+ this.logger.error('npm install failed', { meta: { packageName, error: msg } });
312
+ this.toastService.broadcast({
313
+ title: 'Install Failed',
314
+ message: `Failed to install ${packageName}: ${msg}`,
315
+ severity: 'warning',
316
+ });
317
+ throw new Error(`Install failed: ${msg}`, { cause: err });
318
+ }
319
+ try {
320
+ await this.reloadPackages();
321
+ }
322
+ catch (err) {
323
+ const msg = (0, types_1.errMsg)(err);
324
+ this.logger.error('reloadPackages failed', { meta: { error: msg } });
325
+ }
326
+ let loaded = [];
327
+ let failed = [];
328
+ try {
329
+ const result = await this.addonRegistry.loadNewAddons();
330
+ loaded = result.loaded;
331
+ failed = result.failed;
332
+ }
333
+ catch (err) {
334
+ const msg = (0, types_1.errMsg)(err);
335
+ this.logger.error('loadNewAddons failed', { meta: { error: msg } });
336
+ failed.push(packageName);
337
+ }
338
+ this.toastService.broadcast({
339
+ title: failed.length ? 'Addon Installed (with warnings)' : 'Addon Installed',
340
+ message: `${packageName} installed${loaded.length ? ` — addons: ${loaded.join(', ')}` : ''}${failed.length ? ` — failed: ${failed.join(', ')}` : ''}`,
341
+ severity: failed.length ? 'warning' : 'info',
342
+ });
343
+ return { success: true, loaded, failed };
344
+ }
345
+ /**
346
+ * Full install-from-workspace orchestration:
347
+ * 1. Workspace install
348
+ * 2. Reload packages
349
+ * 3. Load new addons into registry
350
+ * 4. Broadcast toast notification
351
+ */
352
+ async installFromWorkspaceAndLoad(packageName) {
353
+ this.logger.info('Installing from workspace...', { meta: { packageName } });
354
+ try {
355
+ const result = await this.installFromWorkspace(packageName);
356
+ this.logger.info('Workspace install complete', {
357
+ meta: { name: result.name, version: result.version },
358
+ });
359
+ }
360
+ catch (err) {
361
+ const msg = (0, types_1.errMsg)(err);
362
+ this.logger.error('Workspace install failed', { meta: { error: msg } });
363
+ this.toastService.broadcast({ title: 'Install Failed', message: msg, severity: 'warning' });
364
+ throw new Error(`Workspace install failed: ${msg}`, { cause: err });
365
+ }
366
+ try {
367
+ await this.reloadPackages();
368
+ }
369
+ catch (err) {
370
+ this.logger.warn('Non-fatal: failed to reload packages after workspace install', {
371
+ meta: { error: (0, types_1.errMsg)(err) },
372
+ });
373
+ }
374
+ let loaded = [];
375
+ let failed = [];
376
+ try {
377
+ const result = await this.addonRegistry.loadNewAddons();
378
+ loaded = result.loaded;
379
+ failed = result.failed;
380
+ }
381
+ catch (err) {
382
+ this.logger.warn('Failed to load new addons after install', { meta: { error: (0, types_1.errMsg)(err) } });
383
+ failed.push(packageName);
384
+ }
385
+ this.toastService.broadcast({
386
+ title: 'Addon Installed from Workspace',
387
+ message: `${packageName} installed${loaded.length ? ` — addons: ${loaded.join(', ')}` : ''}`,
388
+ severity: failed.length ? 'warning' : 'info',
389
+ });
390
+ return { success: true, loaded, failed };
391
+ }
392
+ /**
393
+ * Full uninstall orchestration:
394
+ * 1. Emit uninstall lifecycle event
395
+ * 2. Uninstall package
396
+ * 3. Reload packages
397
+ * 4. Load addons (to update registry)
398
+ * 5. Broadcast toast notification
399
+ */
400
+ async uninstallAndReload(packageName) {
401
+ if (this.isProtected(packageName)) {
402
+ throw new Error(`Package ${packageName} is required and cannot be uninstalled`);
403
+ }
404
+ try {
405
+ this.addonRegistry.emitUninstallEvent(packageName);
406
+ this.logger.info('Uninstalling package...', { meta: { packageName } });
407
+ await this.uninstall(packageName);
408
+ this.logger.info('Uninstall complete', { meta: { packageName } });
409
+ }
410
+ catch (err) {
411
+ const msg = (0, types_1.errMsg)(err);
412
+ this.logger.error('Uninstall failed', { meta: { packageName, error: msg } });
413
+ this.toastService.broadcast({ title: 'Uninstall Failed', message: msg, severity: 'warning' });
414
+ throw new Error(`Uninstall failed: ${msg}`, { cause: err });
415
+ }
416
+ await this.reloadPackages().catch((err) => {
417
+ this.logger.warn('Non-fatal: failed to reload packages after uninstall', {
418
+ meta: { error: (0, types_1.errMsg)(err) },
419
+ });
420
+ });
421
+ await this.addonRegistry.loadNewAddons().catch((err) => {
422
+ this.logger.warn('Non-fatal: failed to load new addons after uninstall', {
423
+ meta: { error: (0, types_1.errMsg)(err) },
424
+ });
425
+ });
426
+ this.toastService.broadcast({
427
+ title: 'Addon Uninstalled',
428
+ message: `${packageName} has been removed`,
429
+ severity: 'info',
430
+ });
431
+ return { success: true };
432
+ }
433
+ // =========================================================================
434
+ // Workspace
435
+ // =========================================================================
436
+ /** Check if workspace packages directory is available (dev mode) */
437
+ isWorkspaceAvailable() {
438
+ return this.installer?.workspaceDir != null;
439
+ }
440
+ /** List addon packages available in the workspace but not necessarily installed */
441
+ listWorkspacePackages() {
442
+ const workspaceDir = this.installer?.workspaceDir;
443
+ if (!workspaceDir)
444
+ return [];
445
+ const results = [];
446
+ const installed = new Set(this.listInstalled().map((p) => p.name));
447
+ try {
448
+ for (const entry of fs.readdirSync(workspaceDir, { withFileTypes: true })) {
449
+ if (!entry.isDirectory())
450
+ continue;
451
+ const pkgJsonPath = path.join(workspaceDir, entry.name, 'package.json');
452
+ if (!fs.existsSync(pkgJsonPath))
453
+ continue;
454
+ const pkg = readJsonObject(pkgJsonPath);
455
+ if (!pkg) {
456
+ this.logger.debug('Skipping malformed package.json', { meta: { entryName: entry.name } });
457
+ continue;
458
+ }
459
+ const name = asString(pkg['name']);
460
+ if (!name.startsWith('@camstack/'))
461
+ continue;
462
+ const camstack = asRecord(pkg['camstack']);
463
+ if (!Array.isArray(camstack['addons']))
464
+ continue;
465
+ results.push({
466
+ name,
467
+ version: asString(pkg['version'], '0.0.0'),
468
+ installed: installed.has(name),
469
+ });
470
+ }
471
+ }
472
+ catch (err) {
473
+ this.logger.warn('Failed to read workspace directory', { meta: { error: (0, types_1.errMsg)(err) } });
474
+ }
475
+ return results;
476
+ }
477
+ // =========================================================================
478
+ // Query
479
+ // =========================================================================
480
+ /** List all installed addon packages */
481
+ listInstalled() {
482
+ if (!this.installer) {
483
+ throw new Error('AddonInstaller is not available — cannot list installed packages. Ensure @camstack/kernel is installed and the addons directory is accessible');
484
+ }
485
+ return this.installer.listInstalled();
486
+ }
487
+ /**
488
+ * Check whether a package is currently protected from uninstall.
489
+ *
490
+ * Source of truth: the running `AddonRegistry`'s loaded manifests —
491
+ * any addon in this package with `camstack.addons[N].protected: true`
492
+ * marks the whole package non-removable. This replaces the legacy
493
+ * `AddonInstaller.REQUIRED_PACKAGES` hardcoded list.
494
+ *
495
+ * Fallback: if the registry hasn't loaded yet (early-boot install
496
+ * paths, tests with stub registries), fall back to the legacy list
497
+ * so we don't accidentally allow uninstalling `@camstack/core` etc.
498
+ * during a window where protection metadata isn't yet readable.
499
+ */
500
+ isProtected(name) {
501
+ const fromRegistry = this.addonRegistry?.isPackageProtected?.(name);
502
+ if (typeof fromRegistry === 'boolean')
503
+ return fromRegistry;
504
+ return kernel_1.AddonInstaller.REQUIRED_PACKAGES.includes(name);
505
+ }
506
+ /**
507
+ * Set of package names with a pending pre-update backup on disk —
508
+ * i.e. packages where `applyUpdate` last ran and the post-update
509
+ * health-check hasn't cleared the backup pointer yet, OR the user
510
+ * explicitly hasn't called `clearBackup`.
511
+ *
512
+ * The Rollback button in admin-ui's AddonCard reads this via the
513
+ * cap-router's `hasBackup` flag on `addons.list`.
514
+ */
515
+ getRollbackablePackages() {
516
+ if (!this.installer)
517
+ return new Set();
518
+ const out = new Set();
519
+ for (const entry of this.installer.manifest.list()) {
520
+ if (entry.lastBackupDir)
521
+ out.add(entry.name);
522
+ }
523
+ return out;
524
+ }
525
+ // =========================================================================
526
+ // Update checks
527
+ // =========================================================================
528
+ /**
529
+ * Check npm registry for newer versions of all managed @camstack/* packages.
530
+ * Includes both core packages (server root) and addon packages (data/addons).
531
+ * Results are cached for 6 hours.
532
+ */
533
+ async checkUpdates(force) {
534
+ if (force) {
535
+ this.cachedUpdates = null;
536
+ }
537
+ const now = Date.now();
538
+ if (this.cachedUpdates && this.cachedUpdates.expiresAt > now) {
539
+ // this.logger.debug('Returning cached update check results')
540
+ return this.cachedUpdates.updates;
541
+ }
542
+ this.logger.info('Checking for package updates...');
543
+ const updates = [];
544
+ // Check installed addon packages in addons dir
545
+ const addonUpdates = await this.checkAddonPackageUpdates();
546
+ updates.push(...addonUpdates);
547
+ this.logger.info('Found package updates', {
548
+ meta: {
549
+ count: updates.length,
550
+ updates: updates.map((u) => ({
551
+ name: u.name,
552
+ currentVersion: u.currentVersion,
553
+ latestVersion: u.latestVersion,
554
+ })),
555
+ },
556
+ });
557
+ this.cachedUpdates = {
558
+ updates,
559
+ expiresAt: now + AddonPackageService.UPDATE_CACHE_TTL_MS,
560
+ };
561
+ return updates;
562
+ }
563
+ /** Clear the cached update check results */
564
+ clearUpdateCache() {
565
+ this.cachedUpdates = null;
566
+ this.logger.info('Update cache cleared');
567
+ }
568
+ /**
569
+ * Diff an explicit list of installed packages against npm — the
570
+ * agent-targeted counterpart of `checkUpdates`. The hub owns the npm
571
+ * machinery; an agent only reports what it has installed (via
572
+ * `$agent.status`), so the hub does the registry lookups + diff here.
573
+ *
574
+ * Not cached: the caller decides freshness (an agent roster changes
575
+ * per deploy, and `forceRefresh` must always be live).
576
+ */
577
+ async checkUpdatesForInstalled(installed) {
578
+ // De-dup by package name — one npm package may bundle several addons.
579
+ const seen = new Map();
580
+ for (const pkg of installed) {
581
+ if (pkg.name.length > 0 && !seen.has(pkg.name))
582
+ seen.set(pkg.name, pkg.version);
583
+ }
584
+ const updates = [];
585
+ await Promise.all([...seen].map(async ([name, version]) => {
586
+ if (!this.isAllowedPackage(name))
587
+ return;
588
+ const latestVersion = await this.fetchLatestVersion(name);
589
+ if (latestVersion === null || latestVersion === version)
590
+ return;
591
+ const category = this.categorize(name);
592
+ updates.push({
593
+ name,
594
+ currentVersion: version,
595
+ latestVersion,
596
+ category,
597
+ requiresRestart: category === 'core',
598
+ });
599
+ }));
600
+ return updates;
601
+ }
602
+ /**
603
+ * Resolve `name@version` and `npm pack` it into a tarball buffer
604
+ * WITHOUT installing. Used to push a package update to an agent: the
605
+ * hub packs here, then ships the tgz via `$agent.deploy` — agents
606
+ * need no npm runtime of their own.
607
+ */
608
+ async packPackage(name, version) {
609
+ const registry = process.env['CAMSTACK_NPM_REGISTRY'];
610
+ const resolvedVersion = await resolveNpmVersion(name, version ?? 'latest', registry);
611
+ const spec = `${name}@${resolvedVersion}`;
612
+ const destDir = fs.mkdtempSync(path.join(os.tmpdir(), 'camstack-pack-'));
613
+ try {
614
+ const args = ['pack', spec, '--pack-destination', destDir, ...buildNpmRegistryArgs(registry)];
615
+ await execFileAsync('npm', args, { timeout: 120_000 });
616
+ const onDisk = fs.readdirSync(destDir).find((f) => f.endsWith('.tgz'));
617
+ if (onDisk === undefined) {
618
+ throw new Error(`packPackage: npm pack produced no tarball for ${spec}`);
619
+ }
620
+ const buffer = fs.readFileSync(path.join(destDir, onDisk));
621
+ return { buffer, version: resolvedVersion, filename: onDisk };
622
+ }
623
+ finally {
624
+ fs.rmSync(destDir, { recursive: true, force: true });
625
+ }
626
+ }
627
+ // =========================================================================
628
+ // npm search
629
+ // =========================================================================
630
+ /** Search npm for camstack addon packages, optionally filtered by query */
631
+ async searchNpm(query) {
632
+ const npmResults = await this.fetchSearchFromNpm();
633
+ let filtered = npmResults;
634
+ if (query) {
635
+ const q = query.toLowerCase();
636
+ filtered = npmResults.filter((r) => r.name.toLowerCase().includes(q) ||
637
+ r.description?.toLowerCase().includes(q) ||
638
+ r.keywords?.some((k) => k.toLowerCase().includes(q)));
639
+ }
640
+ return filtered.map((r) => ({
641
+ name: r.name,
642
+ version: r.version,
643
+ description: r.description ?? '',
644
+ keywords: r.keywords ?? [],
645
+ publishedAt: r.date ?? '',
646
+ author: r.publisher?.username ?? '',
647
+ installed: false, // caller will enrich
648
+ installedVersion: undefined,
649
+ }));
650
+ }
651
+ // =========================================================================
652
+ // Package versions
653
+ // =========================================================================
654
+ /**
655
+ * Fetch all published versions of a package from npm, including dist-tags.
656
+ * Results are cached for 10 minutes per package.
657
+ */
658
+ async getPackageVersions(name) {
659
+ const now = Date.now();
660
+ const cached = this.versionCache.get(name);
661
+ if (cached && cached.expiresAt > now) {
662
+ return cached.versions;
663
+ }
664
+ try {
665
+ const encodedName = name.replace('/', '%2F');
666
+ const url = `${AddonPackageService.NPM_REGISTRY}/${encodedName}`;
667
+ const response = await fetch(url, {
668
+ signal: AbortSignal.timeout(AddonPackageService.REGISTRY_TIMEOUT_MS),
669
+ });
670
+ if (!response.ok) {
671
+ this.logger.debug('Registry returned non-ok status', {
672
+ meta: { name, status: response.status },
673
+ });
674
+ return [];
675
+ }
676
+ const data = await fetchJsonObject(response);
677
+ const distTags = asRecord(data['dist-tags']);
678
+ const versions = asRecord(data['versions']);
679
+ const time = asRecord(data['time']);
680
+ // Build a reverse lookup: version -> list of dist-tag names
681
+ const tagsByVersion = new Map();
682
+ for (const [tag, ver] of Object.entries(distTags)) {
683
+ const verStr = asString(ver);
684
+ if (!verStr)
685
+ continue;
686
+ const existing = tagsByVersion.get(verStr) ?? [];
687
+ tagsByVersion.set(verStr, [...existing, tag]);
688
+ }
689
+ const result = Object.entries(versions).map(([ver, rawMeta]) => {
690
+ const meta = asRecord(rawMeta);
691
+ return {
692
+ version: ver,
693
+ publishedAt: asString(time[ver]),
694
+ deprecated: typeof meta['deprecated'] === 'string' ? meta['deprecated'] : undefined,
695
+ distTags: tagsByVersion.get(ver) ?? [],
696
+ };
697
+ });
698
+ // Sort by published date descending (newest first)
699
+ result.sort((a, b) => {
700
+ if (!a.publishedAt && !b.publishedAt)
701
+ return 0;
702
+ if (!a.publishedAt)
703
+ return 1;
704
+ if (!b.publishedAt)
705
+ return -1;
706
+ return new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime();
707
+ });
708
+ this.versionCache.set(name, {
709
+ versions: result,
710
+ expiresAt: now + AddonPackageService.VERSION_CACHE_TTL_MS,
711
+ });
712
+ return result;
713
+ }
714
+ catch (error) {
715
+ const msg = (0, types_1.errMsg)(error);
716
+ this.logger.warn('Failed to fetch versions', { meta: { name, error: msg } });
717
+ return cached?.versions ?? [];
718
+ }
719
+ }
720
+ // =========================================================================
721
+ // Update / restart
722
+ // =========================================================================
723
+ /**
724
+ * Update a specific package. For addon packages, triggers hot-reload.
725
+ * For core packages, installs in the server root and returns requiresRestart: true.
726
+ */
727
+ async updatePackage(name, version) {
728
+ if (!this.isAllowedPackage(name)) {
729
+ return {
730
+ success: false,
731
+ version: '',
732
+ requiresRestart: false,
733
+ error: `Package "${name}" is not an allowed @camstack/* package`,
734
+ };
735
+ }
736
+ // Dev-mode npm-install gate REMOVED (2026-05-12). The legacy
737
+ // workspace-link flow assumed addons in dev came from `packages/*`
738
+ // and a stray "Update" click would clobber the source. With the
739
+ // CLI's `camstack push` flow taking over that responsibility, the
740
+ // hub now treats every install identically — straight from npm
741
+ // (or from the operator's deliberate push). No more env var
742
+ // gymnastics required to update an addon from the UI.
743
+ const category = this.categorize(name);
744
+ this.logger.info('Updating package', { meta: { name, version, category } });
745
+ try {
746
+ let updatedVersion;
747
+ if (category === 'addon') {
748
+ // Reuse the long-lived installer the registry already configured
749
+ // (correct addonsDir + installSource + workspaceDir) — building a
750
+ // fresh one with `new AI({ addonsDir })` would lose installSource
751
+ // detection and skip the central manifest write.
752
+ this.requireInstaller();
753
+ const addonInstaller = this.installer;
754
+ const previousVersion = this.getInstalledPackageVersion(name);
755
+ // getInstalledPackageVersion returns '' when not installed
756
+ const isFirstInstall = !previousVersion;
757
+ // First-time installs go through the original installFromNpm path;
758
+ // applyUpdate explicitly refuses when the package isn't tracked yet.
759
+ // Subsequent updates take the safe path: backup current install,
760
+ // run the install, auto-restore on failure (PR2).
761
+ let result;
762
+ if (isFirstInstall) {
763
+ const r = await addonInstaller.installFromNpm(name, version);
764
+ result = { version: r.version };
765
+ }
766
+ else {
767
+ const r = await addonInstaller.applyUpdate(name, version);
768
+ result = { version: r.version, backupDir: r.backupDir };
769
+ }
770
+ updatedVersion = result.version;
771
+ // Update version in registry so UI reflects the change immediately
772
+ this.addonRegistry.refreshPackageVersion(name, updatedVersion);
773
+ // Emit addon.updated lifecycle event
774
+ this.addonRegistry.emitUpdateEvent(name, previousVersion, updatedVersion);
775
+ this.logger.info('Addon package updated, triggering hot-reload', {
776
+ meta: { name, updatedVersion },
777
+ });
778
+ const addonId = this.extractAddonId(name);
779
+ if (addonId) {
780
+ try {
781
+ await this.addonRegistry.restartAddon(addonId);
782
+ this.logger.info('Addon restarted after update', { tags: { addonId } });
783
+ // Restart succeeded — drop the backup. We keep it on failure
784
+ // (caller can roll back manually via UI).
785
+ if (result.backupDir != null) {
786
+ addonInstaller.clearBackup(name);
787
+ }
788
+ }
789
+ catch (reloadError) {
790
+ const msg = (0, types_1.errMsg)(reloadError);
791
+ this.logger.warn('Hot-reload failed for addon — backup retained for rollback', {
792
+ tags: { addonId },
793
+ meta: { error: msg, backupDir: result.backupDir },
794
+ });
795
+ }
796
+ }
797
+ // Clear update cache so next check reflects new state
798
+ this.cachedUpdates = null;
799
+ this.sendUpdateNotification(name, updatedVersion);
800
+ return { success: true, version: updatedVersion, requiresRestart: false };
801
+ }
802
+ // Core package -- install to data/addons/. Self-contained addon
803
+ // bundles no longer require the reverse symlink under
804
+ // hub/node_modules/@camstack/ — addons resolve everything from
805
+ // their own inlined deps.
806
+ const addonsDir = this.resolveAddonsDir();
807
+ const { installPackageFromNpmSync } = await Promise.resolve().then(() => __importStar(require('@camstack/kernel')));
808
+ const dirName = name.replace(/^@camstack\//, '');
809
+ const targetDir = path.join(addonsDir, dirName);
810
+ const packageSpec = version ? `${name}@${version}` : name;
811
+ installPackageFromNpmSync(packageSpec, targetDir);
812
+ updatedVersion = this.getInstalledPackageVersion(name);
813
+ // Invalidate cache after update
814
+ this.cachedUpdates = null;
815
+ this.logger.info('Core package updated -- restart required', {
816
+ meta: { name, updatedVersion },
817
+ });
818
+ this.sendUpdateNotification(name, updatedVersion);
819
+ return { success: true, version: updatedVersion, requiresRestart: true };
820
+ }
821
+ catch (error) {
822
+ const msg = (0, types_1.errMsg)(error);
823
+ this.logger.error('Failed to update package', { meta: { name, error: msg } });
824
+ return { success: false, version: '', requiresRestart: false, error: msg };
825
+ }
826
+ }
827
+ /**
828
+ * Gracefully restart the server process.
829
+ *
830
+ * Writes a `manual` restart marker so the post-boot service can
831
+ * emit `system.restart-completed` on the next boot (powers the
832
+ * admin-UI "Server restarted" toast). Emits `system.restarting`
833
+ * with the same payload so subscribers can show a reconnect overlay
834
+ * immediately. Then delegates to `scheduleSelfRestart` which picks
835
+ * the right exit path (Electron `app.relaunch` vs supervisor-driven
836
+ * `process.exit`).
837
+ */
838
+ restartServer(requestedBy) {
839
+ this.logger.info('Server restart requested -- initiating graceful shutdown');
840
+ const payload = {
841
+ kind: 'manual',
842
+ requestedAt: Date.now(),
843
+ ...(requestedBy !== undefined ? { requestedBy } : {}),
844
+ };
845
+ try {
846
+ (0, kernel_1.writePendingRestart)(this.resolveDataDir(), payload);
847
+ }
848
+ catch (err) {
849
+ // Marker write failure shouldn't block the restart — log + continue.
850
+ this.logger.warn('Failed to write restart marker; restart will proceed without completion toast', {
851
+ meta: { error: (0, types_1.errMsg)(err) },
852
+ });
853
+ }
854
+ this.eventBusService.emit({
855
+ id: (0, node_crypto_1.randomUUID)(),
856
+ timestamp: new Date(),
857
+ source: { type: 'core', id: 'addon-package-service' },
858
+ category: types_1.EventCategory.SystemRestarting,
859
+ data: payload,
860
+ });
861
+ // 10s grace gives in-flight requests time to drain.
862
+ (0, kernel_1.scheduleSelfRestart)({ delayMs: 10_000 });
863
+ }
864
+ // =========================================================================
865
+ // Framework live-update
866
+ //
867
+ // Spec: docs/superpowers/specs/2026-05-14-framework-live-update-design.md
868
+ // =========================================================================
869
+ /**
870
+ * Snapshot of installed framework packages — drives the admin-UI
871
+ * "System packages" panel. Best-effort: `latestVersion` is null when
872
+ * the npm lookup fails (offline, registry unreachable), and the UI
873
+ * hides the Update button for those rows.
874
+ *
875
+ * Latest-version lookups run in parallel against npm so the full
876
+ * snapshot returns in roughly the slowest individual lookup, not
877
+ * the sum.
878
+ */
879
+ async listFrameworkPackages() {
880
+ const registry = process.env['CAMSTACK_NPM_REGISTRY'];
881
+ const rows = await Promise.all(exports.FRAMEWORK_PACKAGE_ALLOWLIST.map(async (packageName) => {
882
+ const manifest = readResolvedPackageManifest(packageName);
883
+ const currentVersion = manifest !== null && typeof manifest['version'] === 'string'
884
+ ? manifest['version']
885
+ : 'unknown';
886
+ const description = manifest !== null && typeof manifest['description'] === 'string'
887
+ ? manifest['description']
888
+ : undefined;
889
+ let latestVersion = null;
890
+ try {
891
+ const args = [
892
+ 'view',
893
+ `${packageName}@latest`,
894
+ 'version',
895
+ ...buildNpmRegistryArgs(registry),
896
+ ];
897
+ const { stdout } = await execFileAsync('npm', args, { timeout: 15_000 });
898
+ const trimmed = stdout.trim();
899
+ latestVersion = trimmed.length > 0 ? trimmed : null;
900
+ }
901
+ catch (err) {
902
+ this.logger.debug('listFrameworkPackages: npm view failed', {
903
+ meta: { packageName, error: (0, types_1.errMsg)(err) },
904
+ });
905
+ }
906
+ const hasUpdate = latestVersion !== null && currentVersion !== 'unknown' && latestVersion !== currentVersion;
907
+ return {
908
+ packageName,
909
+ currentVersion,
910
+ latestVersion,
911
+ hasUpdate,
912
+ ...(description !== undefined ? { description } : {}),
913
+ };
914
+ }));
915
+ return rows;
916
+ }
917
+ /**
918
+ * Update one of the framework packages (manifest `camstack.system:
919
+ * true`) and schedule a hub restart.
920
+ *
921
+ * Steps:
922
+ * 1. Allow-list the package name (refuses anything not framework).
923
+ * 2. Resolve `'latest'`/`undefined` to a concrete version via `npm view`.
924
+ * 3. Run `npm install --prefix <appRoot> <name>@<version> --no-save`.
925
+ * 4. Write a `.restart-pending` marker (kind: `framework-update`).
926
+ * 5. Emit `system.restarting` event.
927
+ * 6. `scheduleSelfRestart({ delayMs: 500 })` — gives the cap method
928
+ * time to return before the WS drops.
929
+ *
930
+ * Returns BEFORE the exit fires so the admin UI receives `restartingAt`
931
+ * and can pivot to the reconnect overlay.
932
+ */
933
+ async updateFrameworkPackage(input) {
934
+ const { packageName } = input;
935
+ if (!exports.FRAMEWORK_PACKAGE_ALLOWLIST.includes(packageName)) {
936
+ throw new Error(`updateFrameworkPackage: '${packageName}' is not a framework package. Allowed: ${exports.FRAMEWORK_PACKAGE_ALLOWLIST.join(', ')}`);
937
+ }
938
+ const appRoot = resolveFrameworkPackageAppRoot(packageName, this.logger);
939
+ const fromManifest = readResolvedPackageManifest(packageName);
940
+ const fromVersion = fromManifest !== null && typeof fromManifest['version'] === 'string'
941
+ ? fromManifest['version']
942
+ : 'unknown';
943
+ const requestedVersion = input.version ?? 'latest';
944
+ const toVersion = await resolveNpmVersion(packageName, requestedVersion, process.env['CAMSTACK_NPM_REGISTRY']);
945
+ const spec = `${packageName}@${toVersion}`;
946
+ this.logger.info('updateFrameworkPackage: installing', {
947
+ meta: { packageName, fromVersion, toVersion, appRoot },
948
+ });
949
+ const registry = process.env['CAMSTACK_NPM_REGISTRY'];
950
+ const args = [
951
+ 'install',
952
+ '--prefix',
953
+ appRoot,
954
+ spec,
955
+ '--no-save',
956
+ ...buildNpmRegistryArgs(registry),
957
+ ];
958
+ await execFileAsync('npm', args, { timeout: 180_000 });
959
+ if (input.deferRestart === true) {
960
+ this.logger.info(`updateFrameworkPackage(${packageName}@${toVersion}): install done, restart deferred`);
961
+ // Sentinel: 0 signals "no restart scheduled" to the caller
962
+ return { packageName, fromVersion, toVersion, restartingAt: 0 };
963
+ }
964
+ const restartingAt = Date.now();
965
+ const markerPayload = {
966
+ kind: 'framework-update',
967
+ packageName,
968
+ fromVersion,
969
+ toVersion,
970
+ requestedAt: restartingAt,
971
+ ...(input.requestedBy !== undefined ? { requestedBy: input.requestedBy } : {}),
972
+ };
973
+ try {
974
+ (0, kernel_1.writePendingRestart)(this.resolveDataDir(), markerPayload);
975
+ }
976
+ catch (err) {
977
+ // The npm install already completed — the restart will still
978
+ // pick up the new version, just without the completion toast.
979
+ this.logger.warn('Failed to write restart marker after framework update', {
980
+ meta: { error: (0, types_1.errMsg)(err) },
981
+ });
982
+ }
983
+ this.eventBusService.emit({
984
+ id: (0, node_crypto_1.randomUUID)(),
985
+ timestamp: new Date(),
986
+ source: { type: 'core', id: 'addon-package-service' },
987
+ category: types_1.EventCategory.SystemRestarting,
988
+ data: markerPayload,
989
+ });
990
+ (0, kernel_1.scheduleSelfRestart)({ delayMs: 500 });
991
+ return { packageName, fromVersion, toVersion, restartingAt };
992
+ }
993
+ // =========================================================================
994
+ // Reload
995
+ // =========================================================================
996
+ /** Re-discover addons from the addons directory (call after install/uninstall) */
997
+ async reloadPackages() {
998
+ try {
999
+ const kernel = await Promise.resolve().then(() => __importStar(require('@camstack/kernel')));
1000
+ this.loader = new kernel.AddonLoader(this.logger.child('AddonLoader'));
1001
+ const dataDir = process.env.CAMSTACK_DATA ?? 'camstack-data';
1002
+ const addonsDir = path.resolve(dataDir, 'addons');
1003
+ await this.loader.loadFromDirectory(addonsDir);
1004
+ this.logger.info('Reloaded', { meta: { count: this.loader.listAddons().length } });
1005
+ }
1006
+ catch (error) {
1007
+ const msg = (0, types_1.errMsg)(error);
1008
+ this.logger.warn('reloadPackages failed', { meta: { error: msg } });
1009
+ }
1010
+ }
1011
+ // =========================================================================
1012
+ // Auto-update channel resolution
1013
+ // =========================================================================
1014
+ /** Resolve effective auto-update channel for a package */
1015
+ getEffectiveAutoUpdateChannel(_addonId, globalChannel, perAddonChannel) {
1016
+ if (perAddonChannel !== 'inherit')
1017
+ return perAddonChannel;
1018
+ if (globalChannel === 'inherit')
1019
+ return 'off'; // shouldn't happen at global level
1020
+ return globalChannel;
1021
+ }
1022
+ // =========================================================================
1023
+ // Auto-update settings
1024
+ // =========================================================================
1025
+ /** Get global auto-update settings */
1026
+ getAutoUpdateSettings() {
1027
+ return { ...this.autoUpdateConfig.global };
1028
+ }
1029
+ /** Set global auto-update settings and restart the timer */
1030
+ async setAutoUpdateSettings(channel, intervalSeconds) {
1031
+ this.autoUpdateConfig = {
1032
+ ...this.autoUpdateConfig,
1033
+ global: {
1034
+ channel,
1035
+ intervalSeconds: intervalSeconds ?? this.autoUpdateConfig.global.intervalSeconds,
1036
+ },
1037
+ };
1038
+ this.saveAutoUpdateConfig();
1039
+ this.scheduleAutoUpdate();
1040
+ }
1041
+ /** Get per-addon auto-update override */
1042
+ getAddonAutoUpdate(addonId) {
1043
+ return this.autoUpdateConfig.overrides[addonId] ?? 'inherit';
1044
+ }
1045
+ /** Set per-addon auto-update override */
1046
+ async setAddonAutoUpdate(addonId, channel) {
1047
+ const newOverrides = { ...this.autoUpdateConfig.overrides };
1048
+ if (channel === 'inherit') {
1049
+ delete newOverrides[addonId];
1050
+ }
1051
+ else {
1052
+ newOverrides[addonId] = channel;
1053
+ }
1054
+ this.autoUpdateConfig = { ...this.autoUpdateConfig, overrides: newOverrides };
1055
+ this.saveAutoUpdateConfig();
1056
+ }
1057
+ /** Schedule periodic auto-update check */
1058
+ scheduleAutoUpdate() {
1059
+ if (this.autoUpdateTimer) {
1060
+ clearInterval(this.autoUpdateTimer);
1061
+ this.autoUpdateTimer = null;
1062
+ }
1063
+ const hasOverrides = Object.values(this.autoUpdateConfig.overrides).some((ch) => ch !== 'off');
1064
+ if (this.autoUpdateConfig.global.channel === 'off' && !hasOverrides) {
1065
+ this.logger.info('Auto-update disabled');
1066
+ return;
1067
+ }
1068
+ const intervalMs = this.autoUpdateConfig.global.intervalSeconds * 1000;
1069
+ this.logger.info('Auto-update scheduled', {
1070
+ meta: {
1071
+ intervalSeconds: this.autoUpdateConfig.global.intervalSeconds,
1072
+ channel: this.autoUpdateConfig.global.channel,
1073
+ },
1074
+ });
1075
+ this.autoUpdateTimer = setInterval(() => {
1076
+ this.runAutoUpdate().catch((err) => {
1077
+ this.logger.error('Auto-update check failed', { meta: { error: (0, types_1.errMsg)(err) } });
1078
+ });
1079
+ }, intervalMs);
1080
+ }
1081
+ /** Run auto-update: check each installed package against its configured channel */
1082
+ async runAutoUpdate() {
1083
+ this.logger.info('Running auto-update check...');
1084
+ const installed = this.listInstalled();
1085
+ let updatedCount = 0;
1086
+ for (const pkg of installed) {
1087
+ try {
1088
+ // Determine effective channel for this addon
1089
+ const addonId = pkg.name.replace('@camstack/addon-', '').replace('@camstack/', '');
1090
+ const override = this.autoUpdateConfig.overrides[addonId];
1091
+ const effectiveChannel = this.getEffectiveAutoUpdateChannel(addonId, this.autoUpdateConfig.global.channel, override ?? 'inherit');
1092
+ if (effectiveChannel === 'off')
1093
+ continue;
1094
+ // Fetch dist-tags for this package
1095
+ const encodedName = pkg.name.replace('/', '%2F');
1096
+ const url = `${AddonPackageService.NPM_REGISTRY}/${encodedName}`;
1097
+ const response = await fetch(url, {
1098
+ signal: AbortSignal.timeout(AddonPackageService.REGISTRY_TIMEOUT_MS),
1099
+ });
1100
+ if (!response.ok)
1101
+ continue;
1102
+ const data = await fetchJsonObject(response);
1103
+ const distTags = asRecord(data['dist-tags']);
1104
+ // Determine target version based on channel
1105
+ const targetVersion = effectiveChannel === 'beta'
1106
+ ? asString(distTags['beta']) || asString(distTags['latest'])
1107
+ : asString(distTags['latest']);
1108
+ if (!targetVersion || targetVersion === pkg.version)
1109
+ continue;
1110
+ this.logger.info('Auto-updating package', {
1111
+ meta: {
1112
+ name: pkg.name,
1113
+ currentVersion: pkg.version,
1114
+ targetVersion,
1115
+ channel: effectiveChannel,
1116
+ },
1117
+ });
1118
+ await this.updatePackage(pkg.name, targetVersion);
1119
+ updatedCount++;
1120
+ }
1121
+ catch (err) {
1122
+ this.logger.warn('Auto-update failed', { meta: { name: pkg.name, error: (0, types_1.errMsg)(err) } });
1123
+ }
1124
+ }
1125
+ if (updatedCount > 0) {
1126
+ this.logger.info('Auto-update complete', { meta: { updatedCount } });
1127
+ }
1128
+ else {
1129
+ this.logger.debug('Auto-update: all packages up-to-date');
1130
+ }
1131
+ }
1132
+ // =========================================================================
1133
+ // Private: auto-update config persistence
1134
+ // =========================================================================
1135
+ /** Load auto-update config from disk */
1136
+ loadAutoUpdateConfig() {
1137
+ const configPath = path.join(this.resolveDataDir(), 'auto-update.json');
1138
+ try {
1139
+ if (fs.existsSync(configPath)) {
1140
+ const raw = readJsonObject(configPath);
1141
+ if (raw) {
1142
+ const global = asRecord(raw['global']);
1143
+ const channel = asString(global['channel']);
1144
+ const validChannel = channel === 'latest' || channel === 'beta' ? channel : 'off';
1145
+ return {
1146
+ global: {
1147
+ channel: validChannel,
1148
+ intervalSeconds: typeof global['intervalSeconds'] === 'number' ? global['intervalSeconds'] : 3600,
1149
+ },
1150
+ overrides: Object.fromEntries(Object.entries(asRecord(raw['overrides'])).map(([k, v]) => {
1151
+ const s = asString(v);
1152
+ const valid = s === 'off' || s === 'latest' || s === 'beta' || s === 'inherit' ? s : 'inherit';
1153
+ return [k, valid];
1154
+ })),
1155
+ };
1156
+ }
1157
+ }
1158
+ }
1159
+ catch (err) {
1160
+ this.logger.debug('Corrupt auto-update config, falling back to defaults', {
1161
+ meta: { error: (0, types_1.errMsg)(err) },
1162
+ });
1163
+ }
1164
+ return { global: { channel: 'off', intervalSeconds: 21600 }, overrides: {} };
1165
+ }
1166
+ /** Save auto-update config to disk */
1167
+ saveAutoUpdateConfig() {
1168
+ const dataDir = this.resolveDataDir();
1169
+ try {
1170
+ if (!fs.existsSync(dataDir)) {
1171
+ fs.mkdirSync(dataDir, { recursive: true });
1172
+ }
1173
+ const configPath = path.join(dataDir, 'auto-update.json');
1174
+ fs.writeFileSync(configPath, JSON.stringify(this.autoUpdateConfig, null, 2));
1175
+ }
1176
+ catch (err) {
1177
+ this.logger.warn('Failed to save auto-update config', { meta: { error: (0, types_1.errMsg)(err) } });
1178
+ }
1179
+ }
1180
+ /** Resolve the top-level data directory */
1181
+ resolveDataDir() {
1182
+ return path.resolve(process.env.CAMSTACK_DATA ?? 'camstack-data');
1183
+ }
1184
+ // =========================================================================
1185
+ // Private: core package update checks
1186
+ // =========================================================================
1187
+ /**
1188
+ * Check addon packages for updates by reading installed versions from
1189
+ * data/addons/{name}/package.json and comparing against npm registry.
1190
+ */
1191
+ async checkAddonPackageUpdates() {
1192
+ const addonsDir = this.resolveAddonsDir();
1193
+ const updates = [];
1194
+ if (!fs.existsSync(addonsDir))
1195
+ return updates;
1196
+ // Collect all package.json paths -- handles both flat and scoped layouts
1197
+ const pkgJsonPaths = [];
1198
+ const topDirs = fs
1199
+ .readdirSync(addonsDir, { withFileTypes: true })
1200
+ .filter((d) => d.isDirectory());
1201
+ for (const dir of topDirs) {
1202
+ const dirPath = path.join(addonsDir, dir.name);
1203
+ if (dir.name.startsWith('@')) {
1204
+ // Scoped package directory -- scan one level deeper
1205
+ const scopedDirs = fs
1206
+ .readdirSync(dirPath, { withFileTypes: true })
1207
+ .filter((d) => d.isDirectory());
1208
+ for (const scopedDir of scopedDirs) {
1209
+ const pkgJson = path.join(dirPath, scopedDir.name, 'package.json');
1210
+ if (fs.existsSync(pkgJson))
1211
+ pkgJsonPaths.push(pkgJson);
1212
+ }
1213
+ }
1214
+ else {
1215
+ const pkgJson = path.join(dirPath, 'package.json');
1216
+ if (fs.existsSync(pkgJson))
1217
+ pkgJsonPaths.push(pkgJson);
1218
+ }
1219
+ }
1220
+ for (const pkgJsonPath of pkgJsonPaths) {
1221
+ try {
1222
+ const pkgJson = readJsonObject(pkgJsonPath);
1223
+ if (!pkgJson)
1224
+ continue;
1225
+ const name = asString(pkgJson['name']);
1226
+ const version = asString(pkgJson['version']);
1227
+ if (!name || !version || !this.isAllowedPackage(name))
1228
+ continue;
1229
+ // Skip non-addon packages (core, types, etc.) — only check packages with camstack.addons
1230
+ const camstackField = asRecord(pkgJson['camstack']);
1231
+ if (!camstackField['addons'])
1232
+ continue;
1233
+ // Skip workspace-installed packages (dev mode) — they always lag behind npm
1234
+ const addonDir = path.dirname(pkgJsonPath);
1235
+ const installSourcePath = path.join(addonDir, '.install-source');
1236
+ if (fs.existsSync(installSourcePath)) {
1237
+ const source = fs.readFileSync(installSourcePath, 'utf-8').trim();
1238
+ if (source === 'workspace')
1239
+ continue;
1240
+ }
1241
+ const latestVersion = await this.fetchLatestVersion(name);
1242
+ if (!latestVersion)
1243
+ continue;
1244
+ if (latestVersion !== version) {
1245
+ updates.push({
1246
+ name,
1247
+ currentVersion: version,
1248
+ latestVersion,
1249
+ category: this.categorize(name),
1250
+ requiresRestart: this.categorize(name) === 'core',
1251
+ });
1252
+ }
1253
+ }
1254
+ catch (error) {
1255
+ const msg = (0, types_1.errMsg)(error);
1256
+ this.logger.debug('Failed to check updates for addon', {
1257
+ meta: { pkgJsonPath, error: msg },
1258
+ });
1259
+ }
1260
+ }
1261
+ return updates;
1262
+ }
1263
+ // =========================================================================
1264
+ // Private: npm registry helpers
1265
+ // =========================================================================
1266
+ /**
1267
+ * Base URL for npm registry METADATA fetches.
1268
+ *
1269
+ * Honours `CAMSTACK_NPM_REGISTRY` so update checks resolve against
1270
+ * the same registry the installer/pack paths use. Without this, a
1271
+ * per-node `listUpdates` (which diffs an agent's roster via
1272
+ * `checkUpdatesForInstalled` → `fetchLatestVersion`) would bypass a
1273
+ * private registry — including the e2e harness's verdaccio — and
1274
+ * silently report "no update" for packages that only exist there.
1275
+ * Trailing slashes are stripped so the `${base}/${name}` join is clean.
1276
+ */
1277
+ resolveRegistryBase() {
1278
+ const override = process.env['CAMSTACK_NPM_REGISTRY'];
1279
+ const base = override && override.length > 0 ? override : AddonPackageService.NPM_REGISTRY;
1280
+ return base.replace(/\/+$/, '');
1281
+ }
1282
+ /** Fetch the latest published version of a package from the npm registry */
1283
+ async fetchLatestVersion(packageName) {
1284
+ try {
1285
+ const encodedName = packageName.replace('/', '%2F');
1286
+ const url = `${this.resolveRegistryBase()}/${encodedName}/latest`;
1287
+ const response = await fetch(url, {
1288
+ signal: AbortSignal.timeout(AddonPackageService.REGISTRY_TIMEOUT_MS),
1289
+ });
1290
+ if (!response.ok) {
1291
+ this.logger.debug('Registry returned non-ok status', {
1292
+ meta: { packageName, status: response.status },
1293
+ });
1294
+ return null;
1295
+ }
1296
+ const data = await fetchJsonObject(response);
1297
+ const version = asString(data['version']);
1298
+ return version || null;
1299
+ }
1300
+ catch (error) {
1301
+ const msg = (0, types_1.errMsg)(error);
1302
+ this.logger.debug('Failed to fetch latest version', { meta: { packageName, error: msg } });
1303
+ return null;
1304
+ }
1305
+ }
1306
+ /** Fetch npm search results for camstack addon packages (cached 5 min) */
1307
+ async fetchSearchFromNpm() {
1308
+ if (this.searchCache &&
1309
+ Date.now() - this.searchCache.timestamp < AddonPackageService.SEARCH_CACHE_TTL_MS) {
1310
+ return this.searchCache.results;
1311
+ }
1312
+ const url = 'https://registry.npmjs.org/-/v1/search?text=keywords:camstack+keywords:addon&size=250';
1313
+ try {
1314
+ const response = await fetch(url, {
1315
+ headers: { Accept: 'application/json' },
1316
+ signal: AbortSignal.timeout(AddonPackageService.REGISTRY_TIMEOUT_MS),
1317
+ });
1318
+ if (!response.ok) {
1319
+ throw new Error(`npm search failed: ${response.status}`);
1320
+ }
1321
+ const data = await fetchJsonObject(response);
1322
+ const rawObjects = Array.isArray(data['objects']) ? data['objects'] : [];
1323
+ const results = [];
1324
+ for (const raw of rawObjects) {
1325
+ const wrapper = asRecord(raw);
1326
+ const pkg = asRecord(wrapper['package']);
1327
+ const name = asString(pkg['name']);
1328
+ if (!name)
1329
+ continue;
1330
+ const rawKeywords = Array.isArray(pkg['keywords']) ? pkg['keywords'] : [];
1331
+ const publisher = asRecord(pkg['publisher']);
1332
+ results.push({
1333
+ name,
1334
+ version: asString(pkg['version']),
1335
+ description: asString(pkg['description']),
1336
+ keywords: rawKeywords.filter((k) => typeof k === 'string'),
1337
+ date: asString(pkg['date']),
1338
+ publisher: { username: asString(publisher['username']) },
1339
+ });
1340
+ }
1341
+ this.searchCache = { results, timestamp: Date.now() };
1342
+ return results;
1343
+ }
1344
+ catch (err) {
1345
+ this.logger.warn('npm search failed', { meta: { error: (0, types_1.errMsg)(err) } });
1346
+ // Stale cache is better than nothing on transient network failure
1347
+ return this.searchCache?.results ?? [];
1348
+ }
1349
+ }
1350
+ // =========================================================================
1351
+ // Private: general helpers
1352
+ // =========================================================================
1353
+ /**
1354
+ * Read the currently installed version of a package.
1355
+ * Checks data/addons/ first, then falls back to Node module resolution.
1356
+ * Returns '0.0.0' if not found.
1357
+ */
1358
+ getInstalledPackageVersion(packageName) {
1359
+ try {
1360
+ const addonsDir = this.resolveAddonsDir();
1361
+ const dirName = packageName.replace(/^@camstack\//, '');
1362
+ const pkgJsonPath = path.join(addonsDir, dirName, 'package.json');
1363
+ if (fs.existsSync(pkgJsonPath)) {
1364
+ const pkgJson = readJsonObject(pkgJsonPath);
1365
+ const v = asString(pkgJson?.['version']);
1366
+ if (v)
1367
+ return v;
1368
+ }
1369
+ }
1370
+ catch (err) {
1371
+ this.logger.debug('Version lookup via fs failed, trying require.resolve', {
1372
+ meta: { packageName, error: (0, types_1.errMsg)(err) },
1373
+ });
1374
+ }
1375
+ try {
1376
+ const pkgJsonPath = require.resolve(`${packageName}/package.json`);
1377
+ const pkgJson = readJsonObject(pkgJsonPath);
1378
+ return asString(pkgJson?.['version'], '0.0.0');
1379
+ }
1380
+ catch (err) {
1381
+ this.logger.debug('Could not resolve version, returning 0.0.0', {
1382
+ meta: { packageName, error: (0, types_1.errMsg)(err) },
1383
+ });
1384
+ return '0.0.0';
1385
+ }
1386
+ }
1387
+ /** Only allow @camstack/* scoped packages */
1388
+ isAllowedPackage(name) {
1389
+ return name.startsWith('@camstack/');
1390
+ }
1391
+ /** Categorize a package as 'addon' or 'core' */
1392
+ categorize(name) {
1393
+ if (CORE_MANAGED_PACKAGES.includes(name)) {
1394
+ return 'core';
1395
+ }
1396
+ return name.includes('/addon-') ? 'addon' : 'core';
1397
+ }
1398
+ /** Extract addon ID from package name: '@camstack/addon-benchmark' -> 'benchmark' */
1399
+ extractAddonId(packageName) {
1400
+ const match = packageName.match(/@camstack\/addon-(.+)/);
1401
+ return match?.[1] ?? null;
1402
+ }
1403
+ /** Resolve the addons directory from config or fall back to default */
1404
+ resolveAddonsDir() {
1405
+ const dataPath = this.configService.get('server.dataPath') ?? 'camstack-data';
1406
+ return path.resolve(dataPath, 'addons');
1407
+ }
1408
+ /** Throw if installer was not initialised */
1409
+ requireInstaller() {
1410
+ if (!this.installer) {
1411
+ throw new Error('AddonInstaller is not available -- @camstack/kernel may not be installed');
1412
+ }
1413
+ }
1414
+ /** Send notification and toast for a successful package update */
1415
+ sendUpdateNotification(name, version) {
1416
+ this.notificationService
1417
+ .notify({
1418
+ title: 'Package Updated',
1419
+ message: `${name} updated to v${version}`,
1420
+ severity: 'info',
1421
+ category: 'system',
1422
+ timestamp: Date.now(),
1423
+ })
1424
+ .catch((err) => {
1425
+ const msg = (0, types_1.errMsg)(err);
1426
+ this.logger.debug('Update notification failed', { meta: { error: msg } });
1427
+ });
1428
+ this.toastService.broadcast({
1429
+ title: 'Package Updated',
1430
+ message: `${name} updated to v${version}`,
1431
+ severity: 'info',
1432
+ duration: 5000,
1433
+ });
1434
+ }
1435
+ }
1436
+ exports.AddonPackageService = AddonPackageService;
1437
+ // ---------------------------------------------------------------------------
1438
+ // Framework live-update helpers
1439
+ // ---------------------------------------------------------------------------
1440
+ /**
1441
+ * Build the npm CLI args that pin every relevant registry to
1442
+ * `CAMSTACK_NPM_REGISTRY`. We pass BOTH the default `--registry` and
1443
+ * the scope-specific `--@camstack:registry=` flag because workspace or
1444
+ * user-home `.npmrc` files commonly declare
1445
+ * `@camstack:registry=https://registry.npmjs.org/`, and that scoped
1446
+ * entry takes precedence over the plain `--registry` CLI flag for
1447
+ * `@camstack/*` lookups — which is exactly the path framework-update
1448
+ * traverses.
1449
+ *
1450
+ * Without this, the e2e suite's verdaccio gets bypassed even with
1451
+ * `CAMSTACK_NPM_REGISTRY` set, AND in production any operator running
1452
+ * their own private npm proxy via `@camstack:registry` would have
1453
+ * `updateFrameworkPackage` silently route around it.
1454
+ */
1455
+ function buildNpmRegistryArgs(registry) {
1456
+ if (registry === undefined || registry.length === 0)
1457
+ return [];
1458
+ return ['--registry', registry, `--@camstack:registry=${registry}`];
1459
+ }
1460
+ /**
1461
+ * Resolve the directory whose `node_modules/<pkg>/` holds the currently-
1462
+ * installed copy of a framework package. `npm install --prefix <appRoot>`
1463
+ * will then update that exact copy in place.
1464
+ *
1465
+ * Strategy: ask Node's resolver where it finds the package today, then walk
1466
+ * up to the `node_modules/`-parent. This matches whatever resolution path
1467
+ * the running hub actually uses (server-local node_modules in prod;
1468
+ * workspace-root in dev; bundled in Electron) without hard-coding either.
1469
+ *
1470
+ * Test knob: `CAMSTACK_FRAMEWORK_APP_ROOT_OVERRIDE` short-circuits the walk
1471
+ * and returns the env-supplied path. Used by the e2e suite to redirect the
1472
+ * `npm install --prefix` side-effects into an isolated temp dir instead of
1473
+ * the workspace's `server/backend/node_modules/`. Never set in production.
1474
+ */
1475
+ function resolveFrameworkPackageAppRoot(packageName, logger) {
1476
+ const override = process.env['CAMSTACK_FRAMEWORK_APP_ROOT_OVERRIDE'];
1477
+ if (override !== undefined && override.length > 0) {
1478
+ return override;
1479
+ }
1480
+ const resolved = require.resolve(`${packageName}/package.json`);
1481
+ // …/<appRoot>/node_modules/<scope>/<name>/package.json
1482
+ // walk up: package.json → name → scope → node_modules → appRoot
1483
+ let dir = path.dirname(resolved);
1484
+ while (dir !== path.dirname(dir)) {
1485
+ if (path.basename(dir) === 'node_modules') {
1486
+ return path.dirname(dir);
1487
+ }
1488
+ dir = path.dirname(dir);
1489
+ }
1490
+ logger.warn(`Could not resolve appRoot for ${packageName}; falling back to process.cwd()`);
1491
+ return process.cwd();
1492
+ }
1493
+ /**
1494
+ * Read a framework package's `package.json`, resolved however the
1495
+ * running hub actually loads it — workspace symlink in dev, a real
1496
+ * `node_modules` tree in prod, bundled in Electron.
1497
+ *
1498
+ * `require.resolve('<pkg>/package.json')` is the happy path. Packages
1499
+ * whose `exports` map omits the `./package.json` subpath (e.g.
1500
+ * `@camstack/sdk`, `@camstack/ui-library`) make that throw — so we
1501
+ * fall back to resolving the package's main entry and walking up to
1502
+ * the first `package.json` whose `name` matches.
1503
+ *
1504
+ * This is deliberately independent of `resolveFrameworkPackageAppRoot`:
1505
+ * that walk only finds a real `node_modules`-parent, which doesn't
1506
+ * exist for workspace-symlinked packages in dev — the cause of the
1507
+ * `vunknown` version label in the System Packages UI.
1508
+ */
1509
+ function readResolvedPackageManifest(packageName) {
1510
+ try {
1511
+ return readJsonObject(require.resolve(`${packageName}/package.json`));
1512
+ }
1513
+ catch {
1514
+ // `exports` map blocks the package.json subpath — fall through.
1515
+ }
1516
+ try {
1517
+ let dir = path.dirname(require.resolve(packageName));
1518
+ while (dir !== path.dirname(dir)) {
1519
+ const candidate = path.join(dir, 'package.json');
1520
+ const obj = fs.existsSync(candidate) ? readJsonObject(candidate) : null;
1521
+ if (obj !== null && obj['name'] === packageName)
1522
+ return obj;
1523
+ dir = path.dirname(dir);
1524
+ }
1525
+ }
1526
+ catch {
1527
+ // The package itself is not resolvable — treat as not installed.
1528
+ }
1529
+ return null;
1530
+ }
1531
+ /**
1532
+ * Resolve a version specifier (`'latest'`, semver tag, exact) to a concrete
1533
+ * version string via `npm view <pkg>@<spec> version`. Returns the spec as-is
1534
+ * when `npm view` is unavailable or fails — better to attempt the install
1535
+ * than to block.
1536
+ */
1537
+ async function resolveNpmVersion(packageName, versionSpec, registry) {
1538
+ const args = [
1539
+ 'view',
1540
+ `${packageName}@${versionSpec}`,
1541
+ 'version',
1542
+ ...buildNpmRegistryArgs(registry),
1543
+ ];
1544
+ try {
1545
+ const { stdout } = await execFileAsync('npm', args, { timeout: 30_000 });
1546
+ const trimmed = stdout.trim();
1547
+ // `npm view` returns just the version line for a single match, or
1548
+ // "pkg@1.2.3 '1.2.3'" lines for a range. Take the last token on the
1549
+ // last non-empty line which is the most specific resolved version.
1550
+ const lines = trimmed.split('\n').filter((line) => line.trim().length > 0);
1551
+ if (lines.length === 0)
1552
+ return versionSpec;
1553
+ const last = lines[lines.length - 1].trim();
1554
+ const match = last.match(/'([^']+)'\s*$/);
1555
+ return match ? match[1] : last;
1556
+ }
1557
+ catch {
1558
+ return versionSpec;
1559
+ }
1560
+ }