@camstack/system 1.2.15 → 1.2.17

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 (57) hide show
  1. package/dist/addon-runner.js +2 -10
  2. package/dist/addon-runner.mjs +3 -11
  3. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.js +1 -1
  4. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.mjs +1 -1
  5. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.js +1 -1
  6. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.mjs +1 -1
  7. package/dist/builtins/alerts/alerts.addon.js +1 -1
  8. package/dist/builtins/alerts/alerts.addon.mjs +1 -1
  9. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.js +73 -2
  10. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.mjs +73 -2
  11. package/dist/builtins/console-logging/index.js +1 -1
  12. package/dist/builtins/console-logging/index.mjs +1 -1
  13. package/dist/builtins/device-manager/device-manager.addon.js +1 -1
  14. package/dist/builtins/device-manager/device-manager.addon.mjs +1 -1
  15. package/dist/builtins/doorbell/virtual-doorbell.addon.js +1 -1
  16. package/dist/builtins/doorbell/virtual-doorbell.addon.mjs +1 -1
  17. package/dist/builtins/hub-forwarder/index.js +1 -1
  18. package/dist/builtins/hub-forwarder/index.mjs +1 -1
  19. package/dist/builtins/local-auth/local-auth.addon.js +1 -1
  20. package/dist/builtins/local-auth/local-auth.addon.mjs +3 -38
  21. package/dist/builtins/local-network/local-network.addon.js +1 -1
  22. package/dist/builtins/local-network/local-network.addon.mjs +1 -1
  23. package/dist/builtins/loki-logging/index.js +1 -1
  24. package/dist/builtins/loki-logging/index.mjs +1 -1
  25. package/dist/builtins/native-metrics/native-metrics.addon.js +1 -1
  26. package/dist/builtins/native-metrics/native-metrics.addon.mjs +2 -2
  27. package/dist/builtins/platform-probe/index.js +2 -2
  28. package/dist/builtins/platform-probe/index.mjs +3 -2
  29. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.js +1 -1
  30. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.mjs +1 -1
  31. package/dist/builtins/snapshot/index.js +1 -1
  32. package/dist/builtins/snapshot/index.mjs +1 -1
  33. package/dist/builtins/sqlite-storage/filesystem-storage.addon.js +1 -1
  34. package/dist/builtins/sqlite-storage/filesystem-storage.addon.mjs +1 -1
  35. package/dist/builtins/sqlite-storage/sqlite-settings.addon.js +1 -1
  36. package/dist/builtins/sqlite-storage/sqlite-settings.addon.mjs +1 -1
  37. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.js +1 -1
  38. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.mjs +1 -1
  39. package/dist/builtins/system-backup/system-backup.service.d.ts +14 -1
  40. package/dist/builtins/system-config/system-config.addon.js +1 -1
  41. package/dist/builtins/system-config/system-config.addon.mjs +1 -1
  42. package/dist/builtins/winston-logging/index.js +1 -1
  43. package/dist/builtins/winston-logging/index.mjs +1 -1
  44. package/dist/chunk-CNf5ZN-e.mjs +37 -0
  45. package/dist/{dist-eOaraBhT.mjs → dist-DmQ6thmF.mjs} +10418 -10231
  46. package/dist/{dist-yttC1aUI.js → dist-OQ1yU92D.js} +10477 -10296
  47. package/dist/framework-resolver-hook.mjs +2 -0
  48. package/dist/index.js +224 -32
  49. package/dist/index.mjs +227 -34
  50. package/dist/kernel/addon-installer.d.ts +86 -7
  51. package/dist/kernel/fs-utils.d.ts +18 -5
  52. package/dist/lib-5THG9eKz.mjs +15545 -0
  53. package/dist/lib-CZ3g2WTS.js +15549 -0
  54. package/dist/{manifest-python-deps-bn3qrsxR.mjs → manifest-python-deps-B30qVAuW.mjs} +1 -2
  55. package/dist/{manifest-python-deps-DSrmih6E.js → manifest-python-deps-CnRW2Utv.js} +2 -3
  56. package/dist/{resource-monitor-Le3l0qS2.mjs → resource-monitor-BkP504Vq.mjs} +1 -1
  57. package/package.json +1 -1
@@ -19,6 +19,8 @@ const HOST_EXTERNAL_SPECIFIERS = [
19
19
  // in sync with host-externals.ts (a spec pins both).
20
20
  'sharp',
21
21
  'node-av',
22
+ 'node-pty',
23
+ 'ssh2',
22
24
  ]
23
25
 
24
26
  function isHostExternal(specifier) {
package/dist/index.js CHANGED
@@ -1,8 +1,8 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
2
  const require_chunk = require("./chunk-Cek0wNdY.js");
3
- const require_dist = require("./dist-yttC1aUI.js");
3
+ const require_dist = require("./dist-OQ1yU92D.js");
4
4
  const require_model_download_service = require("./model-download-service-hf0ookyy.js");
5
- const require_manifest_python_deps = require("./manifest-python-deps-DSrmih6E.js");
5
+ const require_manifest_python_deps = require("./manifest-python-deps-CnRW2Utv.js");
6
6
  const require_resource_monitor = require("./resource-monitor-DNNomR-i.js");
7
7
  const require_builtins_sqlite_storage_filesystem_storage_addon = require("./builtins/sqlite-storage/filesystem-storage.addon.js");
8
8
  const require_builtins_sqlite_storage_sqlite_settings_addon = require("./builtins/sqlite-storage/sqlite-settings.addon.js");
@@ -2733,21 +2733,26 @@ async function copyDirRecursive(src, dest) {
2733
2733
  });
2734
2734
  }
