@camstack/server 1.2.89 → 1.2.91
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api/addons-custom.router.js +99 -0
- package/dist/api/core/bulk-update-coordinator.js +229 -0
- package/dist/api/core/cap-providers.js +2 -2
- package/dist/api/core/settings-backend.router.js +121 -0
- package/dist/api/static/spa-static.js +10 -1
- package/dist/api/trpc/generated-cap-routers.js +18 -0
- package/dist/boot/resume-framework-swap.js +119 -0
- package/dist/core/addon/addon-package.service.js +30 -3
- package/dist/core/addon/framework-live-sync.js +344 -0
- package/dist/core/server-update/server-update.service.js +25 -0
- package/dist/core/update-availability-emitter.js +57 -0
- package/dist/launcher-framework-swap.js +408 -0
- package/dist/main.js +11 -0
- package/dist/manual-boot.js +1 -0
- package/dist/request-framework-swap.js +41 -0
- package/dist/server-root/boot-plan.js +110 -0
- package/dist/server-root/semver-compare.js +45 -0
- package/dist/server-root/server-root-state.js +220 -0
- package/dist/server-root/workspace-detect.js +73 -0
- package/package.json +14 -14
|
@@ -46,6 +46,7 @@ const node_util_1 = require("node:util");
|
|
|
46
46
|
const system_1 = require("@camstack/system");
|
|
47
47
|
const types_1 = require("@camstack/types");
|
|
48
48
|
const package_dir_utils_js_1 = require("./package-dir-utils.js");
|
|
49
|
+
const update_availability_emitter_js_1 = require("../update-availability-emitter.js");
|
|
49
50
|
const execFileAsync = (0, node_util_1.promisify)(node_child_process_1.execFile);
|
|
50
51
|
/**
|
|
51
52
|
* The primary host-provided framework package (kernel + core). Kept as a named
|
|
@@ -189,6 +190,7 @@ class AddonPackageService {
|
|
|
189
190
|
cachedUpdates = null;
|
|
190
191
|
searchCache = null;
|
|
191
192
|
versionCache = new Map();
|
|
193
|
+
updateAvailability;
|
|
192
194
|
// -- Auto-update state ----------------------------------------------------
|
|
193
195
|
autoUpdateConfig = {
|
|
194
196
|
global: { channel: 'off', intervalSeconds: 21600 },
|
|
@@ -213,6 +215,10 @@ class AddonPackageService {
|
|
|
213
215
|
this.notificationService = notificationService;
|
|
214
216
|
this.toastService = toastService;
|
|
215
217
|
this.logger = this.loggingService.createLogger('AddonPackageService');
|
|
218
|
+
this.updateAvailability = new update_availability_emitter_js_1.UpdateAvailabilityEmitter(this.eventBusService, {
|
|
219
|
+
type: 'core',
|
|
220
|
+
id: 'addon-package-service',
|
|
221
|
+
});
|
|
216
222
|
// Initialize installer eagerly (no async needed).
|
|
217
223
|
// Ensures install/uninstall works before full module init completes.
|
|
218
224
|
try {
|
|
@@ -620,6 +626,12 @@ class AddonPackageService {
|
|
|
620
626
|
updates,
|
|
621
627
|
expiresAt: now + AddonPackageService.UPDATE_CACHE_TTL_MS,
|
|
622
628
|
};
|
|
629
|
+
this.updateAvailability.publishSnapshot('addon', updates.map((update) => ({
|
|
630
|
+
target: 'addon',
|
|
631
|
+
packageName: update.name,
|
|
632
|
+
currentVersion: update.currentVersion,
|
|
633
|
+
latestVersion: update.latestVersion,
|
|
634
|
+
})));
|
|
623
635
|
return updates;
|
|
624
636
|
}
|
|
625
637
|
/** Clear the cached update check results */
|
|
@@ -636,7 +648,7 @@ class AddonPackageService {
|
|
|
636
648
|
* Not cached: the caller decides freshness (an agent roster changes
|
|
637
649
|
* per deploy, and `forceRefresh` must always be live).
|
|
638
650
|
*/
|
|
639
|
-
async checkUpdatesForInstalled(installed) {
|
|
651
|
+
async checkUpdatesForInstalled(installed, nodeId) {
|
|
640
652
|
// De-dup by package name — one npm package may bundle several addons.
|
|
641
653
|
const seen = new Map();
|
|
642
654
|
for (const pkg of installed) {
|
|
@@ -648,7 +660,7 @@ class AddonPackageService {
|
|
|
648
660
|
if (!this.isAllowedPackage(name))
|
|
649
661
|
return;
|
|
650
662
|
const latestVersion = await this.fetchLatestVersion(name);
|
|
651
|
-
if (latestVersion === null || latestVersion
|
|
663
|
+
if (latestVersion === null || !isVersionNewer(latestVersion, version))
|
|
652
664
|
return;
|
|
653
665
|
const category = this.categorize(name);
|
|
654
666
|
updates.push({
|
|
@@ -659,6 +671,13 @@ class AddonPackageService {
|
|
|
659
671
|
requiresRestart: category === 'core',
|
|
660
672
|
});
|
|
661
673
|
}));
|
|
674
|
+
this.updateAvailability.publishSnapshot('addon', updates.map((update) => ({
|
|
675
|
+
target: 'addon',
|
|
676
|
+
packageName: update.name,
|
|
677
|
+
currentVersion: update.currentVersion,
|
|
678
|
+
latestVersion: update.latestVersion,
|
|
679
|
+
...(nodeId !== undefined ? { nodeId } : {}),
|
|
680
|
+
})), nodeId);
|
|
662
681
|
return updates;
|
|
663
682
|
}
|
|
664
683
|
/**
|
|
@@ -1243,6 +1262,7 @@ class AddonPackageService {
|
|
|
1243
1262
|
this.logger.info('Running auto-update check...');
|
|
1244
1263
|
const installed = this.listInstalled();
|
|
1245
1264
|
const targets = [];
|
|
1265
|
+
const available = [];
|
|
1246
1266
|
for (const pkg of installed) {
|
|
1247
1267
|
try {
|
|
1248
1268
|
// Framework packages NEVER auto-update from npm — they ship via
|
|
@@ -1289,6 +1309,12 @@ class AddonPackageService {
|
|
|
1289
1309
|
},
|
|
1290
1310
|
});
|
|
1291
1311
|
targets.push({ name: pkg.name, version: targetVersion });
|
|
1312
|
+
available.push({
|
|
1313
|
+
target: 'addon',
|
|
1314
|
+
packageName: pkg.name,
|
|
1315
|
+
currentVersion: pkg.version,
|
|
1316
|
+
latestVersion: targetVersion,
|
|
1317
|
+
});
|
|
1292
1318
|
}
|
|
1293
1319
|
catch (err) {
|
|
1294
1320
|
this.logger.warn('Auto-update check failed', {
|
|
@@ -1296,6 +1322,7 @@ class AddonPackageService {
|
|
|
1296
1322
|
});
|
|
1297
1323
|
}
|
|
1298
1324
|
}
|
|
1325
|
+
this.updateAvailability.publishCandidates(available);
|
|
1299
1326
|
if (targets.length === 0) {
|
|
1300
1327
|
this.logger.debug('Auto-update: all packages up-to-date');
|
|
1301
1328
|
return;
|
|
@@ -1431,7 +1458,7 @@ class AddonPackageService {
|
|
|
1431
1458
|
const latestVersion = await this.fetchLatestVersion(name);
|
|
1432
1459
|
if (!latestVersion)
|
|
1433
1460
|
continue;
|
|
1434
|
-
if (latestVersion
|
|
1461
|
+
if (isVersionNewer(latestVersion, version)) {
|
|
1435
1462
|
updates.push({
|
|
1436
1463
|
name,
|
|
1437
1464
|
currentVersion: version,
|
|
@@ -0,0 +1,344 @@
|
|
|
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.resolveHubClosurePackageDir = resolveHubClosurePackageDir;
|
|
37
|
+
exports.resolveOverlayPackageDir = resolveOverlayPackageDir;
|
|
38
|
+
exports.computeDistBuildId = computeDistBuildId;
|
|
39
|
+
exports.swapDist = swapDist;
|
|
40
|
+
exports.syncFrameworkDist = syncFrameworkDist;
|
|
41
|
+
/**
|
|
42
|
+
* Framework live-sync — make a `camstack deploy` of a framework package
|
|
43
|
+
* (`@camstack/system`, …) actually reach the code the running hub loads.
|
|
44
|
+
*
|
|
45
|
+
* WHY THIS EXISTS
|
|
46
|
+
* ---------------
|
|
47
|
+
* `installFromTgz` writes the freshly-built framework tree to
|
|
48
|
+
* `CAMSTACK_ADDONS_DIR/<pkg>` (`/data/addons/@camstack/system`). NO runtime
|
|
49
|
+
* consumer loads from there:
|
|
50
|
+
* • the hub main process resolves `@camstack/system` from its OWN
|
|
51
|
+
* `node_modules` closure (Node's `node_modules` walk-up from
|
|
52
|
+
* `@camstack/server` finds the co-located copy BEFORE `NODE_PATH` is ever
|
|
53
|
+
* consulted — so the `/data/framework` overlay is a no-op for it);
|
|
54
|
+
* • the forked addon runners resolve it from `CAMSTACK_FRAMEWORK_DIR`
|
|
55
|
+
* (`/data/framework/node_modules/<pkg>`) via the ESM resolver hook.
|
|
56
|
+
* So a framework deploy landed on disk in a dead location and the subsequent
|
|
57
|
+
* hub restart reloaded the SAME unchanged code.
|
|
58
|
+
*
|
|
59
|
+
* This module mirrors the freshly-built `dist/` into BOTH live locations with
|
|
60
|
+
* an atomic rename-aside swap (mirrors `AddonInstaller.evictInstallDir`), so the
|
|
61
|
+
* scheduled restart actually loads the new framework code in the hub main AND
|
|
62
|
+
* the addon runners. Only `dist/` is swapped — native prebuilds and sibling
|
|
63
|
+
* packages in each closure are left untouched.
|
|
64
|
+
*
|
|
65
|
+
* All failures are surfaced (logged + returned) but never thrown: a failed
|
|
66
|
+
* mirror must not brick the restart, and the rename-aside guarantees a live
|
|
67
|
+
* `dist/` is never left half-written (the incoming copy is staged first, and
|
|
68
|
+
* the previous `dist/` is restored if the final swap fails).
|
|
69
|
+
*/
|
|
70
|
+
const fs = __importStar(require("node:fs"));
|
|
71
|
+
const path = __importStar(require("node:path"));
|
|
72
|
+
const node_crypto_1 = require("node:crypto");
|
|
73
|
+
const node_module_1 = require("node:module");
|
|
74
|
+
// Resolve framework packages the same way the running hub does — from THIS
|
|
75
|
+
// module's location, whose walk-up lands on the hub's own `node_modules`
|
|
76
|
+
// closure (identical to the hub main's `@camstack/system` resolution).
|
|
77
|
+
const requireFromHere = (0, node_module_1.createRequire)(__filename);
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
// Tiny cast-free JSON helper (mirror of addon-package.service.readJsonObject).
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
function readJsonObjectSafe(filePath) {
|
|
82
|
+
try {
|
|
83
|
+
const parsed = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
|
84
|
+
if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
85
|
+
return { ...parsed };
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
/* missing / malformed — treat as absent */
|
|
90
|
+
}
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
function packageNameOf(pkgJsonPath) {
|
|
94
|
+
const obj = readJsonObjectSafe(pkgJsonPath);
|
|
95
|
+
const name = obj?.['name'];
|
|
96
|
+
return typeof name === 'string' ? name : null;
|
|
97
|
+
}
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
// Target resolution
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
/**
|
|
102
|
+
* The package directory the HUB MAIN process loads `<packageName>` from — its
|
|
103
|
+
* own `node_modules` closure. `require.resolve('<pkg>/package.json')` is the
|
|
104
|
+
* happy path; packages whose `exports` map omits that subpath fall back to
|
|
105
|
+
* resolving the main entry and walking up to the matching `package.json`.
|
|
106
|
+
* Returns null when the package is not resolvable.
|
|
107
|
+
*/
|
|
108
|
+
function resolveHubClosurePackageDir(packageName) {
|
|
109
|
+
try {
|
|
110
|
+
return path.dirname(requireFromHere.resolve(`${packageName}/package.json`));
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
// `exports` map blocks the package.json subpath — fall through.
|
|
114
|
+
}
|
|
115
|
+
try {
|
|
116
|
+
let dir = path.dirname(requireFromHere.resolve(packageName));
|
|
117
|
+
while (dir !== path.dirname(dir)) {
|
|
118
|
+
const candidate = path.join(dir, 'package.json');
|
|
119
|
+
if (fs.existsSync(candidate) && packageNameOf(candidate) === packageName) {
|
|
120
|
+
return dir;
|
|
121
|
+
}
|
|
122
|
+
dir = path.dirname(dir);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
// package itself not resolvable — treat as not installed.
|
|
127
|
+
}
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* The package directory the forked ADDON RUNNERS load `<packageName>` from —
|
|
132
|
+
* the `CAMSTACK_FRAMEWORK_DIR` overlay. Returns null when the env is unset
|
|
133
|
+
* (dev / no overlay) or the dir does not exist.
|
|
134
|
+
*/
|
|
135
|
+
function resolveOverlayPackageDir(packageName) {
|
|
136
|
+
const frameworkDir = process.env['CAMSTACK_FRAMEWORK_DIR'];
|
|
137
|
+
if (frameworkDir === undefined || frameworkDir.length === 0)
|
|
138
|
+
return null;
|
|
139
|
+
const dir = path.join(frameworkDir, 'node_modules', packageName);
|
|
140
|
+
return fs.existsSync(dir) ? dir : null;
|
|
141
|
+
}
|
|
142
|
+
function resolveLiveTargets(packageName) {
|
|
143
|
+
const out = [];
|
|
144
|
+
const hub = resolveHubClosurePackageDir(packageName);
|
|
145
|
+
if (hub !== null)
|
|
146
|
+
out.push({ role: 'hub-closure', packageDir: hub });
|
|
147
|
+
const overlay = resolveOverlayPackageDir(packageName);
|
|
148
|
+
if (overlay !== null)
|
|
149
|
+
out.push({ role: 'overlay', packageDir: overlay });
|
|
150
|
+
return out;
|
|
151
|
+
}
|
|
152
|
+
function safeRealpath(p) {
|
|
153
|
+
try {
|
|
154
|
+
return fs.realpathSync(p);
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
return path.resolve(p);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
// ---------------------------------------------------------------------------
|
|
161
|
+
// Build-id (content hash of a `dist/` tree)
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
163
|
+
function collectJsFiles(root, dir, out) {
|
|
164
|
+
let entries;
|
|
165
|
+
try {
|
|
166
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
for (const entry of entries) {
|
|
172
|
+
const full = path.join(dir, entry.name);
|
|
173
|
+
if (entry.isDirectory()) {
|
|
174
|
+
collectJsFiles(root, full, out);
|
|
175
|
+
}
|
|
176
|
+
else if (entry.isFile() && (entry.name.endsWith('.js') || entry.name.endsWith('.mjs'))) {
|
|
177
|
+
out.push(path.relative(root, full));
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Content build-id of a `dist/` tree — md5 over every `.js`/`.mjs` file's
|
|
183
|
+
* relative path + bytes (sorted for determinism). Analogous to the Docker
|
|
184
|
+
* image's `seed-version` fingerprint. Returns null when the dir is missing or
|
|
185
|
+
* empty. Used to surface "same semver, new code" instead of trusting `version`.
|
|
186
|
+
*/
|
|
187
|
+
function computeDistBuildId(distDir) {
|
|
188
|
+
if (!fs.existsSync(distDir))
|
|
189
|
+
return null;
|
|
190
|
+
const files = [];
|
|
191
|
+
collectJsFiles(distDir, distDir, files);
|
|
192
|
+
if (files.length === 0)
|
|
193
|
+
return null;
|
|
194
|
+
files.sort();
|
|
195
|
+
const hash = (0, node_crypto_1.createHash)('md5');
|
|
196
|
+
for (const rel of files) {
|
|
197
|
+
hash.update(rel);
|
|
198
|
+
hash.update('\0');
|
|
199
|
+
hash.update(fs.readFileSync(path.join(distDir, rel)));
|
|
200
|
+
}
|
|
201
|
+
return hash.digest('hex');
|
|
202
|
+
}
|
|
203
|
+
// ---------------------------------------------------------------------------
|
|
204
|
+
// Atomic dist swap (rename-aside)
|
|
205
|
+
// ---------------------------------------------------------------------------
|
|
206
|
+
function isCrossDeviceError(err) {
|
|
207
|
+
return err instanceof Error && err.code === 'EXDEV';
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Replace `<targetPkgDir>/dist` with a copy of `sourceDistDir`.
|
|
211
|
+
*
|
|
212
|
+
* 1. COPY `sourceDistDir` → a staging dir INSIDE the target dir. The live
|
|
213
|
+
* `dist/` is untouched, so a mid-copy failure cannot corrupt it.
|
|
214
|
+
* 2. Evict the current `dist/` aside (rename), then rename the staging dir into
|
|
215
|
+
* place. Both renames are metadata ops WITHIN the target fs (atomic). If the
|
|
216
|
+
* final swap fails, the previous `dist/` is restored.
|
|
217
|
+
*
|
|
218
|
+
* OVERLAYFS FALLBACK: the hub-closure target (`/opt/.../@camstack/system`) lives
|
|
219
|
+
* on the container's overlay rootfs. A `dist/` materialized from a LOWER image
|
|
220
|
+
* layer cannot be `rename()`d (overlayfs returns `EXDEV: cross-device link`),
|
|
221
|
+
* so the atomic aside/swap is impossible there. On EXDEV we fall back to a
|
|
222
|
+
* copy-aside restore point + in-place replace. This opens a brief non-atomic
|
|
223
|
+
* window, but it is safe here: the running process already holds the old
|
|
224
|
+
* modules in memory (nothing reads `dist/` until the scheduled restart), and
|
|
225
|
+
* the copy-aside is kept until the replace completes so a failure is
|
|
226
|
+
* recoverable.
|
|
227
|
+
*/
|
|
228
|
+
async function swapDist(sourceDistDir, targetPkgDir) {
|
|
229
|
+
const targetDist = path.join(targetPkgDir, 'dist');
|
|
230
|
+
const stamp = `${process.pid}-${Date.now()}`;
|
|
231
|
+
const incoming = path.join(targetPkgDir, `.dist-incoming-${stamp}`);
|
|
232
|
+
const aside = path.join(targetPkgDir, `.dist-evicted-${stamp}`);
|
|
233
|
+
// Step 1 — stage the incoming dist (cross-device copy is fine here).
|
|
234
|
+
try {
|
|
235
|
+
await fs.promises.cp(sourceDistDir, incoming, { recursive: true, force: true });
|
|
236
|
+
}
|
|
237
|
+
catch (err) {
|
|
238
|
+
await fs.promises.rm(incoming, { recursive: true, force: true }).catch(() => undefined);
|
|
239
|
+
throw err;
|
|
240
|
+
}
|
|
241
|
+
const hadDist = fs.existsSync(targetDist);
|
|
242
|
+
// How the previous dist was preserved: 'rename' (moved aside, same fs),
|
|
243
|
+
// 'copy' (overlayfs EXDEV — copied aside then removed in place), or 'none'.
|
|
244
|
+
let asideKind = 'none';
|
|
245
|
+
const restorePreviousDist = async () => {
|
|
246
|
+
if (asideKind === 'none' || fs.existsSync(targetDist))
|
|
247
|
+
return;
|
|
248
|
+
if (asideKind === 'rename') {
|
|
249
|
+
await fs.promises.rename(aside, targetDist).catch(() => undefined);
|
|
250
|
+
}
|
|
251
|
+
else {
|
|
252
|
+
await fs.promises
|
|
253
|
+
.cp(aside, targetDist, { recursive: true, force: true })
|
|
254
|
+
.catch(() => undefined);
|
|
255
|
+
}
|
|
256
|
+
};
|
|
257
|
+
// Step 2 — evict the current dist. Prefer the atomic rename; fall back to
|
|
258
|
+
// copy-aside + in-place remove on overlayfs EXDEV.
|
|
259
|
+
if (hadDist) {
|
|
260
|
+
try {
|
|
261
|
+
await fs.promises.rename(targetDist, aside);
|
|
262
|
+
asideKind = 'rename';
|
|
263
|
+
}
|
|
264
|
+
catch (err) {
|
|
265
|
+
if (!isCrossDeviceError(err)) {
|
|
266
|
+
await fs.promises.rm(incoming, { recursive: true, force: true }).catch(() => undefined);
|
|
267
|
+
throw err;
|
|
268
|
+
}
|
|
269
|
+
await fs.promises.cp(targetDist, aside, { recursive: true, force: true });
|
|
270
|
+
await fs.promises.rm(targetDist, { recursive: true, force: true });
|
|
271
|
+
asideKind = 'copy';
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
// Step 3 — move the incoming dist into place; fall back to copy on EXDEV.
|
|
275
|
+
try {
|
|
276
|
+
await fs.promises.rename(incoming, targetDist);
|
|
277
|
+
}
|
|
278
|
+
catch (err) {
|
|
279
|
+
if (isCrossDeviceError(err)) {
|
|
280
|
+
try {
|
|
281
|
+
await fs.promises.cp(incoming, targetDist, { recursive: true, force: true });
|
|
282
|
+
await fs.promises.rm(incoming, { recursive: true, force: true }).catch(() => undefined);
|
|
283
|
+
}
|
|
284
|
+
catch (copyErr) {
|
|
285
|
+
await restorePreviousDist();
|
|
286
|
+
await fs.promises.rm(incoming, { recursive: true, force: true }).catch(() => undefined);
|
|
287
|
+
throw copyErr;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
else {
|
|
291
|
+
await restorePreviousDist();
|
|
292
|
+
await fs.promises.rm(incoming, { recursive: true, force: true }).catch(() => undefined);
|
|
293
|
+
throw err;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
// Step 4 — best-effort cleanup of the evicted copy.
|
|
297
|
+
if (asideKind !== 'none') {
|
|
298
|
+
await fs.promises.rm(aside, { recursive: true, force: true }).catch(() => undefined);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
// ---------------------------------------------------------------------------
|
|
302
|
+
// Public entry
|
|
303
|
+
// ---------------------------------------------------------------------------
|
|
304
|
+
/**
|
|
305
|
+
* Mirror a freshly-installed framework `dist/` into every live location the
|
|
306
|
+
* runtime actually loads it from (hub-main closure + addon-runner overlay).
|
|
307
|
+
* Never throws — returns a per-target outcome. Call BEFORE the scheduled hub
|
|
308
|
+
* restart so the bounce loads the new code.
|
|
309
|
+
*/
|
|
310
|
+
async function syncFrameworkDist(args) {
|
|
311
|
+
const { packageName, sourceDistDir, logger } = args;
|
|
312
|
+
if (!fs.existsSync(sourceDistDir)) {
|
|
313
|
+
logger.warn('framework sync: source dist missing — nothing to mirror', {
|
|
314
|
+
meta: { packageName, sourceDistDir },
|
|
315
|
+
});
|
|
316
|
+
return { results: [] };
|
|
317
|
+
}
|
|
318
|
+
const sourcePkgReal = safeRealpath(path.dirname(sourceDistDir));
|
|
319
|
+
const seen = new Set();
|
|
320
|
+
const results = [];
|
|
321
|
+
for (const target of resolveLiveTargets(packageName)) {
|
|
322
|
+
const real = safeRealpath(target.packageDir);
|
|
323
|
+
// Dedupe (a dev symlink can point both roles at one dir) and never mirror
|
|
324
|
+
// a package dir onto its own source (would delete the code mid-swap).
|
|
325
|
+
if (seen.has(real) || real === sourcePkgReal)
|
|
326
|
+
continue;
|
|
327
|
+
seen.add(real);
|
|
328
|
+
try {
|
|
329
|
+
await swapDist(sourceDistDir, target.packageDir);
|
|
330
|
+
results.push({ role: target.role, packageDir: target.packageDir, ok: true });
|
|
331
|
+
logger.info('framework sync: dist swapped into live location', {
|
|
332
|
+
meta: { packageName, role: target.role, packageDir: target.packageDir },
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
catch (err) {
|
|
336
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
337
|
+
results.push({ role: target.role, packageDir: target.packageDir, ok: false, error: message });
|
|
338
|
+
logger.error('framework sync: dist swap FAILED (live dir left intact)', {
|
|
339
|
+
meta: { packageName, role: target.role, packageDir: target.packageDir, error: message },
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
return { results };
|
|
344
|
+
}
|
|
@@ -55,7 +55,9 @@ const path = __importStar(require("node:path"));
|
|
|
55
55
|
const index_js_1 = require("../../server-root/index.js");
|
|
56
56
|
const system_exec_npm_js_1 = require("./system-exec-npm.js");
|
|
57
57
|
const system_ensure_prebuilds_js_1 = require("./system-ensure-prebuilds.js");
|
|
58
|
+
const update_availability_emitter_js_1 = require("../update-availability-emitter.js");
|
|
58
59
|
class ServerUpdateService extends index_js_1.RootUpdateService {
|
|
60
|
+
updateAvailability;
|
|
59
61
|
constructor(options) {
|
|
60
62
|
const dataDir = path.resolve(options.dataDir ??
|
|
61
63
|
options.env?.['CAMSTACK_DATA'] ??
|
|
@@ -90,6 +92,29 @@ class ServerUpdateService extends index_js_1.RootUpdateService {
|
|
|
90
92
|
env: options.env,
|
|
91
93
|
now: options.now,
|
|
92
94
|
});
|
|
95
|
+
this.updateAvailability =
|
|
96
|
+
options.eventBus !== undefined
|
|
97
|
+
? new update_availability_emitter_js_1.UpdateAvailabilityEmitter(options.eventBus, {
|
|
98
|
+
type: 'core',
|
|
99
|
+
id: 'server-update-service',
|
|
100
|
+
})
|
|
101
|
+
: null;
|
|
102
|
+
}
|
|
103
|
+
async checkServerUpdate() {
|
|
104
|
+
const result = await super.checkServerUpdate();
|
|
105
|
+
if (result.error !== null)
|
|
106
|
+
return result;
|
|
107
|
+
this.updateAvailability?.publishSnapshot('server', result.updateAvailable && result.runningVersion !== null && result.latestVersion !== null
|
|
108
|
+
? [
|
|
109
|
+
{
|
|
110
|
+
target: 'server',
|
|
111
|
+
packageName: result.packageName,
|
|
112
|
+
currentVersion: result.runningVersion,
|
|
113
|
+
latestVersion: result.latestVersion,
|
|
114
|
+
},
|
|
115
|
+
]
|
|
116
|
+
: []);
|
|
117
|
+
return result;
|
|
93
118
|
}
|
|
94
119
|
}
|
|
95
120
|
exports.ServerUpdateService = ServerUpdateService;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.UpdateAvailabilityEmitter = void 0;
|
|
4
|
+
const types_1 = require("@camstack/types");
|
|
5
|
+
/**
|
|
6
|
+
* Emits update availability only when a successful check changes the observed
|
|
7
|
+
* `(currentVersion, latestVersion)` pair. A successful empty snapshot clears
|
|
8
|
+
* prior state; failed checks should not call this method.
|
|
9
|
+
*/
|
|
10
|
+
class UpdateAvailabilityEmitter {
|
|
11
|
+
eventBus;
|
|
12
|
+
source;
|
|
13
|
+
signatures = new Map();
|
|
14
|
+
constructor(eventBus, source) {
|
|
15
|
+
this.eventBus = eventBus;
|
|
16
|
+
this.source = source;
|
|
17
|
+
}
|
|
18
|
+
publishSnapshot(target, candidates, nodeId) {
|
|
19
|
+
const scope = nodeId ?? 'hub';
|
|
20
|
+
const present = new Set();
|
|
21
|
+
for (const candidate of candidates) {
|
|
22
|
+
if (candidate.target !== target)
|
|
23
|
+
continue;
|
|
24
|
+
const key = this.key(candidate);
|
|
25
|
+
present.add(key);
|
|
26
|
+
this.publish(candidate);
|
|
27
|
+
}
|
|
28
|
+
for (const key of this.signatures.keys()) {
|
|
29
|
+
if (key.startsWith(`${target}:${scope}:`) && !present.has(key)) {
|
|
30
|
+
this.signatures.delete(key);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/** Publish a partial candidate set without treating omissions as up-to-date. */
|
|
35
|
+
publishCandidates(candidates) {
|
|
36
|
+
for (const candidate of candidates)
|
|
37
|
+
this.publish(candidate);
|
|
38
|
+
}
|
|
39
|
+
publish(candidate) {
|
|
40
|
+
const key = this.key(candidate);
|
|
41
|
+
const signature = `${candidate.currentVersion}->${candidate.latestVersion}`;
|
|
42
|
+
if (this.signatures.get(key) === signature)
|
|
43
|
+
return;
|
|
44
|
+
this.signatures.set(key, signature);
|
|
45
|
+
this.eventBus.emit({
|
|
46
|
+
id: `update.available:${key}:${signature}`,
|
|
47
|
+
timestamp: new Date(),
|
|
48
|
+
source: this.source,
|
|
49
|
+
category: types_1.EventCategory.UpdateAvailable,
|
|
50
|
+
data: candidate,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
key(candidate) {
|
|
54
|
+
return `${candidate.target}:${candidate.nodeId ?? 'hub'}:${candidate.packageName}`;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
exports.UpdateAvailabilityEmitter = UpdateAvailabilityEmitter;
|