@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/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 framework_nodepath_js_1 = require("./framework-nodepath.js");
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
- const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
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 derive bootstrap list from package.json: ${err instanceof Error ? err.message : String(err)}`);
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
- // Reported, not fatal. Every node in the fleet currently HAS extra copies —
476
- // the image seeds under /opt and the legacy /data/framework — and D45 records
477
- // their removal as a deliberate, scheduled migration with a container
478
- // recreate as the escape hatch, not something a routine boot should enact by
479
- // refusing to start. `CAMSTACK_INVENTORY_STRICT=1` makes it fatal, which is
480
- // how a layout that HAS been cleaned keeps itself clean.
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. Making the
536
- // second location LOUD is the whole job; removing it stays an operator
537
- // decision. `CAMSTACK_INVENTORY_STRICT=1` makes it fatal, which is how a
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
- return reply.type('text/html').send(fs.createReadStream(spaIndexHtml));
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', 'no-cache, must-revalidate');
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', 'no-cache, must-revalidate');
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 countByPkg = new Map();
175
+ const byPkg = new Map();
150
176
  for (const c of copies)
151
- countByPkg.set(c.pkg, (countByPkg.get(c.pkg) ?? 0) + 1);
152
- const duplicated = [...countByPkg.entries()]
153
- .filter(([, n]) => n > 1)
154
- .map(([pkg]) => pkg)
155
- .sort();
156
- return { copies, duplicated, rootsScanned: roots };
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 when the node is clean a compliant boot says
160
- * nothing, so the one that isn't stands out instead of scrolling past.
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
- if (result.duplicated.length === 0)
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 exists more than once on this node.',
167
- 'Which copy runs is a module-resolution outcome, and every version this node',
168
- 'reports is a claim about one of them rather than about the node.',
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 pkg of result.duplicated) {
172
- const found = result.copies.filter((c) => c.pkg === pkg);
173
- lines.push(` ${pkg} ${found.length} copies:`);
174
- for (const c of found) {
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
+ }