@camstack/server 1.2.100 → 1.2.102
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/agent/main.js +19 -0
- package/dist/api/core/addon-settings.router.js +24 -0
- package/dist/api/core/system-events.router.js +11 -5
- package/dist/api/static/spa-static.js +27 -1
- package/dist/api/trpc/generated-cap-routers.js +45 -0
- package/dist/core/addon/addon-registry.service.js +77 -23
- package/dist/core/moleculer/moleculer.service.js +11 -0
- package/dist/first-boot-addon-plan.js +112 -0
- package/dist/launcher.js +97 -25
- package/dist/main.js +29 -3
- package/dist/package-inventory.js +114 -27
- package/dist/single-copy-cleanup-runner.js +128 -0
- package/dist/single-copy-cleanup.js +241 -0
- package/package.json +14 -14
package/dist/launcher.js
CHANGED
|
@@ -54,12 +54,13 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
54
54
|
const fs = __importStar(require("node:fs"));
|
|
55
55
|
const os = __importStar(require("node:os"));
|
|
56
56
|
const path = __importStar(require("node:path"));
|
|
57
|
-
const tar = __importStar(require("tar"));
|
|
58
57
|
const yaml = __importStar(require("js-yaml"));
|
|
59
|
-
const
|
|
58
|
+
const tar = __importStar(require("tar"));
|
|
60
59
|
const addon_root_inventory_js_1 = require("./addon-root-inventory.js");
|
|
61
|
-
const package_inventory_js_1 = require("./package-inventory.js");
|
|
62
60
|
const bootstrap_packages_js_1 = require("./bootstrap-packages.js");
|
|
61
|
+
const first_boot_addon_plan_js_1 = require("./first-boot-addon-plan.js");
|
|
62
|
+
const framework_nodepath_js_1 = require("./framework-nodepath.js");
|
|
63
|
+
const package_inventory_js_1 = require("./package-inventory.js");
|
|
63
64
|
/** Path of the manifest file embedded inside every archive. */
|
|
64
65
|
const ARCHIVE_MANIFEST_NAME = '.camstack-backup-manifest.json';
|
|
65
66
|
/** Resolve the data directory from env or default */
|
|
@@ -206,15 +207,44 @@ function readBootstrapInstallSource(dataDir, bootstrapSchema) {
|
|
|
206
207
|
* `launch()` on import, so nothing declared here can be imported by a spec).
|
|
207
208
|
*/
|
|
208
209
|
function deriveBootstrapFromSelf() {
|
|
210
|
+
const pkg = readOwnManifest();
|
|
211
|
+
return pkg === null ? [] : (0, bootstrap_packages_js_1.selectHubBootstrapPackages)(pkg);
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* THIS server closure's own `package.json` — the roster's source and, when
|
|
215
|
+
* `pin-root-deps` ran for the release, the exact version of every bootstrap
|
|
216
|
+
* addon. Read by absolute path for the same reason `deriveBootstrapFromSelf`
|
|
217
|
+
* is: the slim image strips the `@camstack/*` symlinks, so `require.resolve`
|
|
218
|
+
* would answer nothing here.
|
|
219
|
+
*/
|
|
220
|
+
function readOwnManifest() {
|
|
209
221
|
try {
|
|
210
222
|
const pkgPath = path.resolve(__dirname, '..', 'package.json');
|
|
211
|
-
|
|
212
|
-
return (0, bootstrap_packages_js_1.selectHubBootstrapPackages)(pkg);
|
|
223
|
+
return JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
|
|
213
224
|
}
|
|
214
225
|
catch (err) {
|
|
215
|
-
console.warn(`[launcher] could not
|
|
216
|
-
return
|
|
226
|
+
console.warn(`[launcher] could not read the server manifest: ${err instanceof Error ? err.message : String(err)}`);
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
/** The version each required package currently has under the addon root. */
|
|
231
|
+
function readInstalledAddonVersions(addonRoot, packages) {
|
|
232
|
+
const out = {};
|
|
233
|
+
for (const name of packages) {
|
|
234
|
+
const pkgJson = path.join(addonRoot, name, 'package.json');
|
|
235
|
+
if (!fs.existsSync(pkgJson))
|
|
236
|
+
continue;
|
|
237
|
+
try {
|
|
238
|
+
const raw = JSON.parse(fs.readFileSync(pkgJson, 'utf-8'));
|
|
239
|
+
const version = typeof raw === 'object' && raw !== null ? raw.version : undefined;
|
|
240
|
+
out[name] = typeof version === 'string' ? version : null;
|
|
241
|
+
}
|
|
242
|
+
catch {
|
|
243
|
+
// Present but unreadable is NOT absent — the plan says so in its own words.
|
|
244
|
+
out[name] = null;
|
|
245
|
+
}
|
|
217
246
|
}
|
|
247
|
+
return out;
|
|
218
248
|
}
|
|
219
249
|
/**
|
|
220
250
|
* Read `bootstrap.requiredAddons` from `<dataDir>/config.yaml` if present.
|
|
@@ -364,6 +394,44 @@ async function launch() {
|
|
|
364
394
|
const roleDefaultBootstrap = role === 'agent' ? AddonInstaller.AGENT_PACKAGES : deriveBootstrapFromSelf();
|
|
365
395
|
const bootstrapRequired = readBootstrapRequiredAddons(dataDir, bootstrapSchema) ?? roleDefaultBootstrap;
|
|
366
396
|
console.log(`[launcher] bootstrap (${role}): ${bootstrapRequired.length} required package(s)`);
|
|
397
|
+
// THE FIRST-BOOT ADDON PLAN — reported every boot (D45 task 32).
|
|
398
|
+
//
|
|
399
|
+
// The image no longer bakes a NON-system addon tree, so this roster comes
|
|
400
|
+
// from the registry on a fresh node: "tutti gli altri addons devono rimanere
|
|
401
|
+
// copia unica, sempre, installati durante l'avvio iniziale" (2026-08-12), at
|
|
402
|
+
// `latest`, because "il sistema e' designato per essere addons-agnostic"
|
|
403
|
+
// (2026-08-13). The plan below is what `ensureRequiredPackages` is about to
|
|
404
|
+
// do, said out loud BEFORE it happens: how many packages this node must fetch
|
|
405
|
+
// (i.e. whether this boot needs the registry at all), and where an installed
|
|
406
|
+
// copy differs from the one inside this very closure — one node, two truthful
|
|
407
|
+
// versions, which is the condition task 32 exists to end.
|
|
408
|
+
//
|
|
409
|
+
// Diagnostics, so it can never end a boot: same policy as the inventories.
|
|
410
|
+
try {
|
|
411
|
+
const closureNodeModules = path.resolve(__dirname, '..', 'node_modules');
|
|
412
|
+
const plan = (0, first_boot_addon_plan_js_1.planFirstBootAddons)({
|
|
413
|
+
required: bootstrapRequired,
|
|
414
|
+
installed: readInstalledAddonVersions(addonsDir, bootstrapRequired),
|
|
415
|
+
closureVersions: readInstalledAddonVersions(closureNodeModules, bootstrapRequired),
|
|
416
|
+
// Mirrors `shouldSkipClosureProvidedSeed`: only `@camstack/system`, and
|
|
417
|
+
// only while it actually resolves.
|
|
418
|
+
closureProvided: (() => {
|
|
419
|
+
try {
|
|
420
|
+
require.resolve('@camstack/system/package.json');
|
|
421
|
+
return ['@camstack/system'];
|
|
422
|
+
}
|
|
423
|
+
catch {
|
|
424
|
+
return [];
|
|
425
|
+
}
|
|
426
|
+
})(),
|
|
427
|
+
});
|
|
428
|
+
for (const line of (0, first_boot_addon_plan_js_1.formatFirstBootPlan)(plan, 'observe')) {
|
|
429
|
+
console.log(`[launcher] ${line}`);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
catch (err) {
|
|
433
|
+
console.warn(`[launcher] first-boot addon plan failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
434
|
+
}
|
|
367
435
|
await installer.ensureRequiredPackages(bootstrapRequired);
|
|
368
436
|
// Reconcile the install manifest against what is on disk — the directory is
|
|
369
437
|
// the truth, because it is what the loader runs. Registers image-seeded
|
|
@@ -472,12 +540,21 @@ async function launch() {
|
|
|
472
540
|
// 1.2.30), so every version the node reported was true of a copy nobody was
|
|
473
541
|
// running.
|
|
474
542
|
//
|
|
475
|
-
//
|
|
476
|
-
// the image
|
|
477
|
-
//
|
|
478
|
-
//
|
|
479
|
-
//
|
|
480
|
-
//
|
|
543
|
+
// Only the copies nobody deposits on purpose are reported. The /opt seeds
|
|
544
|
+
// are baked into the image and shadowed by the /data/server-root/current
|
|
545
|
+
// deposit on every applyServerUpdate — calling those a violation printed the
|
|
546
|
+
// banner at every boot on every node, which is how it stopped being read.
|
|
547
|
+
// They get one compact line instead; an unexpected location (the legacy
|
|
548
|
+
// /data/framework, a bootstrap install under /data/addons) and two copies
|
|
549
|
+
// that could both run still scream.
|
|
550
|
+
//
|
|
551
|
+
// NEVER fatal — operator directive, 2026-08-12: "non dovremmo mai prevenire
|
|
552
|
+
// il boot". There is no flag that turns this into a refusal. A node that
|
|
553
|
+
// cannot start is a camera system that is not recording, and the inventory is
|
|
554
|
+
// the thing that was supposed to EXPLAIN a bad layout, not enact a verdict on
|
|
555
|
+
// it. Removing a copy stays an operator decision.
|
|
556
|
+
//
|
|
557
|
+
// [inventory-diagnostics-begin] enforced by scripts/check-inventory-never-fatal.ts
|
|
481
558
|
try {
|
|
482
559
|
const inventory = (0, package_inventory_js_1.inventoryHostProvided)((0, package_inventory_js_1.hostProvidedSearchRoots)({
|
|
483
560
|
nodePath: process.env['NODE_PATH'],
|
|
@@ -508,13 +585,12 @@ async function launch() {
|
|
|
508
585
|
}
|
|
509
586
|
},
|
|
510
587
|
});
|
|
588
|
+
for (const note of (0, package_inventory_js_1.formatShadowingNotes)(inventory)) {
|
|
589
|
+
console.log(`[launcher] ${note}`);
|
|
590
|
+
}
|
|
511
591
|
const report = (0, package_inventory_js_1.formatInventoryReport)(inventory);
|
|
512
592
|
if (report !== '') {
|
|
513
593
|
console.error(`[launcher] ${report}`);
|
|
514
|
-
if (process.env['CAMSTACK_INVENTORY_STRICT'] === '1') {
|
|
515
|
-
console.error('[launcher] CAMSTACK_INVENTORY_STRICT=1 — refusing to boot on this layout');
|
|
516
|
-
process.exit(1);
|
|
517
|
-
}
|
|
518
594
|
}
|
|
519
595
|
}
|
|
520
596
|
catch (err) {
|
|
@@ -532,10 +608,9 @@ async function launch() {
|
|
|
532
608
|
// not run since June. Measured on the Mac agent: four addon roots, four
|
|
533
609
|
// truthful `addon-pipeline` versions, one of them the running node's.
|
|
534
610
|
//
|
|
535
|
-
// Reported, never fatal, never deleted here — same policy as D45
|
|
536
|
-
// second location LOUD is the whole job;
|
|
537
|
-
//
|
|
538
|
-
// machine that HAS been cleaned keeps itself clean.
|
|
611
|
+
// Reported, never fatal, never deleted here — same policy as D45 above, and
|
|
612
|
+
// no flag changes it. Making the second location LOUD is the whole job;
|
|
613
|
+
// removing it stays an operator decision.
|
|
539
614
|
try {
|
|
540
615
|
const addonRoots = (0, addon_root_inventory_js_1.inventoryAddonRoots)({
|
|
541
616
|
dataDir: DATA_DIR,
|
|
@@ -579,15 +654,12 @@ async function launch() {
|
|
|
579
654
|
const addonRootReport = (0, addon_root_inventory_js_1.formatAddonRootReport)(addonRoots);
|
|
580
655
|
if (addonRootReport !== '') {
|
|
581
656
|
console.error(`[launcher] ${addonRootReport}`);
|
|
582
|
-
if (process.env['CAMSTACK_INVENTORY_STRICT'] === '1') {
|
|
583
|
-
console.error('[launcher] CAMSTACK_INVENTORY_STRICT=1 — refusing to boot on this layout');
|
|
584
|
-
process.exit(1);
|
|
585
|
-
}
|
|
586
657
|
}
|
|
587
658
|
}
|
|
588
659
|
catch (err) {
|
|
589
660
|
console.warn(`[launcher] addon-root inventory failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
590
661
|
}
|
|
662
|
+
// [inventory-diagnostics-end]
|
|
591
663
|
// Now safe to load the role's runtime entry (both have static imports from
|
|
592
664
|
// @camstack/system, resolved only after the framework dir + NODE_PATH setup
|
|
593
665
|
// above). The agent boots from THIS same @camstack/server closure — there is
|
package/dist/main.js
CHANGED
|
@@ -46,6 +46,7 @@ const ws_2 = require("ws");
|
|
|
46
46
|
const fs = __importStar(require("node:fs"));
|
|
47
47
|
const path = __importStar(require("node:path"));
|
|
48
48
|
const node_child_process_1 = require("node:child_process");
|
|
49
|
+
const single_copy_cleanup_runner_js_1 = require("./single-copy-cleanup-runner.js");
|
|
49
50
|
const logging_service_1 = require("./core/logging/logging.service");
|
|
50
51
|
const event_bus_service_1 = require("./core/events/event-bus.service");
|
|
51
52
|
const config_service_1 = require("./core/config/config.service");
|
|
@@ -888,7 +889,15 @@ async function bootstrap() {
|
|
|
888
889
|
return reply.status(404).send({ error: 'Not found' });
|
|
889
890
|
}
|
|
890
891
|
if (method === 'GET' && spaIndexHtml) {
|
|
891
|
-
|
|
892
|
+
// An addon deep-link that is not an addon ROUTE is an admin-UI page —
|
|
893
|
+
// serve the admin shell, under the SAME no-store policy as `/`. This
|
|
894
|
+
// send used to carry no cache header at all, so browsers kept the
|
|
895
|
+
// index.html of a previous build (and its dead hashed asset names)
|
|
896
|
+
// across deploys until a force refresh.
|
|
897
|
+
return reply
|
|
898
|
+
.header('cache-control', (0, spa_static_1.spaShellCacheControl)())
|
|
899
|
+
.type('text/html')
|
|
900
|
+
.send(fs.createReadStream(spaIndexHtml));
|
|
892
901
|
}
|
|
893
902
|
return reply.status(404).send({ error: 'Not found' });
|
|
894
903
|
}
|
|
@@ -1115,7 +1124,7 @@ async function bootstrap() {
|
|
|
1115
1124
|
}
|
|
1116
1125
|
return reply.callNotFound();
|
|
1117
1126
|
}
|
|
1118
|
-
reply.header('cache-control',
|
|
1127
|
+
reply.header('cache-control', (0, spa_static_1.spaShellCacheControl)());
|
|
1119
1128
|
return reply.type('text/html').send(fs.createReadStream(indexPath));
|
|
1120
1129
|
});
|
|
1121
1130
|
fastify.get('/*', async (request, reply) => {
|
|
@@ -1143,7 +1152,7 @@ async function bootstrap() {
|
|
|
1143
1152
|
}
|
|
1144
1153
|
return reply.callNotFound();
|
|
1145
1154
|
}
|
|
1146
|
-
reply.header('cache-control',
|
|
1155
|
+
reply.header('cache-control', (0, spa_static_1.spaShellCacheControl)());
|
|
1147
1156
|
return reply.type('text/html').send(fs.createReadStream(indexPath));
|
|
1148
1157
|
});
|
|
1149
1158
|
const resolveAdminUi = async () => {
|
|
@@ -1282,6 +1291,23 @@ async function bootstrap() {
|
|
|
1282
1291
|
meta: { error: err instanceof Error ? err.message : String(err) },
|
|
1283
1292
|
});
|
|
1284
1293
|
}
|
|
1294
|
+
// ONE COPY PER NODE — the sweep, at the only moment it is safe (D45, task 32).
|
|
1295
|
+
//
|
|
1296
|
+
// The boot above is confirmed healthy and the hub is serving, so the legacy
|
|
1297
|
+
// `<dataDir>/framework` tree and any host-provided package under the addon
|
|
1298
|
+
// root are redundant: nothing loads them, and each is a resolution race that
|
|
1299
|
+
// has already cost this project three wrong diagnoses in one evening.
|
|
1300
|
+
//
|
|
1301
|
+
// It runs HERE and not in the launcher because "healthy" is the gate. Before
|
|
1302
|
+
// the hub is up, the copies this removes are the recovery path — the image
|
|
1303
|
+
// seeds them for exactly the boot that would otherwise fail. The agent runs
|
|
1304
|
+
// the SAME function at its own confirmation point; neither role owns a
|
|
1305
|
+
// private idea of what may be deleted.
|
|
1306
|
+
void (0, single_copy_cleanup_runner_js_1.runSingleCopyCleanup)({
|
|
1307
|
+
dataDir: dataPath,
|
|
1308
|
+
addonRoot: process.env['CAMSTACK_ADDONS_DIR'] ?? path.join(dataPath, 'addons'),
|
|
1309
|
+
log: (line) => logger.info(line),
|
|
1310
|
+
});
|
|
1285
1311
|
// One-time backfill: stamp integrationId on devices created before the
|
|
1286
1312
|
// device-manager forwarder started stamping it (legacy camera providers),
|
|
1287
1313
|
// so deleting their integration cascades them. Idempotent — only touches
|
|
@@ -38,6 +38,7 @@ exports.hostProvidedSearchRoots = hostProvidedSearchRoots;
|
|
|
38
38
|
exports.seedNodeModulesRoots = seedNodeModulesRoots;
|
|
39
39
|
exports.inventoryHostProvided = inventoryHostProvided;
|
|
40
40
|
exports.formatInventoryReport = formatInventoryReport;
|
|
41
|
+
exports.formatShadowingNotes = formatShadowingNotes;
|
|
41
42
|
/**
|
|
42
43
|
* Boot-time inventory of host-provided packages — enforcement step 2 of
|
|
43
44
|
* [D45](../../../docs/decisions/adr-0045.md).
|
|
@@ -54,6 +55,16 @@ exports.formatInventoryReport = formatInventoryReport;
|
|
|
54
55
|
* This answers the other half — "what else is lying around that could have
|
|
55
56
|
* run" — which is the question a resolved path alone cannot.
|
|
56
57
|
*
|
|
58
|
+
* WHERE a copy sits decides whether it is noise or a violation. Two of the four
|
|
59
|
+
* copies above are EXPECTED: the `/opt` seed trees are baked into the image and
|
|
60
|
+
* deliberately shadowed by the `/data/server-root/current` deposit on every
|
|
61
|
+
* `applyServerUpdate`. Counting them as a violation printed the banner at every
|
|
62
|
+
* boot on every node, which taught the operator to scroll past the one boot
|
|
63
|
+
* where it mattered — the exact failure D45 exists to prevent. So the detector
|
|
64
|
+
* classifies locations and screams only for a copy nobody deposits on purpose,
|
|
65
|
+
* or for two copies that could BOTH be the one that runs. The expected
|
|
66
|
+
* shadowing gets one compact line instead.
|
|
67
|
+
*
|
|
57
68
|
* Everything here is pure: the caller injects the three filesystem operations,
|
|
58
69
|
* so the whole inventory is testable against a described layout rather than a
|
|
59
70
|
* real disk.
|
|
@@ -78,24 +89,39 @@ exports.HOST_PROVIDED_PACKAGES = [
|
|
|
78
89
|
* to a handful of stat calls at boot. `/data/addons` is a root in its own right
|
|
79
90
|
* because the bootstrap-install layout puts the package DIRECTLY there
|
|
80
91
|
* (`/data/addons/@camstack/system`), not under a `node_modules`.
|
|
92
|
+
*
|
|
93
|
+
* A `NODE_PATH` entry is classified by WHICH directory it names, not by being
|
|
94
|
+
* on `NODE_PATH`: the launcher puts the deposit and the seeds there itself, so
|
|
95
|
+
* "it is on NODE_PATH" would mark the whole fleet unexpected.
|
|
81
96
|
*/
|
|
82
97
|
function hostProvidedSearchRoots(input) {
|
|
83
98
|
const fromNodePath = (input.nodePath ?? '')
|
|
84
99
|
.split(input.pathSeparator)
|
|
85
100
|
.map((p) => p.trim())
|
|
86
101
|
.filter((p) => p.length > 0);
|
|
102
|
+
const seedPaths = new Set(input.seedRoots);
|
|
103
|
+
const expectationFor = (dir) => {
|
|
104
|
+
if (dir === input.serverNodeModules)
|
|
105
|
+
return 'active';
|
|
106
|
+
if (seedPaths.has(dir))
|
|
107
|
+
return 'seed';
|
|
108
|
+
return 'unexpected';
|
|
109
|
+
};
|
|
87
110
|
const roots = [
|
|
88
|
-
input.serverNodeModules,
|
|
89
|
-
...fromNodePath,
|
|
90
|
-
path.join(input.dataDir, 'framework', 'node_modules'),
|
|
91
|
-
path.join(input.dataDir, 'addons'),
|
|
92
|
-
...input.seedRoots,
|
|
111
|
+
{ path: input.serverNodeModules, expectation: 'active' },
|
|
112
|
+
...fromNodePath.map((p) => ({ path: p, expectation: expectationFor(p) })),
|
|
113
|
+
{ path: path.join(input.dataDir, 'framework', 'node_modules'), expectation: 'unexpected' },
|
|
114
|
+
{ path: path.join(input.dataDir, 'addons'), expectation: 'unexpected' },
|
|
115
|
+
...input.seedRoots.map((p) => ({ path: p, expectation: expectationFor(p) })),
|
|
93
116
|
];
|
|
117
|
+
// First occurrence wins, and the deposit is listed first — so a directory
|
|
118
|
+
// that is both the closure and a seed keeps the classification that decides
|
|
119
|
+
// whether it runs.
|
|
94
120
|
const seen = new Set();
|
|
95
121
|
return roots.filter((r) => {
|
|
96
|
-
if (seen.has(r))
|
|
122
|
+
if (seen.has(r.path))
|
|
97
123
|
return false;
|
|
98
|
-
seen.add(r);
|
|
124
|
+
seen.add(r.path);
|
|
99
125
|
return true;
|
|
100
126
|
});
|
|
101
127
|
}
|
|
@@ -136,45 +162,106 @@ function inventoryHostProvided(roots, fs, packages = exports.HOST_PROVIDED_PACKA
|
|
|
136
162
|
// them two would cry wolf on a node that is actually compliant.
|
|
137
163
|
const seenReal = new Set();
|
|
138
164
|
for (const root of roots) {
|
|
139
|
-
const dir = path.join(root, pkg);
|
|
165
|
+
const dir = path.join(root.path, pkg);
|
|
140
166
|
if (!fs.exists(dir))
|
|
141
167
|
continue;
|
|
142
168
|
const real = fs.realPath(dir);
|
|
143
169
|
if (seenReal.has(real))
|
|
144
170
|
continue;
|
|
145
171
|
seenReal.add(real);
|
|
146
|
-
copies.push({ pkg, path: dir, version: fs.readVersion(dir) });
|
|
172
|
+
copies.push({ pkg, path: dir, version: fs.readVersion(dir), location: root.expectation });
|
|
147
173
|
}
|
|
148
174
|
}
|
|
149
|
-
const
|
|
175
|
+
const byPkg = new Map();
|
|
150
176
|
for (const c of copies)
|
|
151
|
-
|
|
152
|
-
const
|
|
153
|
-
.
|
|
154
|
-
.
|
|
155
|
-
|
|
156
|
-
|
|
177
|
+
byPkg.set(c.pkg, [...(byPkg.get(c.pkg) ?? []), c]);
|
|
178
|
+
const inventories = [...byPkg.entries()]
|
|
179
|
+
.map(([pkg, found]) => classifyPackage(pkg, found))
|
|
180
|
+
.sort((a, b) => a.pkg.localeCompare(b.pkg));
|
|
181
|
+
return {
|
|
182
|
+
copies,
|
|
183
|
+
packages: inventories,
|
|
184
|
+
violations: inventories.filter((p) => p.violation).map((p) => p.pkg),
|
|
185
|
+
rootsScanned: roots.map((r) => r.path),
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Sort one package's copies into what each one means.
|
|
190
|
+
*
|
|
191
|
+
* A deposit copy shadows every seed — that is what `applyServerUpdate` does on
|
|
192
|
+
* every release, so those seeds are not candidates for anything. With no
|
|
193
|
+
* deposit copy the seeds ARE the candidates, and two of them is the resolution
|
|
194
|
+
* race D45 was written about.
|
|
195
|
+
*/
|
|
196
|
+
function classifyPackage(pkg, found) {
|
|
197
|
+
const active = found.filter((c) => c.location === 'active');
|
|
198
|
+
const seeds = found.filter((c) => c.location === 'seed');
|
|
199
|
+
const unexpected = found.filter((c) => c.location === 'unexpected');
|
|
200
|
+
const activeCandidates = active.length > 0 ? active : seeds;
|
|
201
|
+
const shadowedSeeds = active.length > 0 ? seeds : [];
|
|
202
|
+
return {
|
|
203
|
+
pkg,
|
|
204
|
+
copies: found,
|
|
205
|
+
activeCandidates,
|
|
206
|
+
shadowedSeeds,
|
|
207
|
+
unexpected,
|
|
208
|
+
violation: unexpected.length > 0 || activeCandidates.length > 1,
|
|
209
|
+
};
|
|
157
210
|
}
|
|
158
211
|
/**
|
|
159
|
-
* The loud report. Empty string
|
|
160
|
-
*
|
|
212
|
+
* The loud report. Empty string unless a copy is somewhere nobody deposits on
|
|
213
|
+
* purpose, or two copies could both be the one that runs — an expected seed
|
|
214
|
+
* under the deposit is the fleet's normal shape and reports nothing here, or
|
|
215
|
+
* the banner would print on every boot of every node and be read on none.
|
|
161
216
|
*/
|
|
162
217
|
function formatInventoryReport(result) {
|
|
163
|
-
|
|
218
|
+
const violating = result.packages.filter((p) => p.violation);
|
|
219
|
+
if (violating.length === 0)
|
|
164
220
|
return '';
|
|
165
221
|
const lines = [
|
|
166
|
-
'D45 violation — a host-provided package
|
|
167
|
-
'
|
|
168
|
-
'
|
|
222
|
+
'D45 violation — a host-provided package sits somewhere it must not, or two',
|
|
223
|
+
'copies could both be the one that runs. Which copy runs is then a',
|
|
224
|
+
'module-resolution outcome, and every version this node reports is a claim',
|
|
225
|
+
'about one of them rather than about the node.',
|
|
169
226
|
'',
|
|
170
227
|
];
|
|
171
|
-
for (const
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
lines.push(` ${c.version ?? '<unreadable package.json>'} ${c.path}`);
|
|
228
|
+
for (const inv of violating) {
|
|
229
|
+
lines.push(` ${inv.pkg} — ${inv.copies.length} copies:`);
|
|
230
|
+
for (const c of inv.copies) {
|
|
231
|
+
lines.push(` ${c.version ?? '<unreadable package.json>'} [${describeLocation(c, inv)}] ${c.path}`);
|
|
176
232
|
}
|
|
177
233
|
}
|
|
178
234
|
lines.push('', ` roots scanned: ${result.rootsScanned.join(', ')}`);
|
|
179
235
|
return lines.join('\n');
|
|
180
236
|
}
|
|
237
|
+
/** Why this one copy is (or is not) part of the problem, in one word or two. */
|
|
238
|
+
function describeLocation(copy, inv) {
|
|
239
|
+
if (copy.location === 'unexpected')
|
|
240
|
+
return 'UNEXPECTED — remove it';
|
|
241
|
+
if (inv.activeCandidates.includes(copy)) {
|
|
242
|
+
return inv.activeCandidates.length > 1 ? 'CONFLICTS — could run' : 'active';
|
|
243
|
+
}
|
|
244
|
+
return 'image seed, shadowed';
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* The quiet half: the expected seed shadowing, one compact line per package.
|
|
248
|
+
*
|
|
249
|
+
* Deliberately not a banner and deliberately not phrased as a fault — the image
|
|
250
|
+
* bakes these trees and the deposit shadows them on every release. A package
|
|
251
|
+
* already named in {@link formatInventoryReport} is skipped: the banner lists
|
|
252
|
+
* all of its copies, and a reassuring line beside it would read as verification.
|
|
253
|
+
*/
|
|
254
|
+
function formatShadowingNotes(result) {
|
|
255
|
+
return result.packages
|
|
256
|
+
.filter((p) => !p.violation && p.shadowedSeeds.length > 0)
|
|
257
|
+
.map((p) => {
|
|
258
|
+
const seedVersions = [...new Set(p.shadowedSeeds.map(describeVersion))].join(', ');
|
|
259
|
+
const active = p.activeCandidates[0];
|
|
260
|
+
const activeVersion = active === undefined ? 'unknown' : describeVersion(active);
|
|
261
|
+
const activePath = active === undefined ? '' : ` (${active.path})`;
|
|
262
|
+
return `${p.pkg} — seed ${seedVersions} shadowed by active ${activeVersion}${activePath}`;
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
function describeVersion(copy) {
|
|
266
|
+
return copy.version ?? '<unreadable package.json>';
|
|
267
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
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.runSingleCopyCleanup = runSingleCopyCleanup;
|
|
37
|
+
/**
|
|
38
|
+
* The one adapter that runs the post-boot single-copy sweep on BOTH roles.
|
|
39
|
+
*
|
|
40
|
+
* Operator, 2026-08-13: *"l'agente deve essere identico all'hub"*. Two code
|
|
41
|
+
* paths that must agree get one implementation — the hub calls this after its
|
|
42
|
+
* boot-health confirmation, the agent after its first acked `registerNode`, and
|
|
43
|
+
* neither owns a private idea of what may be deleted.
|
|
44
|
+
*
|
|
45
|
+
* Everything decidable lives in `single-copy-cleanup.ts` and is pure. This file
|
|
46
|
+
* only reads the real world: the active-root env, where `@camstack/system`
|
|
47
|
+
* actually resolved, the effective `NODE_PATH`, and the disk.
|
|
48
|
+
*
|
|
49
|
+
* It never throws. A sweep that fails leaves a node with an extra directory; a
|
|
50
|
+
* sweep that ends a process is an outage, and the copies it removes exist
|
|
51
|
+
* precisely because a node once could not boot.
|
|
52
|
+
*/
|
|
53
|
+
const fs = __importStar(require("node:fs"));
|
|
54
|
+
const path = __importStar(require("node:path"));
|
|
55
|
+
const single_copy_cleanup_js_1 = require("./single-copy-cleanup.js");
|
|
56
|
+
const EMPTY_RESULT = { removed: [], failed: [], refused: true };
|
|
57
|
+
/**
|
|
58
|
+
* Discover, plan, report, sweep. Called only from a point where the role has
|
|
59
|
+
* already confirmed its boot healthy — that confirmation IS the gate, and it is
|
|
60
|
+
* re-stated as `bootHealthy: true` here so the pure planner can be exercised
|
|
61
|
+
* from both sides in a test.
|
|
62
|
+
*/
|
|
63
|
+
async function runSingleCopyCleanup(opts) {
|
|
64
|
+
try {
|
|
65
|
+
if (!(0, single_copy_cleanup_js_1.isCleanupEnabled)(process.env)) {
|
|
66
|
+
opts.log('single-copy cleanup disabled by CAMSTACK_SINGLE_COPY_CLEANUP — nothing removed');
|
|
67
|
+
return EMPTY_RESULT;
|
|
68
|
+
}
|
|
69
|
+
const candidates = (0, single_copy_cleanup_js_1.discoverRedundantCopies)({
|
|
70
|
+
dataDir: opts.dataDir,
|
|
71
|
+
addonRoot: opts.addonRoot,
|
|
72
|
+
closureProvides: (pkg) => {
|
|
73
|
+
try {
|
|
74
|
+
require.resolve(`${pkg}/package.json`);
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
}, {
|
|
82
|
+
exists: (p) => fs.existsSync(p),
|
|
83
|
+
readVersion: (dir) => readVersion(dir),
|
|
84
|
+
});
|
|
85
|
+
const plan = (0, single_copy_cleanup_js_1.planSingleCopyCleanup)({
|
|
86
|
+
activeRoot: process.env['CAMSTACK_SERVER_ACTIVE_ROOT'] ?? null,
|
|
87
|
+
closureResolvedFrom: resolveSystemPath(),
|
|
88
|
+
bootHealthy: true,
|
|
89
|
+
nodePathEntries: (process.env['NODE_PATH'] ?? '')
|
|
90
|
+
.split(process.platform === 'win32' ? ';' : ':')
|
|
91
|
+
.map((entry) => entry.trim())
|
|
92
|
+
.filter((entry) => entry.length > 0),
|
|
93
|
+
candidates,
|
|
94
|
+
});
|
|
95
|
+
for (const line of (0, single_copy_cleanup_js_1.formatCleanupPlan)(plan))
|
|
96
|
+
opts.log(line);
|
|
97
|
+
return await (0, single_copy_cleanup_js_1.executeSingleCopyCleanup)(plan, {
|
|
98
|
+
exists: (p) => fs.existsSync(p),
|
|
99
|
+
rename: (from, to) => fs.renameSync(from, to),
|
|
100
|
+
remove: async (p) => {
|
|
101
|
+
await fs.promises.rm(p, { recursive: true, force: true });
|
|
102
|
+
},
|
|
103
|
+
}, opts.log);
|
|
104
|
+
}
|
|
105
|
+
catch (err) {
|
|
106
|
+
opts.log(`single-copy cleanup failed: ${err instanceof Error ? err.message : String(err)} — nothing was removed`);
|
|
107
|
+
return EMPTY_RESULT;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
/** Where this process actually resolved `@camstack/system`, or `null`. */
|
|
111
|
+
function resolveSystemPath() {
|
|
112
|
+
try {
|
|
113
|
+
return require.resolve('@camstack/system/package.json');
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
function readVersion(packageDir) {
|
|
120
|
+
try {
|
|
121
|
+
const raw = JSON.parse(fs.readFileSync(path.join(packageDir, 'package.json'), 'utf-8'));
|
|
122
|
+
const version = typeof raw === 'object' && raw !== null ? raw.version : undefined;
|
|
123
|
+
return typeof version === 'string' ? version : null;
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
}
|