2735
2735
  /**
2736
- * Runtime natives that must stay installable even when an addon forgets to
2737
- * declare them: the mirror of the build preset's `COMMON_NATIVE_DEPS`
2738
- * (tools/build/vite-lib.preset.ts — build tooling, not loadable at runtime).
2739
- * These are the ONLY packages a self-contained dist genuinely `require()`s
2740
- * from node_modules; everything else is already inlined by the bundle.
2736
+ * Runtime deps a stripped addon package.json must KEEP so the installer still
2737
+ * `npm install`s them into the addon dir.
2738
+ *
2739
+ * Empty by design: every native an addon can import is now HOST-EXTERNAL
2740
+ * (node-av, sharp, node-pty resolved from the framework closure via the
2741
+ * resolver hook, see host-externals.ts), and every pure-JS lib (ssh2, werift,
2742
+ * @simplewebauthn, mqtt, aws-sdk, …) is inlined by the self-contained vite
2743
+ * bundle. So a self-contained dist requires NOTHING from a local node_modules —
2744
+ * `addons/` stays node_modules-free, which is what lets the backup tar the whole
2745
+ * folder with no exclusion list (see ADR-0027). An addon that genuinely needs an
2746
+ * un-bundleable runtime dep declares it explicitly in
2747
+ * `camstack.runtimeDependencies`; the debug escape hatch
2748
+ * `CAMSTACK_INSTALL_FULL_DEPS=1` restores the legacy install-everything path.
2749
+ *
2750
+ * (Historically this mirrored the build preset's COMMON_NATIVE_DEPS and
2751
+ * per-addon-installed node-av/sharp/onnxruntime-node/ssh2/werift/systeminformation
2752
+ * — 74k files / 1.34 GB of redundant node_modules on the live hub. That redundancy
2753
+ * is removed: the natives come from the framework, the pure-JS from the bundle.)
2741
2754
  */
2742
- var RUNTIME_NATIVE_KEEP = [
2743
- "better-sqlite3",
2744
- "node-av",
2745
- "onnxruntime-node",
2746
- "sharp",
2747
- "ssh2",
2748
- "werift",
2749
- "systeminformation"
2750
- ];
2755
+ var RUNTIME_NATIVE_KEEP = [];
2751
2756
  /** Read `camstack.runtimeDependencies` (extra non-native deps the dist
2752
2757
  * requires at runtime — versions come from the manifest's own dep maps). */
2753
2758
  function readRuntimeDependencies(pkg) {
@@ -3934,10 +3939,14 @@ var AddonInstaller = class AddonInstaller {
3934
3939
  * 1. Rename current install dir → {addonsDir}/.backups/{name}/{ts}/
3935
3940
  * 2. Run a fresh install (npm or local depending on installSource)
3936
3941
  * 3. Record `lastBackupDir` in the manifest entry
3942
+ * 4. Prune every OTHER backup of this addon — the previous one just
3943
+ * lost its only pointer and would otherwise be orphaned forever.
3937
3944
  *
3938
- * The post-install caller (kernel.restartAddon + health-check) is
3939
- * responsible for verifying the new version actually works and for
3940
- * triggering `rollbackAddon()` if the health check fails.
3945
+ * The post-install caller (restart + health-check) is responsible for
3946
+ * verifying the new version actually works, and for calling either
3947
+ * `clearBackup()` on success or `rollbackAddon()` on failure. This
3948
+ * method must NOT delete the backup itself: at this point the new
3949
+ * version has not been proven to work.
3941
3950
  *
3942
3951
  * Throws if there is no installed version to update — caller should
3943
3952
  * prefer plain `install()` for first-time installs.
@@ -3990,6 +3999,7 @@ var AddonInstaller = class AddonInstaller {
3990
3999
  source: this.manifest.get(packageName)?.source ?? "npm",
3991
4000
  lastBackupDir: backupDir
3992
4001
  });
4002
+ await this.pruneBackups(packageName, backupDir);
3993
4003
  return {
3994
4004
  name: result.name,
3995
4005
  version: result.version,
@@ -4060,6 +4070,7 @@ var AddonInstaller = class AddonInstaller {
4060
4070
  source: this.manifest.get(packageName)?.source ?? "npm",
4061
4071
  lastBackupDir: backupDir
4062
4072
  });
4073
+ await this.pruneBackups(packageName, backupDir);
4063
4074
  return {
4064
4075
  name: packageName,
4065
4076
  version,
@@ -4106,23 +4117,204 @@ var AddonInstaller = class AddonInstaller {
4106
4117
  this.logger.info(`${packageName} — rolled back to v${restoredVersion}`);
4107
4118
  return restoredVersion;
4108
4119
  }
4120
+ /** Root of the pre-update rollback copies: `{addonsDir}/.backups`. */
4121
+ get backupsRoot() {
4122
+ return node_path.join(this.addonsDir, ".backups");
4123
+ }
4124
+ /**
4125
+ * True only when `target` resolves to something strictly INSIDE
4126
+ * `{addonsDir}/.backups/`.
4127
+ *
4128
+ * Every deletion in this section funnels through here. `lastBackupDir`
4129
+ * is read back from a manifest file on disk — an operator edit, a
4130
+ * restored manifest from another host, or a `..` in the recorded path
4131
+ * must never turn "reclaim a backup" into "delete the live install".
4132
+ */
4133
+ isInsideBackups(target) {
4134
+ const rel = node_path.relative(node_path.resolve(this.backupsRoot), node_path.resolve(target));
4135
+ return rel !== "" && !rel.startsWith("..") && !node_path.isAbsolute(rel);
4136
+ }
4109
4137
  /**
4110
- * Drop the recorded backup for an addon (e.g. after a successful
4111
- * post-update health check confirms the new version is healthy).
4112
- * Frees disk space without affecting the running install.
4138
+ * Best-effort removal of one path under `.backups/`.
4139
+ *
4140
+ * Never throws: an undeleted backup is a disk-space problem, whereas a
4141
+ * throw on the post-update success path would turn a working update into
4142
+ * a reported outage. Returns true only when the path is gone.
4143
+ */
4144
+ async removeBackupPath(target, reason) {
4145
+ if (!this.isInsideBackups(target)) {
4146
+ this.logger.error("Refusing to remove a backup path outside .backups/", { meta: {
4147
+ target,
4148
+ backupsRoot: this.backupsRoot,
4149
+ reason
4150
+ } });
4151
+ return false;
4152
+ }
4153
+ try {
4154
+ await node_fs.promises.rm(target, {
4155
+ recursive: true,
4156
+ force: true
4157
+ });
4158
+ return true;
4159
+ } catch (err) {
4160
+ this.logger.warn("Backup removal failed; leaving residue for the next boot sweep", { meta: {
4161
+ target,
4162
+ reason,
4163
+ error: require_dist.errMsg(err)
4164
+ } });
4165
+ return false;
4166
+ }
4167
+ }
4168
+ /** List the timestamped backup dirs recorded for one package. */
4169
+ listBackupsFor(packageName) {
4170
+ const dir = node_path.join(this.backupsRoot, packageName);
4171
+ try {
4172
+ return node_fs.readdirSync(dir, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => node_path.join(dir, e.name)).sort();
4173
+ } catch {
4174
+ return [];
4175
+ }
4176
+ }
4177
+ /**
4178
+ * Keep at most ONE backup per addon: drop every backup dir for
4179
+ * `packageName` except `keepDir`.
4180
+ *
4181
+ * Called right after a new backup is recorded. Without this, a second
4182
+ * update overwrites `lastBackupDir` and the previous backup becomes
4183
+ * unreachable — no pointer, so neither `rollbackAddon` nor `clearBackup`
4184
+ * can ever see it again. That is how the live hub accumulated two 398 MiB
4185
+ * copies of one addon. Never throws.
4186
+ */
4187
+ async pruneBackups(packageName, keepDir) {
4188
+ const keep = keepDir == null ? null : node_path.resolve(keepDir);
4189
+ const removed = [];
4190
+ for (const candidate of this.listBackupsFor(packageName)) {
4191
+ if (keep != null && node_path.resolve(candidate) === keep) continue;
4192
+ if (await this.removeBackupPath(candidate, "superseded-backup")) removed.push(candidate);
4193
+ }
4194
+ if (removed.length > 0) this.logger.info(`${packageName} — pruned ${removed.length} superseded backup(s)`);
4195
+ await this.removeEmptyBackupDirs(packageName);
4196
+ return removed;
4197
+ }
4198
+ /** Drop `.backups/{pkg}` (and its `@scope` parent) once they are empty. */
4199
+ async removeEmptyBackupDirs(packageName) {
4200
+ const pkgDir = node_path.join(this.backupsRoot, packageName);
4201
+ for (const dir of [pkgDir, node_path.dirname(pkgDir)]) {
4202
+ if (!this.isInsideBackups(dir)) continue;
4203
+ try {
4204
+ await node_fs.promises.rmdir(dir);
4205
+ } catch {
4206
+ return;
4207
+ }
4208
+ }
4209
+ }
4210
+ /**
4211
+ * Drop the recorded backup for an addon — call once the post-update
4212
+ * health check confirms the new version actually works.
4213
+ *
4214
+ * Never throws and never rejects. The manifest pointer is cleared even
4215
+ * when the bytes could not be removed: a pointer at a half-deleted tree
4216
+ * would make `rollbackAddon` restore a gutted install, and the boot sweep
4217
+ * reclaims the residue on the next start.
4113
4218
  */
4114
- clearBackup(packageName) {
4219
+ async clearBackup(packageName) {
4115
4220
  const entry = this.manifest.get(packageName);
4116
4221
  if (entry?.lastBackupDir == null) return;
4117
- if (node_fs.existsSync(entry.lastBackupDir)) node_fs.rmSync(entry.lastBackupDir, {
4118
- recursive: true,
4119
- force: true
4120
- });
4121
- this.manifest.upsert(packageName, {
4122
- version: entry.version,
4123
- source: entry.source,
4124
- lastBackupDir: void 0
4125
- });
4222
+ if (await this.removeBackupPath(entry.lastBackupDir, "update-confirmed")) this.logger.info(`${packageName} — update confirmed, backup reclaimed`, { meta: { backupDir: entry.lastBackupDir } });
4223
+ try {
4224
+ this.manifest.upsert(packageName, {
4225
+ version: entry.version,
4226
+ source: entry.source,
4227
+ lastBackupDir: void 0
4228
+ });
4229
+ } catch (err) {
4230
+ this.logger.warn(`${packageName} — could not clear the backup pointer`, { meta: { error: require_dist.errMsg(err) } });
4231
+ }
4232
+ await this.removeEmptyBackupDirs(packageName);
4233
+ }
4234
+ /**
4235
+ * Boot-time retention sweep over `{addonsDir}/.backups/`.
4236
+ *
4237
+ * RULE: for each addon, the ONLY backup that survives is the one the
4238
+ * manifest's `lastBackupDir` points at — everything else is unreachable
4239
+ * by construction (no pointer ⇒ neither `rollbackAddon` nor `clearBackup`
4240
+ * can ever reach it). One exception: when the addon's LIVE install dir is
4241
+ * missing, the newest backup is the only surviving copy of that addon (a
4242
+ * crash between the rename-aside and the manifest write in `applyUpdate`),
4243
+ * so it is kept instead of reclaimed.
4244
+ *
4245
+ * Why not "delete everything once the addon is healthy": this installer
4246
+ * has no health signal, and the pointed-at backup is precisely the one a
4247
+ * failed update left for the operator's Rollback button. Bounding to one
4248
+ * per addon is enough — the observed 814 MiB was duplicate copies, not a
4249
+ * single retained rollback target.
4250
+ *
4251
+ * Never throws. Safe to run on every boot; runs before any update can be
4252
+ * in flight.
4253
+ */
4254
+ async sweepBackups() {
4255
+ const removed = [];
4256
+ const kept = [];
4257
+ for (const packageName of this.listBackedUpPackages()) {
4258
+ const backups = this.listBackupsFor(packageName);
4259
+ if (backups.length === 0) {
4260
+ await this.removeEmptyBackupDirs(packageName);
4261
+ continue;
4262
+ }
4263
+ const keep = this.resolveBackupToKeep(packageName, backups);
4264
+ for (const candidate of backups) {
4265
+ if (keep != null && node_path.resolve(candidate) === node_path.resolve(keep)) {
4266
+ kept.push(candidate);
4267
+ continue;
4268
+ }
4269
+ if (await this.removeBackupPath(candidate, "boot-sweep")) removed.push(candidate);
4270
+ }
4271
+ await this.removeEmptyBackupDirs(packageName);
4272
+ }
4273
+ if (removed.length > 0 || kept.length > 0) this.logger.info(`Backup sweep — reclaimed ${removed.length}, retained ${kept.length} rollback target(s)`);
4274
+ return {
4275
+ removed,
4276
+ kept
4277
+ };
4278
+ }
4279
+ /**
4280
+ * Which backup of `packageName` survives the sweep: the manifest's
4281
+ * rollback target, or — when the live install is gone — the newest copy,
4282
+ * which is then the only one that exists. `null` means "reclaim them all".
4283
+ */
4284
+ resolveBackupToKeep(packageName, backups) {
4285
+ const liveDir = node_path.join(this.addonsDir, packageName);
4286
+ if (!node_fs.existsSync(liveDir)) {
4287
+ const newest = backups[backups.length - 1] ?? null;
4288
+ if (newest != null) this.logger.warn(`${packageName} — live install dir missing; retaining its newest backup as the only surviving copy`, { meta: {
4289
+ liveDir,
4290
+ backupDir: newest
4291
+ } });
4292
+ return newest;
4293
+ }
4294
+ const pointer = this.manifest.get(packageName)?.lastBackupDir;
4295
+ if (pointer == null) return null;
4296
+ const resolved = node_path.resolve(pointer);
4297
+ return backups.some((b) => node_path.resolve(b) === resolved) ? pointer : null;
4298
+ }
4299
+ /** Package names that have a `.backups/{pkg}` directory, scoped or not. */
4300
+ listBackedUpPackages() {
4301
+ let entries;
4302
+ try {
4303
+ entries = node_fs.readdirSync(this.backupsRoot, { withFileTypes: true });
4304
+ } catch {
4305
+ return [];
4306
+ }
4307
+ const names = [];
4308
+ for (const entry of entries) {
4309
+ if (!entry.isDirectory()) continue;
4310
+ if (!entry.name.startsWith("@")) {
4311
+ names.push(entry.name);
4312
+ continue;
4313
+ }
4314
+ const scopeDir = node_path.join(this.backupsRoot, entry.name);
4315
+ for (const inner of node_fs.readdirSync(scopeDir, { withFileTypes: true })) if (inner.isDirectory()) names.push(`${entry.name}/${inner.name}`);
4316
+ }
4317
+ return names;
4126
4318
  }
4127
4319
  async uninstall(packageName) {
4128
4320
  const addonDir = node_path.join(this.addonsDir, packageName);
package/dist/index.mjs CHANGED
@@ -1,7 +1,8 @@
1
- import { At as scopeKey, B as logLevelAtMost, Ct as expandCapMethods, Dt as parseJsonUnknown$1, Et as parseJsonObject, F as isVoidInput, G as objectInputDeclaresAddonId, I as kebabToCamel, J as procedureAuthKey, L as lifecycleJobSchema, M as isArrayOutputSchema, Mt as EventCategory$1, N as isCollectionArrayMethod, Ot as readinessKey, P as isObjectInput, V as looseSchema, _t as asJsonObject$1, bt as createEvent, c as RUNTIME_DEFAULTS, ct as DATAPLANE_SECRET_HEADER$1, ht as ReadinessTimeoutError, k as extractNestedAddonId, kt as resolveCapMount, lt as DEVICE_SETTINGS_CONTRIBUTION_METHODS, m as addonSettingsCapability, mt as ReadinessRegistry, ot as errMsg$1, s as METHOD_ACCESS_MAP, t as ALL_CAPABILITY_DEFINITIONS, ut as DEVICE_STATUS_METHOD, vt as asNumber, xt as emitDownForOwnedCaps, yt as asString$1 } from "./dist-eOaraBhT.mjs";
1
+ import { a as __toCommonJS, i as __require, n as __esmMin, o as __toESM$1, r as __exportAll, t as __commonJSMin$1 } from "./chunk-CNf5ZN-e.mjs";
2
+ import { B as logLevelAtMost, Dt as readinessKey, Et as parseJsonUnknown$1, F as isVoidInput, I as kebabToCamel, L as lifecycleJobSchema, M as isArrayOutputSchema, N as isCollectionArrayMethod, Ot as resolveCapMount, P as isObjectInput, Tt as parseJsonObject, V as looseSchema, W as objectInputDeclaresAddonId, _t as asString$1, c as RUNTIME_DEFAULTS, ct as DEVICE_STATUS_METHOD, ft as ReadinessRegistry, gt as asNumber, ht as asJsonObject$1, it as errMsg$1, jt as EventCategory$1, k as extractNestedAddonId, kt as scopeKey, m as addonSettingsCapability, ot as DATAPLANE_SECRET_HEADER$1, pt as ReadinessTimeoutError, q as procedureAuthKey, s as METHOD_ACCESS_MAP, st as DEVICE_SETTINGS_CONTRIBUTION_METHODS, t as ALL_CAPABILITY_DEFINITIONS, vt as createEvent, xt as expandCapMethods, yt as emitDownForOwnedCaps } from "./dist-DmQ6thmF.mjs";
2
3
  import { a as downloadModel, c as getModelFilePath, d as contentTypeFor, f as createAuthenticatedFileServer, h as resolveFilePath, i as downloadFile, l as isModelDownloaded, m as parseTokenizedUrl, n as collectModelFiles, o as ensureModel, p as parseRangeHeader, r as deleteModelFromDisk, s as fetchJson, t as ModelDownloadService, u as createFileDataPlaneHandler } from "./model-download-service-Cp9f4dk6.mjs";
3
- import { $ as buildNativeCapProxy, A as createHubCapForwardService, B as AGENT_CAP_FWD_ACTION, C as brokerTransportLink, Ct as resolveAddonClass, D as localProviderLink, E as ipcParentLink, F as createUdsLogger, G as callWithServiceDiscovery, H as CapRouteResolver, I as createUdsLoggerWithControl, J as UdsLocalTransportServer, K as createLocalTransport, L as LocalChildClient, M as createUdsEventBridge, N as createUdsEventBus, O as HUB_CAP_FWD_ACTION, P as udsChildLogToWorkerEntry, Q as encodeFrame, R as LocalChildRegistry, S as brokerCallForCap, St as runNpm, T as ipcChildLink, U as CapRouteError, V as AGENT_CAP_FWD_SERVICE, W as classifyCapRoute, X as localEndpointPath, Y as SocketChannel, Z as FrameDecoder, _ as createKernelHwAccel, _t as CapabilityHandle, a as getWorkerDeviceRegistry, b as __resetCapUsageRegistryForTests, bt as installManifestNativeDeps, c as setHubConnected, ct as NATIVE_PROVIDER_SERVICE_INFIX, d as getBrokerEventBus, dt as capBareAction, et as buildUdsNativeCapProxy, f as getMoleculerEventStats, ft as capServiceName, g as AddonDepsManager, gt as DeviceRegistry, h as subscribePassthrough, ht as serializeTypedArrays, i as createUdsAddonContext, it as mountNativeCapService, j as createParentUnownedCallHandler, k as HUB_CAP_FWD_SERVICE, l as EVENT_TOPIC_PREFIX, lt as capActionName, m as setNodeEventInterest, mt as deserializeTypedArrays, n as adaptBrokerToCluster, o as getOrInitReadinessRegistry, ot as createAddonService, p as registerEventBusService, pt as parseCapAction, q as UdsLocalTransportClient, r as createAddonContext, s as getOrInitReadinessRegistryForClient, st as validateProviderRegistrations, t as installManifestPythonDeps, tt as createBrokerDeviceManagerApi, u as clusterEventTopic, ut as capActionSuffix, v as resolveHwAccel, vt as CapabilityUnavailableError, w as buildLinkChain, wt as createAddonDataPlaneFacility, x as getCapUsageRegistry, xt as resolveNpmInvocation, y as CapUsageRegistry, yt as copyBundledNativeModules, z as UDS_NO_ROUTE_PREFIX } from "./manifest-python-deps-bn3qrsxR.mjs";
4
- import { n as getSinglePidStats, t as getPidStats } from "./resource-monitor-Le3l0qS2.mjs";
4
+ import { $ as buildNativeCapProxy, A as createHubCapForwardService, B as AGENT_CAP_FWD_ACTION, C as brokerTransportLink, Ct as resolveAddonClass, D as localProviderLink, E as ipcParentLink, F as createUdsLogger, G as callWithServiceDiscovery, H as CapRouteResolver, I as createUdsLoggerWithControl, J as UdsLocalTransportServer, K as createLocalTransport, L as LocalChildClient, M as createUdsEventBridge, N as createUdsEventBus, O as HUB_CAP_FWD_ACTION, P as udsChildLogToWorkerEntry, Q as encodeFrame, R as LocalChildRegistry, S as brokerCallForCap, St as runNpm, T as ipcChildLink, U as CapRouteError, V as AGENT_CAP_FWD_SERVICE, W as classifyCapRoute, X as localEndpointPath, Y as SocketChannel, Z as FrameDecoder, _ as createKernelHwAccel, _t as CapabilityHandle, a as getWorkerDeviceRegistry, b as __resetCapUsageRegistryForTests, bt as installManifestNativeDeps, c as setHubConnected, ct as NATIVE_PROVIDER_SERVICE_INFIX, d as getBrokerEventBus, dt as capBareAction, et as buildUdsNativeCapProxy, f as getMoleculerEventStats, ft as capServiceName, g as AddonDepsManager, gt as DeviceRegistry, h as subscribePassthrough, ht as serializeTypedArrays, i as createUdsAddonContext, it as mountNativeCapService, j as createParentUnownedCallHandler, k as HUB_CAP_FWD_SERVICE, l as EVENT_TOPIC_PREFIX, lt as capActionName, m as setNodeEventInterest, mt as deserializeTypedArrays, n as adaptBrokerToCluster, o as getOrInitReadinessRegistry, ot as createAddonService, p as registerEventBusService, pt as parseCapAction, q as UdsLocalTransportClient, r as createAddonContext, s as getOrInitReadinessRegistryForClient, st as validateProviderRegistrations, t as installManifestPythonDeps, tt as createBrokerDeviceManagerApi, u as clusterEventTopic, ut as capActionSuffix, v as resolveHwAccel, vt as CapabilityUnavailableError, w as buildLinkChain, wt as createAddonDataPlaneFacility, x as getCapUsageRegistry, xt as resolveNpmInvocation, y as CapUsageRegistry, yt as copyBundledNativeModules, z as UDS_NO_ROUTE_PREFIX } from "./manifest-python-deps-B30qVAuW.mjs";
5
+ import { n as getSinglePidStats, t as getPidStats } from "./resource-monitor-BkP504Vq.mjs";
5
6
  import { FilesystemStorageAddon, t as FilesystemStorageProvider } from "./builtins/sqlite-storage/filesystem-storage.addon.mjs";
6
7
  import { SqliteSettingsAddon, t as SqliteSettingsBackend } from "./builtins/sqlite-storage/sqlite-settings.addon.mjs";
7
8
  import { ConfigStore, DeviceStore } from "./builtins/sqlite-storage/index.mjs";
@@ -17,7 +18,7 @@ import { AlertCenterAddon } from "./builtins/alerts/alerts.addon.mjs";
17
18
  import "./builtins/alerts/index.mjs";
18
19
  import { SystemConfigAddon } from "./builtins/system-config/system-config.addon.mjs";
19
20
  import "./builtins/system-config/index.mjs";
20
- import { LocalAuthAddon, a as require_ms, c as __esmMin, d as __toCommonJS, f as __toESM$1, i as AuthManager, l as __exportAll, n as ApiKeyManager, o as require_safe_buffer, r as UserManager, s as __commonJSMin$1, t as ScopedTokenManager, u as __require } from "./builtins/local-auth/local-auth.addon.mjs";
21
+ import { LocalAuthAddon, a as require_ms, i as AuthManager, n as ApiKeyManager, o as require_safe_buffer, r as UserManager, t as ScopedTokenManager } from "./builtins/local-auth/local-auth.addon.mjs";
21
22
  import "./builtins/local-auth/index.mjs";
22
23
  import { DeviceManagerAddon } from "./builtins/device-manager/device-manager.addon.mjs";
23
24
  import "./builtins/device-manager/index.mjs";
@@ -2725,21 +2726,26 @@ async function copyDirRecursive(src, dest) {
2725
2726
  });
2726
2727
  }
2727
2728
  /**
2728
- * Runtime natives that must stay installable even when an addon forgets to
2729
- * declare them: the mirror of the build preset's `COMMON_NATIVE_DEPS`
2730
- * (tools/build/vite-lib.preset.ts — build tooling, not loadable at runtime).
2731
- * These are the ONLY packages a self-contained dist genuinely `require()`s
2732
- * from node_modules; everything else is already inlined by the bundle.
2729
+ * Runtime deps a stripped addon package.json must KEEP so the installer still
2730
+ * `npm install`s them into the addon dir.
2731
+ *
2732
+ * Empty by design: every native an addon can import is now HOST-EXTERNAL
2733
+ * (node-av, sharp, node-pty resolved from the framework closure via the
2734
+ * resolver hook, see host-externals.ts), and every pure-JS lib (ssh2, werift,
2735
+ * @simplewebauthn, mqtt, aws-sdk, …) is inlined by the self-contained vite
2736
+ * bundle. So a self-contained dist requires NOTHING from a local node_modules —
2737
+ * `addons/` stays node_modules-free, which is what lets the backup tar the whole
2738
+ * folder with no exclusion list (see ADR-0027). An addon that genuinely needs an
2739
+ * un-bundleable runtime dep declares it explicitly in
2740
+ * `camstack.runtimeDependencies`; the debug escape hatch
2741
+ * `CAMSTACK_INSTALL_FULL_DEPS=1` restores the legacy install-everything path.
2742
+ *
2743
+ * (Historically this mirrored the build preset's COMMON_NATIVE_DEPS and
2744
+ * per-addon-installed node-av/sharp/onnxruntime-node/ssh2/werift/systeminformation
2745
+ * — 74k files / 1.34 GB of redundant node_modules on the live hub. That redundancy
2746
+ * is removed: the natives come from the framework, the pure-JS from the bundle.)
2733
2747
  */
2734
- var RUNTIME_NATIVE_KEEP = [
2735
- "better-sqlite3",
2736
- "node-av",
2737
- "onnxruntime-node",
2738
- "sharp",
2739
- "ssh2",
2740
- "werift",
2741
- "systeminformation"
2742
- ];
2748
+ var RUNTIME_NATIVE_KEEP = [];
2743
2749
  /** Read `camstack.runtimeDependencies` (extra non-native deps the dist
2744
2750
  * requires at runtime — versions come from the manifest's own dep maps). */
2745
2751
  function readRuntimeDependencies(pkg) {
@@ -3926,10 +3932,14 @@ var AddonInstaller = class AddonInstaller {
3926
3932
  * 1. Rename current install dir → {addonsDir}/.backups/{name}/{ts}/
3927
3933
  * 2. Run a fresh install (npm or local depending on installSource)
3928
3934
  * 3. Record `lastBackupDir` in the manifest entry
3935
+ * 4. Prune every OTHER backup of this addon — the previous one just
3936
+ * lost its only pointer and would otherwise be orphaned forever.
3929
3937
  *
3930
- * The post-install caller (kernel.restartAddon + health-check) is
3931
- * responsible for verifying the new version actually works and for
3932
- * triggering `rollbackAddon()` if the health check fails.
3938
+ * The post-install caller (restart + health-check) is responsible for
3939
+ * verifying the new version actually works, and for calling either
3940
+ * `clearBackup()` on success or `rollbackAddon()` on failure. This
3941
+ * method must NOT delete the backup itself: at this point the new
3942
+ * version has not been proven to work.
3933
3943
  *
3934
3944
  * Throws if there is no installed version to update — caller should
3935
3945
  * prefer plain `install()` for first-time installs.
@@ -3982,6 +3992,7 @@ var AddonInstaller = class AddonInstaller {
3982
3992
  source: this.manifest.get(packageName)?.source ?? "npm",
3983
3993
  lastBackupDir: backupDir
3984
3994
  });
3995
+ await this.pruneBackups(packageName, backupDir);
3985
3996
  return {
3986
3997
  name: result.name,
3987
3998
  version: result.version,
@@ -4052,6 +4063,7 @@ var AddonInstaller = class AddonInstaller {
4052
4063
  source: this.manifest.get(packageName)?.source ?? "npm",
4053
4064
  lastBackupDir: backupDir
4054
4065
  });
4066
+ await this.pruneBackups(packageName, backupDir);
4055
4067
  return {
4056
4068
  name: packageName,
4057
4069
  version,
@@ -4098,23 +4110,204 @@ var AddonInstaller = class AddonInstaller {
4098
4110
  this.logger.info(`${packageName} — rolled back to v${restoredVersion}`);
4099
4111
  return restoredVersion;
4100
4112
  }
4113
+ /** Root of the pre-update rollback copies: `{addonsDir}/.backups`. */
4114
+ get backupsRoot() {
4115
+ return path$39.join(this.addonsDir, ".backups");
4116
+ }
4117
+ /**
4118
+ * True only when `target` resolves to something strictly INSIDE
4119
+ * `{addonsDir}/.backups/`.
4120
+ *
4121
+ * Every deletion in this section funnels through here. `lastBackupDir`
4122
+ * is read back from a manifest file on disk — an operator edit, a
4123
+ * restored manifest from another host, or a `..` in the recorded path
4124
+ * must never turn "reclaim a backup" into "delete the live install".
4125
+ */
4126
+ isInsideBackups(target) {
4127
+ const rel = path$39.relative(path$39.resolve(this.backupsRoot), path$39.resolve(target));
4128
+ return rel !== "" && !rel.startsWith("..") && !path$39.isAbsolute(rel);
4129
+ }
4101
4130
  /**
4102
- * Drop the recorded backup for an addon (e.g. after a successful
4103
- * post-update health check confirms the new version is healthy).
4104
- * Frees disk space without affecting the running install.
4131
+ * Best-effort removal of one path under `.backups/`.
4132
+ *
4133
+ * Never throws: an undeleted backup is a disk-space problem, whereas a
4134
+ * throw on the post-update success path would turn a working update into
4135
+ * a reported outage. Returns true only when the path is gone.
4136
+ */
4137
+ async removeBackupPath(target, reason) {
4138
+ if (!this.isInsideBackups(target)) {
4139
+ this.logger.error("Refusing to remove a backup path outside .backups/", { meta: {
4140
+ target,
4141
+ backupsRoot: this.backupsRoot,
4142
+ reason
4143
+ } });
4144
+ return false;
4145
+ }
4146
+ try {
4147
+ await fs$17.promises.rm(target, {
4148
+ recursive: true,
4149
+ force: true
4150
+ });
4151
+ return true;
4152
+ } catch (err) {
4153
+ this.logger.warn("Backup removal failed; leaving residue for the next boot sweep", { meta: {
4154
+ target,
4155
+ reason,
4156
+ error: errMsg$1(err)
4157
+ } });
4158
+ return false;
4159
+ }
4160
+ }
4161
+ /** List the timestamped backup dirs recorded for one package. */
4162
+ listBackupsFor(packageName) {
4163
+ const dir = path$39.join(this.backupsRoot, packageName);
4164
+ try {
4165
+ return fs$17.readdirSync(dir, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => path$39.join(dir, e.name)).sort();
4166
+ } catch {
4167
+ return [];
4168
+ }
4169
+ }
4170
+ /**
4171
+ * Keep at most ONE backup per addon: drop every backup dir for
4172
+ * `packageName` except `keepDir`.
4173
+ *
4174
+ * Called right after a new backup is recorded. Without this, a second
4175
+ * update overwrites `lastBackupDir` and the previous backup becomes
4176
+ * unreachable — no pointer, so neither `rollbackAddon` nor `clearBackup`
4177
+ * can ever see it again. That is how the live hub accumulated two 398 MiB
4178
+ * copies of one addon. Never throws.
4179
+ */
4180
+ async pruneBackups(packageName, keepDir) {
4181
+ const keep = keepDir == null ? null : path$39.resolve(keepDir);
4182
+ const removed = [];
4183
+ for (const candidate of this.listBackupsFor(packageName)) {
4184
+ if (keep != null && path$39.resolve(candidate) === keep) continue;
4185
+ if (await this.removeBackupPath(candidate, "superseded-backup")) removed.push(candidate);
4186
+ }
4187
+ if (removed.length > 0) this.logger.info(`${packageName} — pruned ${removed.length} superseded backup(s)`);
4188
+ await this.removeEmptyBackupDirs(packageName);
4189
+ return removed;
4190
+ }
4191
+ /** Drop `.backups/{pkg}` (and its `@scope` parent) once they are empty. */
4192
+ async removeEmptyBackupDirs(packageName) {
4193
+ const pkgDir = path$39.join(this.backupsRoot, packageName);
4194
+ for (const dir of [pkgDir, path$39.dirname(pkgDir)]) {
4195
+ if (!this.isInsideBackups(dir)) continue;
4196
+ try {
4197
+ await fs$17.promises.rmdir(dir);
4198
+ } catch {
4199
+ return;
4200
+ }
4201
+ }
4202
+ }
4203
+ /**
4204
+ * Drop the recorded backup for an addon — call once the post-update
4205
+ * health check confirms the new version actually works.
4206
+ *
4207
+ * Never throws and never rejects. The manifest pointer is cleared even
4208
+ * when the bytes could not be removed: a pointer at a half-deleted tree
4209
+ * would make `rollbackAddon` restore a gutted install, and the boot sweep
4210
+ * reclaims the residue on the next start.
4105
4211
  */
4106
- clearBackup(packageName) {
4212
+ async clearBackup(packageName) {
4107
4213
  const entry = this.manifest.get(packageName);
4108
4214
  if (entry?.lastBackupDir == null) return;
4109
- if (fs$17.existsSync(entry.lastBackupDir)) fs$17.rmSync(entry.lastBackupDir, {
4110
- recursive: true,
4111
- force: true
4112
- });
4113
- this.manifest.upsert(packageName, {
4114
- version: entry.version,
4115
- source: entry.source,
4116
- lastBackupDir: void 0
4117
- });
4215
+ if (await this.removeBackupPath(entry.lastBackupDir, "update-confirmed")) this.logger.info(`${packageName} — update confirmed, backup reclaimed`, { meta: { backupDir: entry.lastBackupDir } });
4216
+ try {
4217
+ this.manifest.upsert(packageName, {
4218
+ version: entry.version,
4219
+ source: entry.source,
4220
+ lastBackupDir: void 0
4221
+ });
4222
+ } catch (err) {
4223
+ this.logger.warn(`${packageName} — could not clear the backup pointer`, { meta: { error: errMsg$1(err) } });
4224
+ }
4225
+ await this.removeEmptyBackupDirs(packageName);
4226
+ }
4227
+ /**
4228
+ * Boot-time retention sweep over `{addonsDir}/.backups/`.
4229
+ *
4230
+ * RULE: for each addon, the ONLY backup that survives is the one the
4231
+ * manifest's `lastBackupDir` points at — everything else is unreachable
4232
+ * by construction (no pointer ⇒ neither `rollbackAddon` nor `clearBackup`
4233
+ * can ever reach it). One exception: when the addon's LIVE install dir is
4234
+ * missing, the newest backup is the only surviving copy of that addon (a
4235
+ * crash between the rename-aside and the manifest write in `applyUpdate`),
4236
+ * so it is kept instead of reclaimed.
4237
+ *
4238
+ * Why not "delete everything once the addon is healthy": this installer
4239
+ * has no health signal, and the pointed-at backup is precisely the one a
4240
+ * failed update left for the operator's Rollback button. Bounding to one
4241
+ * per addon is enough — the observed 814 MiB was duplicate copies, not a
4242
+ * single retained rollback target.
4243
+ *
4244
+ * Never throws. Safe to run on every boot; runs before any update can be
4245
+ * in flight.
4246
+ */
4247
+ async sweepBackups() {
4248
+ const removed = [];
4249
+ const kept = [];
4250
+ for (const packageName of this.listBackedUpPackages()) {
4251
+ const backups = this.listBackupsFor(packageName);
4252
+ if (backups.length === 0) {
4253
+ await this.removeEmptyBackupDirs(packageName);
4254
+ continue;
4255
+ }
4256
+ const keep = this.resolveBackupToKeep(packageName, backups);
4257
+ for (const candidate of backups) {
4258
+ if (keep != null && path$39.resolve(candidate) === path$39.resolve(keep)) {
4259
+ kept.push(candidate);
4260
+ continue;
4261
+ }
4262
+ if (await this.removeBackupPath(candidate, "boot-sweep")) removed.push(candidate);
4263
+ }
4264
+ await this.removeEmptyBackupDirs(packageName);
4265
+ }
4266
+ if (removed.length > 0 || kept.length > 0) this.logger.info(`Backup sweep — reclaimed ${removed.length}, retained ${kept.length} rollback target(s)`);
4267
+ return {
4268
+ removed,
4269
+ kept
4270
+ };
4271
+ }
4272
+ /**
4273
+ * Which backup of `packageName` survives the sweep: the manifest's
4274
+ * rollback target, or — when the live install is gone — the newest copy,
4275
+ * which is then the only one that exists. `null` means "reclaim them all".
4276
+ */
4277
+ resolveBackupToKeep(packageName, backups) {
4278
+ const liveDir = path$39.join(this.addonsDir, packageName);
4279
+ if (!fs$17.existsSync(liveDir)) {
4280
+ const newest = backups[backups.length - 1] ?? null;
4281
+ if (newest != null) this.logger.warn(`${packageName} — live install dir missing; retaining its newest backup as the only surviving copy`, { meta: {
4282
+ liveDir,
4283
+ backupDir: newest
4284
+ } });
4285
+ return newest;
4286
+ }
4287
+ const pointer = this.manifest.get(packageName)?.lastBackupDir;
4288
+ if (pointer == null) return null;
4289
+ const resolved = path$39.resolve(pointer);
4290
+ return backups.some((b) => path$39.resolve(b) === resolved) ? pointer : null;
4291
+ }
4292
+ /** Package names that have a `.backups/{pkg}` directory, scoped or not. */
4293
+ listBackedUpPackages() {
4294
+ let entries;
4295
+ try {
4296
+ entries = fs$17.readdirSync(this.backupsRoot, { withFileTypes: true });
4297
+ } catch {
4298
+ return [];
4299
+ }
4300
+ const names = [];
4301
+ for (const entry of entries) {
4302
+ if (!entry.isDirectory()) continue;
4303
+ if (!entry.name.startsWith("@")) {
4304
+ names.push(entry.name);
4305
+ continue;
4306
+ }
4307
+ const scopeDir = path$39.join(this.backupsRoot, entry.name);
4308
+ for (const inner of fs$17.readdirSync(scopeDir, { withFileTypes: true })) if (inner.isDirectory()) names.push(`${entry.name}/${inner.name}`);
4309
+ }
4310
+ return names;
4118
4311
  }
4119
4312
  async uninstall(packageName) {
4120
4313
  const addonDir = path$39.join(this.addonsDir, packageName);