@camstack/kernel 0.1.7 → 0.1.9
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/addon-installer-2BW2KLQD.mjs +7 -0
- package/dist/chunk-67QMERMP.mjs +411 -0
- package/dist/chunk-67QMERMP.mjs.map +1 -0
- package/dist/chunk-S5YWNNPK.mjs +135 -0
- package/dist/{chunk-RHK5CCAL.mjs.map → chunk-S5YWNNPK.mjs.map} +1 -1
- package/dist/index.d.mts +645 -0
- package/dist/index.d.ts +645 -0
- package/dist/index.js +1746 -2037
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1253 -1462
- package/dist/index.mjs.map +1 -1
- package/dist/worker/addon-worker-entry.d.mts +2 -0
- package/dist/worker/addon-worker-entry.d.ts +2 -0
- package/dist/worker/addon-worker-entry.js +300 -430
- package/dist/worker/addon-worker-entry.js.map +1 -1
- package/dist/worker/addon-worker-entry.mjs +4 -8
- package/dist/worker/addon-worker-entry.mjs.map +1 -1
- package/package.json +1 -1
- package/dist/addon-installer-WQBOEZQT.mjs +0 -6
- package/dist/chunk-GJ3DKNOD.mjs +0 -494
- package/dist/chunk-GJ3DKNOD.mjs.map +0 -1
- package/dist/chunk-LZOMFHX3.mjs +0 -38
- package/dist/chunk-LZOMFHX3.mjs.map +0 -1
- package/dist/chunk-RHK5CCAL.mjs +0 -154
- /package/dist/{addon-installer-WQBOEZQT.mjs.map → addon-installer-2BW2KLQD.mjs.map} +0 -0
package/dist/index.mjs
CHANGED
|
@@ -1,1522 +1,1313 @@
|
|
|
1
1
|
import {
|
|
2
|
-
|
|
3
|
-
} from "./chunk-
|
|
2
|
+
WorkerProcessManager
|
|
3
|
+
} from "./chunk-S5YWNNPK.mjs";
|
|
4
4
|
import {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
5
|
+
AddonInstaller,
|
|
6
|
+
copyDirRecursive,
|
|
7
|
+
copyExtraFileDirs,
|
|
8
|
+
ensureDir,
|
|
9
|
+
ensureLibraryBuilt,
|
|
10
|
+
installPackageFromNpmSync,
|
|
11
|
+
isSourceNewer,
|
|
12
|
+
stripCamstackDeps,
|
|
13
|
+
symlinkAddonsToNodeModules
|
|
14
|
+
} from "./chunk-67QMERMP.mjs";
|
|
13
15
|
|
|
14
|
-
// src/addon-loader.
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
})
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
16
|
+
// src/addon-loader.ts
|
|
17
|
+
import * as fs from "fs";
|
|
18
|
+
import * as path from "path";
|
|
19
|
+
function resolveAddonClass(mod) {
|
|
20
|
+
let candidate = mod["default"] ?? mod[Object.keys(mod)[0]];
|
|
21
|
+
if (candidate && typeof candidate === "object" && "default" in candidate) {
|
|
22
|
+
candidate = candidate["default"];
|
|
23
|
+
}
|
|
24
|
+
return typeof candidate === "function" ? candidate : void 0;
|
|
25
|
+
}
|
|
26
|
+
var AddonLoader = class {
|
|
27
|
+
addons = /* @__PURE__ */ new Map();
|
|
28
|
+
/** Scan addons directory and load all addon packages.
|
|
29
|
+
* Supports scoped layout: addons/@scope/package-name/ */
|
|
30
|
+
async loadFromDirectory(addonsDir) {
|
|
31
|
+
if (!fs.existsSync(addonsDir)) return;
|
|
32
|
+
const entries = fs.readdirSync(addonsDir, { withFileTypes: true });
|
|
33
|
+
for (const entry of entries) {
|
|
34
|
+
if (!entry.isDirectory()) continue;
|
|
35
|
+
if (entry.name.startsWith("@")) {
|
|
36
|
+
const scopeDir = path.join(addonsDir, entry.name);
|
|
37
|
+
const scopeEntries = fs.readdirSync(scopeDir, { withFileTypes: true });
|
|
38
|
+
for (const scopeEntry of scopeEntries) {
|
|
39
|
+
if (!scopeEntry.isDirectory()) continue;
|
|
40
|
+
await this.tryLoadAddon(path.join(scopeDir, scopeEntry.name));
|
|
41
|
+
}
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
await this.tryLoadAddon(path.join(addonsDir, entry.name));
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
async tryLoadAddon(addonDir) {
|
|
48
|
+
const pkgJsonPath = path.join(addonDir, "package.json");
|
|
49
|
+
if (!fs.existsSync(pkgJsonPath)) return;
|
|
50
|
+
try {
|
|
51
|
+
await this.loadFromAddonDir(addonDir);
|
|
52
|
+
} catch (err) {
|
|
53
|
+
console.warn(`Failed to load addon from ${addonDir}: ${err}`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/** Load addon from a specific directory (package.json + dist/) */
|
|
57
|
+
async loadFromAddonDir(addonDir) {
|
|
58
|
+
const pkgJsonPath = path.join(addonDir, "package.json");
|
|
59
|
+
const pkgJson = JSON.parse(fs.readFileSync(pkgJsonPath, "utf-8"));
|
|
60
|
+
const packageName = pkgJson["name"];
|
|
61
|
+
const packageVersion = pkgJson["version"] ?? "0.0.0";
|
|
62
|
+
const manifest = pkgJson["camstack"];
|
|
63
|
+
if (!manifest?.addons?.length) return;
|
|
64
|
+
for (const declaration of manifest.addons) {
|
|
65
|
+
const entryFile = declaration.entry.replace(/^\.\//, "").replace(/^src\//, "dist/").replace(/\.ts$/, ".js");
|
|
66
|
+
let entryPath = path.resolve(addonDir, entryFile);
|
|
67
|
+
if (!fs.existsSync(entryPath)) {
|
|
68
|
+
const base = entryPath.replace(/\.(js|cjs|mjs)$/, "");
|
|
69
|
+
const alternatives = [
|
|
70
|
+
`${base}.cjs`,
|
|
71
|
+
`${base}.mjs`,
|
|
72
|
+
path.resolve(addonDir, "dist", "index.js"),
|
|
73
|
+
path.resolve(addonDir, "dist", "index.cjs"),
|
|
74
|
+
path.resolve(addonDir, "dist", "index.mjs"),
|
|
75
|
+
path.resolve(addonDir, declaration.entry)
|
|
76
|
+
];
|
|
77
|
+
entryPath = alternatives.find((p) => fs.existsSync(p)) ?? entryPath;
|
|
78
|
+
}
|
|
79
|
+
if (!fs.existsSync(entryPath)) {
|
|
80
|
+
throw new Error(`Entry not found: ${entryPath}`);
|
|
81
|
+
}
|
|
82
|
+
const mod = await import(entryPath);
|
|
83
|
+
const AddonClass = resolveAddonClass(mod);
|
|
84
|
+
if (!AddonClass) {
|
|
85
|
+
throw new Error(`No addon class in ${entryPath}`);
|
|
86
|
+
}
|
|
87
|
+
this.addons.set(declaration.id, {
|
|
88
|
+
declaration,
|
|
89
|
+
packageName,
|
|
90
|
+
packageVersion,
|
|
91
|
+
packageDisplayName: manifest.displayName,
|
|
92
|
+
addonClass: AddonClass
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
/** Load addon from a direct path (for development/testing) */
|
|
97
|
+
async loadFromPath(addonId, modulePath, packageName, declaration, packageVersion = "0.0.0") {
|
|
98
|
+
const mod = await import(modulePath);
|
|
99
|
+
const AddonClass = resolveAddonClass(mod);
|
|
100
|
+
if (!AddonClass) {
|
|
101
|
+
throw new Error(`Module ${modulePath} has no default export`);
|
|
102
|
+
}
|
|
103
|
+
this.addons.set(addonId, {
|
|
104
|
+
declaration: {
|
|
105
|
+
id: addonId,
|
|
106
|
+
entry: modulePath,
|
|
107
|
+
slot: "detector",
|
|
108
|
+
...declaration
|
|
109
|
+
},
|
|
110
|
+
packageName,
|
|
111
|
+
packageVersion,
|
|
112
|
+
addonClass: AddonClass
|
|
35
113
|
});
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
};
|
|
54
|
-
})();
|
|
55
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
56
|
-
exports.AddonLoader = void 0;
|
|
57
|
-
var fs = __importStar(__require("fs"));
|
|
58
|
-
var path = __importStar(__require("path"));
|
|
59
|
-
function resolveAddonClass(mod) {
|
|
60
|
-
let candidate = mod["default"] ?? mod[Object.keys(mod)[0]];
|
|
61
|
-
if (candidate && typeof candidate === "object" && "default" in candidate) {
|
|
62
|
-
candidate = candidate["default"];
|
|
63
|
-
}
|
|
64
|
-
return typeof candidate === "function" ? candidate : void 0;
|
|
114
|
+
}
|
|
115
|
+
/** Get a registered addon by ID */
|
|
116
|
+
getAddon(addonId) {
|
|
117
|
+
return this.addons.get(addonId);
|
|
118
|
+
}
|
|
119
|
+
/** List all registered addons */
|
|
120
|
+
listAddons() {
|
|
121
|
+
return [...this.addons.values()];
|
|
122
|
+
}
|
|
123
|
+
/** Check if an addon is registered */
|
|
124
|
+
hasAddon(addonId) {
|
|
125
|
+
return this.addons.has(addonId);
|
|
126
|
+
}
|
|
127
|
+
/** Create a new instance of an addon (not yet initialized) */
|
|
128
|
+
createInstance(addonId) {
|
|
129
|
+
const registered = this.addons.get(addonId);
|
|
130
|
+
if (!registered) {
|
|
131
|
+
throw new Error(`Addon "${addonId}" is not registered`);
|
|
65
132
|
}
|
|
66
|
-
|
|
67
|
-
addons = /* @__PURE__ */ new Map();
|
|
68
|
-
/** Scan addons directory and load all addon packages.
|
|
69
|
-
* Supports scoped layout: addons/@scope/package-name/ */
|
|
70
|
-
async loadFromDirectory(addonsDir) {
|
|
71
|
-
if (!fs.existsSync(addonsDir))
|
|
72
|
-
return;
|
|
73
|
-
const entries = fs.readdirSync(addonsDir, { withFileTypes: true });
|
|
74
|
-
for (const entry of entries) {
|
|
75
|
-
if (!entry.isDirectory())
|
|
76
|
-
continue;
|
|
77
|
-
if (entry.name.startsWith("@")) {
|
|
78
|
-
const scopeDir = path.join(addonsDir, entry.name);
|
|
79
|
-
const scopeEntries = fs.readdirSync(scopeDir, { withFileTypes: true });
|
|
80
|
-
for (const scopeEntry of scopeEntries) {
|
|
81
|
-
if (!scopeEntry.isDirectory())
|
|
82
|
-
continue;
|
|
83
|
-
await this.tryLoadAddon(path.join(scopeDir, scopeEntry.name));
|
|
84
|
-
}
|
|
85
|
-
continue;
|
|
86
|
-
}
|
|
87
|
-
await this.tryLoadAddon(path.join(addonsDir, entry.name));
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
async tryLoadAddon(addonDir) {
|
|
91
|
-
const pkgJsonPath = path.join(addonDir, "package.json");
|
|
92
|
-
if (!fs.existsSync(pkgJsonPath))
|
|
93
|
-
return;
|
|
94
|
-
try {
|
|
95
|
-
await this.loadFromAddonDir(addonDir);
|
|
96
|
-
} catch (err) {
|
|
97
|
-
console.warn(`Failed to load addon from ${addonDir}: ${err}`);
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
/** Load addon from a specific directory (package.json + dist/) */
|
|
101
|
-
async loadFromAddonDir(addonDir) {
|
|
102
|
-
const pkgJsonPath = path.join(addonDir, "package.json");
|
|
103
|
-
const pkgJson = JSON.parse(fs.readFileSync(pkgJsonPath, "utf-8"));
|
|
104
|
-
const packageName = pkgJson["name"];
|
|
105
|
-
const packageVersion = pkgJson["version"] ?? "0.0.0";
|
|
106
|
-
const manifest = pkgJson["camstack"];
|
|
107
|
-
if (!manifest?.addons?.length)
|
|
108
|
-
return;
|
|
109
|
-
for (const declaration of manifest.addons) {
|
|
110
|
-
const entryFile = declaration.entry.replace(/^\.\//, "").replace(/^src\//, "dist/").replace(/\.ts$/, ".js");
|
|
111
|
-
let entryPath = path.resolve(addonDir, entryFile);
|
|
112
|
-
if (!fs.existsSync(entryPath)) {
|
|
113
|
-
const base = entryPath.replace(/\.(js|cjs|mjs)$/, "");
|
|
114
|
-
const alternatives = [
|
|
115
|
-
`${base}.cjs`,
|
|
116
|
-
`${base}.mjs`,
|
|
117
|
-
path.resolve(addonDir, "dist", "index.js"),
|
|
118
|
-
path.resolve(addonDir, "dist", "index.cjs"),
|
|
119
|
-
path.resolve(addonDir, "dist", "index.mjs"),
|
|
120
|
-
path.resolve(addonDir, declaration.entry)
|
|
121
|
-
];
|
|
122
|
-
entryPath = alternatives.find((p) => fs.existsSync(p)) ?? entryPath;
|
|
123
|
-
}
|
|
124
|
-
if (!fs.existsSync(entryPath)) {
|
|
125
|
-
throw new Error(`Entry not found: ${entryPath}`);
|
|
126
|
-
}
|
|
127
|
-
const mod = await Promise.resolve(`${entryPath}`).then((s) => __importStar(__require(s)));
|
|
128
|
-
const AddonClass = resolveAddonClass(mod);
|
|
129
|
-
if (!AddonClass) {
|
|
130
|
-
throw new Error(`No addon class in ${entryPath}`);
|
|
131
|
-
}
|
|
132
|
-
this.addons.set(declaration.id, {
|
|
133
|
-
declaration,
|
|
134
|
-
packageName,
|
|
135
|
-
packageVersion,
|
|
136
|
-
packageDisplayName: manifest.displayName,
|
|
137
|
-
addonClass: AddonClass
|
|
138
|
-
});
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
/** Load addon from a direct path (for development/testing) */
|
|
142
|
-
async loadFromPath(addonId, modulePath, packageName, declaration, packageVersion = "0.0.0") {
|
|
143
|
-
const mod = await Promise.resolve(`${modulePath}`).then((s) => __importStar(__require(s)));
|
|
144
|
-
const AddonClass = resolveAddonClass(mod);
|
|
145
|
-
if (!AddonClass) {
|
|
146
|
-
throw new Error(`Module ${modulePath} has no default export`);
|
|
147
|
-
}
|
|
148
|
-
this.addons.set(addonId, {
|
|
149
|
-
declaration: {
|
|
150
|
-
id: addonId,
|
|
151
|
-
entry: modulePath,
|
|
152
|
-
slot: "detector",
|
|
153
|
-
...declaration
|
|
154
|
-
},
|
|
155
|
-
packageName,
|
|
156
|
-
packageVersion,
|
|
157
|
-
addonClass: AddonClass
|
|
158
|
-
});
|
|
159
|
-
}
|
|
160
|
-
/** Get a registered addon by ID */
|
|
161
|
-
getAddon(addonId) {
|
|
162
|
-
return this.addons.get(addonId);
|
|
163
|
-
}
|
|
164
|
-
/** List all registered addons */
|
|
165
|
-
listAddons() {
|
|
166
|
-
return [...this.addons.values()];
|
|
167
|
-
}
|
|
168
|
-
/** Check if an addon is registered */
|
|
169
|
-
hasAddon(addonId) {
|
|
170
|
-
return this.addons.has(addonId);
|
|
171
|
-
}
|
|
172
|
-
/** Create a new instance of an addon (not yet initialized) */
|
|
173
|
-
createInstance(addonId) {
|
|
174
|
-
const registered = this.addons.get(addonId);
|
|
175
|
-
if (!registered) {
|
|
176
|
-
throw new Error(`Addon "${addonId}" is not registered`);
|
|
177
|
-
}
|
|
178
|
-
return new registered.addonClass();
|
|
179
|
-
}
|
|
180
|
-
};
|
|
181
|
-
exports.AddonLoader = AddonLoader2;
|
|
133
|
+
return new registered.addonClass();
|
|
182
134
|
}
|
|
183
|
-
}
|
|
135
|
+
};
|
|
184
136
|
|
|
185
|
-
// src/addon-engine-manager.
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
var node_crypto_1 = __require("crypto");
|
|
192
|
-
var AddonEngineManager2 = class {
|
|
193
|
-
loader;
|
|
194
|
-
baseContext;
|
|
195
|
-
engines = /* @__PURE__ */ new Map();
|
|
196
|
-
constructor(loader, baseContext) {
|
|
197
|
-
this.loader = loader;
|
|
198
|
-
this.baseContext = baseContext;
|
|
199
|
-
}
|
|
200
|
-
/**
|
|
201
|
-
* Get or create an addon engine for the given effective config.
|
|
202
|
-
* Cameras with the same addonId + effective config share the same engine.
|
|
203
|
-
*/
|
|
204
|
-
async getOrCreateEngine(addonId, globalConfig, cameraOverride) {
|
|
205
|
-
const effectiveConfig = { ...globalConfig, ...cameraOverride };
|
|
206
|
-
const configKey = `${addonId}:${this.hashConfig(effectiveConfig)}`;
|
|
207
|
-
const existing = this.engines.get(configKey);
|
|
208
|
-
if (existing)
|
|
209
|
-
return existing;
|
|
210
|
-
const addon = this.loader.createInstance(addonId);
|
|
211
|
-
await addon.initialize({ ...this.baseContext, addonConfig: effectiveConfig });
|
|
212
|
-
this.engines.set(configKey, addon);
|
|
213
|
-
return addon;
|
|
214
|
-
}
|
|
215
|
-
/** Get all active engines */
|
|
216
|
-
getActiveEngines() {
|
|
217
|
-
return new Map(this.engines);
|
|
218
|
-
}
|
|
219
|
-
/** Shutdown a specific engine by its config key */
|
|
220
|
-
async shutdownEngine(configKey) {
|
|
221
|
-
const engine = this.engines.get(configKey);
|
|
222
|
-
if (engine) {
|
|
223
|
-
await engine.shutdown();
|
|
224
|
-
this.engines.delete(configKey);
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
/** Shutdown all engines */
|
|
228
|
-
async shutdownAll() {
|
|
229
|
-
for (const [, engine] of this.engines) {
|
|
230
|
-
await engine.shutdown();
|
|
231
|
-
}
|
|
232
|
-
this.engines.clear();
|
|
233
|
-
}
|
|
234
|
-
/** Compute a deterministic config key (visible for tests) */
|
|
235
|
-
computeConfigKey(addonId, effectiveConfig) {
|
|
236
|
-
return `${addonId}:${this.hashConfig(effectiveConfig)}`;
|
|
237
|
-
}
|
|
238
|
-
hashConfig(config) {
|
|
239
|
-
const sorted = JSON.stringify(config, Object.keys(config).sort());
|
|
240
|
-
return (0, node_crypto_1.createHash)("md5").update(sorted).digest("hex").slice(0, 12);
|
|
241
|
-
}
|
|
242
|
-
};
|
|
243
|
-
exports.AddonEngineManager = AddonEngineManager2;
|
|
137
|
+
// src/addon-engine-manager.ts
|
|
138
|
+
import { createHash } from "crypto";
|
|
139
|
+
var AddonEngineManager = class {
|
|
140
|
+
constructor(loader, baseContext) {
|
|
141
|
+
this.loader = loader;
|
|
142
|
+
this.baseContext = baseContext;
|
|
244
143
|
}
|
|
245
|
-
|
|
144
|
+
engines = /* @__PURE__ */ new Map();
|
|
145
|
+
/**
|
|
146
|
+
* Get or create an addon engine for the given effective config.
|
|
147
|
+
* Cameras with the same addonId + effective config share the same engine.
|
|
148
|
+
*/
|
|
149
|
+
async getOrCreateEngine(addonId, globalConfig, cameraOverride) {
|
|
150
|
+
const effectiveConfig = { ...globalConfig, ...cameraOverride };
|
|
151
|
+
const configKey = `${addonId}:${this.hashConfig(effectiveConfig)}`;
|
|
152
|
+
const existing = this.engines.get(configKey);
|
|
153
|
+
if (existing) return existing;
|
|
154
|
+
const addon = this.loader.createInstance(addonId);
|
|
155
|
+
await addon.initialize({ ...this.baseContext, addonConfig: effectiveConfig });
|
|
156
|
+
this.engines.set(configKey, addon);
|
|
157
|
+
return addon;
|
|
158
|
+
}
|
|
159
|
+
/** Get all active engines */
|
|
160
|
+
getActiveEngines() {
|
|
161
|
+
return new Map(this.engines);
|
|
162
|
+
}
|
|
163
|
+
/** Shutdown a specific engine by its config key */
|
|
164
|
+
async shutdownEngine(configKey) {
|
|
165
|
+
const engine = this.engines.get(configKey);
|
|
166
|
+
if (engine) {
|
|
167
|
+
await engine.shutdown();
|
|
168
|
+
this.engines.delete(configKey);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
/** Shutdown all engines */
|
|
172
|
+
async shutdownAll() {
|
|
173
|
+
for (const [, engine] of this.engines) {
|
|
174
|
+
await engine.shutdown();
|
|
175
|
+
}
|
|
176
|
+
this.engines.clear();
|
|
177
|
+
}
|
|
178
|
+
/** Compute a deterministic config key (visible for tests) */
|
|
179
|
+
computeConfigKey(addonId, effectiveConfig) {
|
|
180
|
+
return `${addonId}:${this.hashConfig(effectiveConfig)}`;
|
|
181
|
+
}
|
|
182
|
+
hashConfig(config) {
|
|
183
|
+
const sorted = JSON.stringify(config, Object.keys(config).sort());
|
|
184
|
+
return createHash("md5").update(sorted).digest("hex").slice(0, 12);
|
|
185
|
+
}
|
|
186
|
+
};
|
|
246
187
|
|
|
247
|
-
// src/workspace-detect.
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
188
|
+
// src/workspace-detect.ts
|
|
189
|
+
import * as fs2 from "fs";
|
|
190
|
+
import * as path2 from "path";
|
|
191
|
+
function detectWorkspacePackagesDir(startDir) {
|
|
192
|
+
let current = path2.resolve(startDir);
|
|
193
|
+
for (let i = 0; i < 6; i++) {
|
|
194
|
+
current = path2.dirname(current);
|
|
195
|
+
const packagesDir = path2.join(current, "packages");
|
|
196
|
+
const rootPkgJson = path2.join(current, "package.json");
|
|
197
|
+
if (fs2.existsSync(packagesDir) && fs2.existsSync(rootPkgJson)) {
|
|
198
|
+
try {
|
|
199
|
+
const rootPkg = JSON.parse(fs2.readFileSync(rootPkgJson, "utf-8"));
|
|
200
|
+
if (rootPkg.workspaces || rootPkg.name === "camstack-server" || rootPkg.name === "camstack") {
|
|
201
|
+
return packagesDir;
|
|
202
|
+
}
|
|
203
|
+
} catch {
|
|
258
204
|
}
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// src/capability-registry.ts
|
|
211
|
+
var CapabilityRegistry = class {
|
|
212
|
+
constructor(logger, configReader) {
|
|
213
|
+
this.logger = logger;
|
|
214
|
+
this.configReader = configReader;
|
|
215
|
+
}
|
|
216
|
+
capabilities = /* @__PURE__ */ new Map();
|
|
217
|
+
/** Per-device singleton overrides: deviceId → (capability → addonId) */
|
|
218
|
+
deviceOverrides = /* @__PURE__ */ new Map();
|
|
219
|
+
/** Per-device collection filters: deviceId → (capability → addonIds[]) */
|
|
220
|
+
deviceCollectionFilters = /* @__PURE__ */ new Map();
|
|
221
|
+
/**
|
|
222
|
+
* Declare a capability (typically called when addon manifests are loaded).
|
|
223
|
+
* Must be called before registerProvider/registerConsumer for that capability.
|
|
224
|
+
*/
|
|
225
|
+
declareCapability(declaration) {
|
|
226
|
+
if (this.capabilities.has(declaration.name)) {
|
|
227
|
+
this.logger.debug(`Capability "${declaration.name}" already declared, skipping`);
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
this.capabilities.set(declaration.name, {
|
|
231
|
+
declaration,
|
|
232
|
+
available: /* @__PURE__ */ new Map(),
|
|
233
|
+
activeAddonId: null,
|
|
234
|
+
activeProvider: null,
|
|
235
|
+
activeCollection: [],
|
|
236
|
+
consumers: /* @__PURE__ */ new Set()
|
|
268
237
|
});
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
current = path.dirname(current);
|
|
296
|
-
const packagesDir = path.join(current, "packages");
|
|
297
|
-
const rootPkgJson = path.join(current, "package.json");
|
|
298
|
-
if (fs.existsSync(packagesDir) && fs.existsSync(rootPkgJson)) {
|
|
238
|
+
this.logger.debug(`Capability declared: ${declaration.name} (mode=${declaration.mode})`);
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Register a capability provider (called by addon loader when addon is enabled).
|
|
242
|
+
* For singleton: auto-activates if user-preferred or first registered.
|
|
243
|
+
* For collection: adds to active set and notifies consumers.
|
|
244
|
+
*/
|
|
245
|
+
registerProvider(capability, addonId, provider) {
|
|
246
|
+
const state = this.capabilities.get(capability);
|
|
247
|
+
if (!state) {
|
|
248
|
+
this.logger.warn(`Cannot register provider for undeclared capability "${capability}"`);
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
state.available.set(addonId, provider);
|
|
252
|
+
this.logger.info(`Provider registered: ${addonId} \u2192 ${capability}`);
|
|
253
|
+
if (state.declaration.mode === "singleton") {
|
|
254
|
+
const userChoice = this.configReader(capability);
|
|
255
|
+
if (userChoice === addonId) {
|
|
256
|
+
this.activateSingleton(state, addonId, provider);
|
|
257
|
+
} else if (userChoice === void 0 && state.activeAddonId === null) {
|
|
258
|
+
this.activateSingleton(state, addonId, provider);
|
|
259
|
+
}
|
|
260
|
+
} else {
|
|
261
|
+
state.activeCollection.push({ addonId, provider });
|
|
262
|
+
for (const consumer of state.consumers) {
|
|
263
|
+
if (consumer.onAdded) {
|
|
299
264
|
try {
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
}
|
|
304
|
-
} catch {
|
|
265
|
+
consumer.onAdded(provider);
|
|
266
|
+
} catch (error) {
|
|
267
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
268
|
+
this.logger.error(`Consumer onAdded failed for ${capability}: ${msg}`);
|
|
305
269
|
}
|
|
306
270
|
}
|
|
307
271
|
}
|
|
308
|
-
return null;
|
|
309
272
|
}
|
|
310
273
|
}
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
/**
|
|
332
|
-
* Declare a capability (typically called when addon manifests are loaded).
|
|
333
|
-
* Must be called before registerProvider/registerConsumer for that capability.
|
|
334
|
-
*/
|
|
335
|
-
declareCapability(declaration) {
|
|
336
|
-
if (this.capabilities.has(declaration.name)) {
|
|
337
|
-
this.logger.debug(`Capability "${declaration.name}" already declared, skipping`);
|
|
338
|
-
return;
|
|
339
|
-
}
|
|
340
|
-
this.capabilities.set(declaration.name, {
|
|
341
|
-
declaration,
|
|
342
|
-
available: /* @__PURE__ */ new Map(),
|
|
343
|
-
activeAddonId: null,
|
|
344
|
-
activeProvider: null,
|
|
345
|
-
activeCollection: [],
|
|
346
|
-
consumers: /* @__PURE__ */ new Set()
|
|
347
|
-
});
|
|
348
|
-
this.logger.debug(`Capability declared: ${declaration.name} (mode=${declaration.mode})`);
|
|
349
|
-
}
|
|
350
|
-
/**
|
|
351
|
-
* Register a capability provider (called by addon loader when addon is enabled).
|
|
352
|
-
* For singleton: auto-activates if user-preferred or first registered.
|
|
353
|
-
* For collection: adds to active set and notifies consumers.
|
|
354
|
-
*/
|
|
355
|
-
registerProvider(capability, addonId, provider) {
|
|
356
|
-
const state = this.capabilities.get(capability);
|
|
357
|
-
if (!state) {
|
|
358
|
-
this.logger.warn(`Cannot register provider for undeclared capability "${capability}"`);
|
|
359
|
-
return;
|
|
360
|
-
}
|
|
361
|
-
state.available.set(addonId, provider);
|
|
362
|
-
this.logger.info(`Provider registered: ${addonId} \u2192 ${capability}`);
|
|
363
|
-
if (state.declaration.mode === "singleton") {
|
|
364
|
-
const userChoice = this.configReader(capability);
|
|
365
|
-
if (userChoice === addonId) {
|
|
366
|
-
this.activateSingleton(state, addonId, provider);
|
|
367
|
-
} else if (userChoice === void 0 && state.activeAddonId === null) {
|
|
368
|
-
this.activateSingleton(state, addonId, provider);
|
|
369
|
-
}
|
|
370
|
-
} else {
|
|
371
|
-
state.activeCollection.push({ addonId, provider });
|
|
372
|
-
for (const consumer of state.consumers) {
|
|
373
|
-
if (consumer.onAdded) {
|
|
374
|
-
try {
|
|
375
|
-
consumer.onAdded(provider);
|
|
376
|
-
} catch (error) {
|
|
377
|
-
const msg = error instanceof Error ? error.message : String(error);
|
|
378
|
-
this.logger.error(`Consumer onAdded failed for ${capability}: ${msg}`);
|
|
379
|
-
}
|
|
380
|
-
}
|
|
381
|
-
}
|
|
382
|
-
}
|
|
383
|
-
}
|
|
384
|
-
/**
|
|
385
|
-
* Unregister a provider (called when addon is disabled/uninstalled).
|
|
386
|
-
*/
|
|
387
|
-
unregisterProvider(capability, addonId) {
|
|
388
|
-
const state = this.capabilities.get(capability);
|
|
389
|
-
if (!state)
|
|
390
|
-
return;
|
|
391
|
-
const provider = state.available.get(addonId);
|
|
392
|
-
state.available.delete(addonId);
|
|
393
|
-
if (state.declaration.mode === "singleton") {
|
|
394
|
-
if (state.activeAddonId === addonId) {
|
|
395
|
-
state.activeAddonId = null;
|
|
396
|
-
state.activeProvider = null;
|
|
397
|
-
this.logger.info(`Singleton deactivated: ${capability} (was ${addonId})`);
|
|
398
|
-
}
|
|
399
|
-
} else {
|
|
400
|
-
const idx = state.activeCollection.findIndex((e) => e.addonId === addonId);
|
|
401
|
-
if (idx !== -1) {
|
|
402
|
-
state.activeCollection.splice(idx, 1);
|
|
403
|
-
for (const consumer of state.consumers) {
|
|
404
|
-
if (consumer.onRemoved && provider !== void 0) {
|
|
405
|
-
try {
|
|
406
|
-
consumer.onRemoved(provider);
|
|
407
|
-
} catch (error) {
|
|
408
|
-
const msg = error instanceof Error ? error.message : String(error);
|
|
409
|
-
this.logger.error(`Consumer onRemoved failed for ${capability}: ${msg}`);
|
|
410
|
-
}
|
|
411
|
-
}
|
|
412
|
-
}
|
|
413
|
-
}
|
|
414
|
-
}
|
|
415
|
-
}
|
|
416
|
-
/**
|
|
417
|
-
* Register a consumer that wants to be notified when providers change.
|
|
418
|
-
* If a provider is already active, the consumer is immediately notified.
|
|
419
|
-
* Returns a disposer function for cleanup.
|
|
420
|
-
*/
|
|
421
|
-
registerConsumer(registration) {
|
|
422
|
-
const state = this.capabilities.get(registration.capability);
|
|
423
|
-
if (!state) {
|
|
424
|
-
this.logger.debug(`Consumer registered for undeclared capability "${registration.capability}" \u2014 auto-declaring`);
|
|
425
|
-
this.declareCapability({ name: registration.capability, mode: "singleton" });
|
|
426
|
-
return this.registerConsumer(registration);
|
|
427
|
-
}
|
|
428
|
-
const untypedReg = registration;
|
|
429
|
-
state.consumers.add(untypedReg);
|
|
430
|
-
if (state.declaration.mode === "singleton") {
|
|
431
|
-
if (state.activeProvider !== null && registration.onSet) {
|
|
274
|
+
/**
|
|
275
|
+
* Unregister a provider (called when addon is disabled/uninstalled).
|
|
276
|
+
*/
|
|
277
|
+
unregisterProvider(capability, addonId) {
|
|
278
|
+
const state = this.capabilities.get(capability);
|
|
279
|
+
if (!state) return;
|
|
280
|
+
const provider = state.available.get(addonId);
|
|
281
|
+
state.available.delete(addonId);
|
|
282
|
+
if (state.declaration.mode === "singleton") {
|
|
283
|
+
if (state.activeAddonId === addonId) {
|
|
284
|
+
state.activeAddonId = null;
|
|
285
|
+
state.activeProvider = null;
|
|
286
|
+
this.logger.info(`Singleton deactivated: ${capability} (was ${addonId})`);
|
|
287
|
+
}
|
|
288
|
+
} else {
|
|
289
|
+
const idx = state.activeCollection.findIndex((e) => e.addonId === addonId);
|
|
290
|
+
if (idx !== -1) {
|
|
291
|
+
state.activeCollection.splice(idx, 1);
|
|
292
|
+
for (const consumer of state.consumers) {
|
|
293
|
+
if (consumer.onRemoved && provider !== void 0) {
|
|
432
294
|
try {
|
|
433
|
-
|
|
295
|
+
consumer.onRemoved(provider);
|
|
434
296
|
} catch (error) {
|
|
435
297
|
const msg = error instanceof Error ? error.message : String(error);
|
|
436
|
-
this.logger.error(`Consumer
|
|
437
|
-
}
|
|
438
|
-
}
|
|
439
|
-
} else {
|
|
440
|
-
if (registration.onAdded) {
|
|
441
|
-
for (const entry of state.activeCollection) {
|
|
442
|
-
try {
|
|
443
|
-
registration.onAdded(entry.provider);
|
|
444
|
-
} catch (error) {
|
|
445
|
-
const msg = error instanceof Error ? error.message : String(error);
|
|
446
|
-
this.logger.error(`Consumer onAdded (immediate) failed for ${registration.capability}: ${msg}`);
|
|
447
|
-
}
|
|
448
|
-
}
|
|
449
|
-
}
|
|
450
|
-
}
|
|
451
|
-
return () => {
|
|
452
|
-
state.consumers.delete(untypedReg);
|
|
453
|
-
};
|
|
454
|
-
}
|
|
455
|
-
/**
|
|
456
|
-
* Get the active singleton provider for a capability.
|
|
457
|
-
* Returns null if none set.
|
|
458
|
-
*/
|
|
459
|
-
getSingleton(capability) {
|
|
460
|
-
const state = this.capabilities.get(capability);
|
|
461
|
-
if (!state || state.declaration.mode !== "singleton")
|
|
462
|
-
return null;
|
|
463
|
-
return state.activeProvider ?? null;
|
|
464
|
-
}
|
|
465
|
-
/**
|
|
466
|
-
* Get all active collection providers for a capability.
|
|
467
|
-
*/
|
|
468
|
-
getCollection(capability) {
|
|
469
|
-
const state = this.capabilities.get(capability);
|
|
470
|
-
if (!state || state.declaration.mode !== "collection")
|
|
471
|
-
return [];
|
|
472
|
-
return state.activeCollection.map((e) => e.provider);
|
|
473
|
-
}
|
|
474
|
-
/**
|
|
475
|
-
* Set which addon should be the active singleton for a capability.
|
|
476
|
-
* Call with `immediate: true` to also swap the runtime provider now
|
|
477
|
-
* (consumers' onSet will be awaited).
|
|
478
|
-
*/
|
|
479
|
-
async setActiveSingleton(capability, addonId, immediate = false) {
|
|
480
|
-
const state = this.capabilities.get(capability);
|
|
481
|
-
if (!state) {
|
|
482
|
-
throw new Error(`Unknown capability: ${capability}`);
|
|
483
|
-
}
|
|
484
|
-
if (state.declaration.mode !== "singleton") {
|
|
485
|
-
throw new Error(`Capability "${capability}" is not a singleton`);
|
|
486
|
-
}
|
|
487
|
-
const provider = state.available.get(addonId);
|
|
488
|
-
if (!provider) {
|
|
489
|
-
throw new Error(`No provider "${addonId}" registered for capability "${capability}"`);
|
|
490
|
-
}
|
|
491
|
-
if (immediate) {
|
|
492
|
-
await this.activateSingletonAsync(state, addonId, provider);
|
|
493
|
-
}
|
|
494
|
-
this.logger.info(`Singleton preference set: ${capability} \u2192 ${addonId}`);
|
|
495
|
-
}
|
|
496
|
-
/**
|
|
497
|
-
* Get the mode declared for a capability.
|
|
498
|
-
*/
|
|
499
|
-
getMode(capability) {
|
|
500
|
-
return this.capabilities.get(capability)?.declaration.mode;
|
|
501
|
-
}
|
|
502
|
-
/**
|
|
503
|
-
* List all registered capabilities with their providers.
|
|
504
|
-
*/
|
|
505
|
-
listCapabilities() {
|
|
506
|
-
const result = [];
|
|
507
|
-
for (const [name, state] of this.capabilities) {
|
|
508
|
-
result.push({
|
|
509
|
-
name,
|
|
510
|
-
mode: state.declaration.mode,
|
|
511
|
-
providers: [...state.available.keys()],
|
|
512
|
-
activeProvider: state.activeAddonId
|
|
513
|
-
});
|
|
514
|
-
}
|
|
515
|
-
return result;
|
|
516
|
-
}
|
|
517
|
-
/**
|
|
518
|
-
* Check if all dependencies for a capability are satisfied (have active providers).
|
|
519
|
-
*/
|
|
520
|
-
areDependenciesMet(declaration) {
|
|
521
|
-
if (!declaration.dependsOn?.length)
|
|
522
|
-
return true;
|
|
523
|
-
return declaration.dependsOn.every((dep) => {
|
|
524
|
-
const state = this.capabilities.get(dep);
|
|
525
|
-
if (!state)
|
|
526
|
-
return false;
|
|
527
|
-
if (state.declaration.mode === "singleton") {
|
|
528
|
-
return state.activeProvider !== null;
|
|
529
|
-
}
|
|
530
|
-
return state.activeCollection.length > 0;
|
|
531
|
-
});
|
|
532
|
-
}
|
|
533
|
-
/**
|
|
534
|
-
* Get the dependency-ordered list of capability names for boot sequencing.
|
|
535
|
-
* Returns capabilities sorted topologically by dependsOn.
|
|
536
|
-
* Throws if a cycle is detected.
|
|
537
|
-
*/
|
|
538
|
-
getBootOrder() {
|
|
539
|
-
const visited = /* @__PURE__ */ new Set();
|
|
540
|
-
const visiting = /* @__PURE__ */ new Set();
|
|
541
|
-
const order = [];
|
|
542
|
-
const visit = (name) => {
|
|
543
|
-
if (visited.has(name))
|
|
544
|
-
return;
|
|
545
|
-
if (visiting.has(name)) {
|
|
546
|
-
throw new Error(`Circular dependency detected involving capability "${name}"`);
|
|
547
|
-
}
|
|
548
|
-
visiting.add(name);
|
|
549
|
-
const state = this.capabilities.get(name);
|
|
550
|
-
if (state?.declaration.dependsOn) {
|
|
551
|
-
for (const dep of state.declaration.dependsOn) {
|
|
552
|
-
visit(dep);
|
|
298
|
+
this.logger.error(`Consumer onRemoved failed for ${capability}: ${msg}`);
|
|
553
299
|
}
|
|
554
300
|
}
|
|
555
|
-
visiting.delete(name);
|
|
556
|
-
visited.add(name);
|
|
557
|
-
order.push(name);
|
|
558
|
-
};
|
|
559
|
-
for (const name of this.capabilities.keys()) {
|
|
560
|
-
visit(name);
|
|
561
301
|
}
|
|
562
|
-
return order;
|
|
563
302
|
}
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
}
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
}
|
|
588
|
-
/**
|
|
589
|
-
* Clear a per-device singleton override, reverting to the global singleton.
|
|
590
|
-
*/
|
|
591
|
-
clearDeviceOverride(deviceId, capability) {
|
|
592
|
-
const deviceMap = this.deviceOverrides.get(deviceId);
|
|
593
|
-
if (!deviceMap)
|
|
594
|
-
return;
|
|
595
|
-
deviceMap.delete(capability);
|
|
596
|
-
if (deviceMap.size === 0) {
|
|
597
|
-
this.deviceOverrides.delete(deviceId);
|
|
598
|
-
}
|
|
599
|
-
this.logger.info(`Device override cleared: ${deviceId} \u2192 ${capability}`);
|
|
600
|
-
}
|
|
601
|
-
/**
|
|
602
|
-
* Get all per-device singleton overrides for a device.
|
|
603
|
-
* Returns a Map of capability name to addon ID.
|
|
604
|
-
*/
|
|
605
|
-
getDeviceOverrides(deviceId) {
|
|
606
|
-
return new Map(this.deviceOverrides.get(deviceId) ?? []);
|
|
607
|
-
}
|
|
608
|
-
/**
|
|
609
|
-
* Resolve a singleton provider for a specific device.
|
|
610
|
-
* 1. Check device override — return that addon's provider
|
|
611
|
-
* 2. Fallback to global singleton
|
|
612
|
-
*/
|
|
613
|
-
resolveForDevice(capability, deviceId) {
|
|
614
|
-
const state = this.capabilities.get(capability);
|
|
615
|
-
if (!state || state.declaration.mode !== "singleton")
|
|
616
|
-
return null;
|
|
617
|
-
const deviceMap = this.deviceOverrides.get(deviceId);
|
|
618
|
-
if (deviceMap) {
|
|
619
|
-
const overrideAddonId = deviceMap.get(capability);
|
|
620
|
-
if (overrideAddonId) {
|
|
621
|
-
const provider = state.available.get(overrideAddonId);
|
|
622
|
-
if (provider)
|
|
623
|
-
return provider;
|
|
624
|
-
this.logger.warn(`Device override for ${deviceId}/${capability} references unregistered addon "${overrideAddonId}" \u2014 falling back to global`);
|
|
625
|
-
}
|
|
626
|
-
}
|
|
627
|
-
return state.activeProvider ?? null;
|
|
628
|
-
}
|
|
629
|
-
/**
|
|
630
|
-
* Set a per-device collection filter. When resolveCollectionForDevice is called
|
|
631
|
-
* for this device + capability, only providers from the specified addon IDs
|
|
632
|
-
* are returned instead of the full collection.
|
|
633
|
-
*/
|
|
634
|
-
setDeviceCollectionFilter(deviceId, capability, addonIds) {
|
|
635
|
-
const state = this.capabilities.get(capability);
|
|
636
|
-
if (!state) {
|
|
637
|
-
this.logger.warn(`Cannot set device collection filter for undeclared capability "${capability}"`);
|
|
638
|
-
return;
|
|
639
|
-
}
|
|
640
|
-
let deviceMap = this.deviceCollectionFilters.get(deviceId);
|
|
641
|
-
if (!deviceMap) {
|
|
642
|
-
deviceMap = /* @__PURE__ */ new Map();
|
|
643
|
-
this.deviceCollectionFilters.set(deviceId, deviceMap);
|
|
644
|
-
}
|
|
645
|
-
deviceMap.set(capability, [...addonIds]);
|
|
646
|
-
this.logger.info(`Device collection filter set: ${deviceId} \u2192 ${capability} = [${addonIds.join(", ")}]`);
|
|
647
|
-
}
|
|
648
|
-
/**
|
|
649
|
-
* Clear a per-device collection filter, reverting to the full collection.
|
|
650
|
-
*/
|
|
651
|
-
clearDeviceCollectionFilter(deviceId, capability) {
|
|
652
|
-
const deviceMap = this.deviceCollectionFilters.get(deviceId);
|
|
653
|
-
if (!deviceMap)
|
|
654
|
-
return;
|
|
655
|
-
deviceMap.delete(capability);
|
|
656
|
-
if (deviceMap.size === 0) {
|
|
657
|
-
this.deviceCollectionFilters.delete(deviceId);
|
|
658
|
-
}
|
|
659
|
-
this.logger.info(`Device collection filter cleared: ${deviceId} \u2192 ${capability}`);
|
|
660
|
-
}
|
|
661
|
-
/**
|
|
662
|
-
* Resolve collection providers for a specific device.
|
|
663
|
-
* If a filter exists for the device + capability, only those addon's providers are returned.
|
|
664
|
-
* If no filter exists, the full collection is returned.
|
|
665
|
-
*/
|
|
666
|
-
resolveCollectionForDevice(capability, deviceId) {
|
|
667
|
-
const state = this.capabilities.get(capability);
|
|
668
|
-
if (!state || state.declaration.mode !== "collection")
|
|
669
|
-
return [];
|
|
670
|
-
const deviceMap = this.deviceCollectionFilters.get(deviceId);
|
|
671
|
-
if (deviceMap) {
|
|
672
|
-
const filterAddonIds = deviceMap.get(capability);
|
|
673
|
-
if (filterAddonIds) {
|
|
674
|
-
const filterSet = new Set(filterAddonIds);
|
|
675
|
-
return state.activeCollection.filter((e) => filterSet.has(e.addonId)).map((e) => e.provider);
|
|
676
|
-
}
|
|
677
|
-
}
|
|
678
|
-
return state.activeCollection.map((e) => e.provider);
|
|
679
|
-
}
|
|
680
|
-
/**
|
|
681
|
-
* Get a specific addon's provider by addon ID, regardless of whether it's the active singleton.
|
|
682
|
-
* Useful for per-device overrides that need to look up any registered provider.
|
|
683
|
-
*/
|
|
684
|
-
getProviderByAddonId(capability, addonId) {
|
|
685
|
-
const state = this.capabilities.get(capability);
|
|
686
|
-
if (!state)
|
|
687
|
-
return null;
|
|
688
|
-
const provider = state.available.get(addonId);
|
|
689
|
-
return provider ?? null;
|
|
690
|
-
}
|
|
691
|
-
activateSingleton(state, addonId, provider) {
|
|
692
|
-
state.activeAddonId = addonId;
|
|
693
|
-
state.activeProvider = provider;
|
|
694
|
-
this.logger.info(`Singleton activated: ${state.declaration.name} \u2192 ${addonId}`);
|
|
695
|
-
for (const consumer of state.consumers) {
|
|
696
|
-
if (consumer.onSet) {
|
|
697
|
-
try {
|
|
698
|
-
consumer.onSet(provider);
|
|
699
|
-
} catch (error) {
|
|
700
|
-
const msg = error instanceof Error ? error.message : String(error);
|
|
701
|
-
this.logger.error(`Consumer onSet failed for ${state.declaration.name}: ${msg}`);
|
|
702
|
-
}
|
|
703
|
-
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* Register a consumer that wants to be notified when providers change.
|
|
307
|
+
* If a provider is already active, the consumer is immediately notified.
|
|
308
|
+
* Returns a disposer function for cleanup.
|
|
309
|
+
*/
|
|
310
|
+
registerConsumer(registration) {
|
|
311
|
+
const state = this.capabilities.get(registration.capability);
|
|
312
|
+
if (!state) {
|
|
313
|
+
this.logger.debug(`Consumer registered for undeclared capability "${registration.capability}" \u2014 auto-declaring`);
|
|
314
|
+
this.declareCapability({ name: registration.capability, mode: "singleton" });
|
|
315
|
+
return this.registerConsumer(registration);
|
|
316
|
+
}
|
|
317
|
+
const untypedReg = registration;
|
|
318
|
+
state.consumers.add(untypedReg);
|
|
319
|
+
if (state.declaration.mode === "singleton") {
|
|
320
|
+
if (state.activeProvider !== null && registration.onSet) {
|
|
321
|
+
try {
|
|
322
|
+
registration.onSet(state.activeProvider);
|
|
323
|
+
} catch (error) {
|
|
324
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
325
|
+
this.logger.error(`Consumer onSet (immediate) failed for ${registration.capability}: ${msg}`);
|
|
704
326
|
}
|
|
705
327
|
}
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
state.
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
} catch (error) {
|
|
715
|
-
const msg = error instanceof Error ? error.message : String(error);
|
|
716
|
-
this.logger.error(`Consumer onSet (async) failed for ${state.declaration.name}: ${msg}`);
|
|
717
|
-
}
|
|
328
|
+
} else {
|
|
329
|
+
if (registration.onAdded) {
|
|
330
|
+
for (const entry of state.activeCollection) {
|
|
331
|
+
try {
|
|
332
|
+
registration.onAdded(entry.provider);
|
|
333
|
+
} catch (error) {
|
|
334
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
335
|
+
this.logger.error(`Consumer onAdded (immediate) failed for ${registration.capability}: ${msg}`);
|
|
718
336
|
}
|
|
719
337
|
}
|
|
720
338
|
}
|
|
339
|
+
}
|
|
340
|
+
return () => {
|
|
341
|
+
state.consumers.delete(untypedReg);
|
|
721
342
|
};
|
|
722
|
-
exports.CapabilityRegistry = CapabilityRegistry2;
|
|
723
343
|
}
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
344
|
+
/**
|
|
345
|
+
* Get the active singleton provider for a capability.
|
|
346
|
+
* Returns null if none set.
|
|
347
|
+
*/
|
|
348
|
+
getSingleton(capability) {
|
|
349
|
+
const state = this.capabilities.get(capability);
|
|
350
|
+
if (!state || state.declaration.mode !== "singleton") return null;
|
|
351
|
+
return state.activeProvider ?? null;
|
|
352
|
+
}
|
|
353
|
+
/**
|
|
354
|
+
* Get all active collection providers for a capability.
|
|
355
|
+
*/
|
|
356
|
+
getCollection(capability) {
|
|
357
|
+
const state = this.capabilities.get(capability);
|
|
358
|
+
if (!state || state.declaration.mode !== "collection") return [];
|
|
359
|
+
return state.activeCollection.map((e) => e.provider);
|
|
360
|
+
}
|
|
361
|
+
/**
|
|
362
|
+
* Set which addon should be the active singleton for a capability.
|
|
363
|
+
* Call with `immediate: true` to also swap the runtime provider now
|
|
364
|
+
* (consumers' onSet will be awaited).
|
|
365
|
+
*/
|
|
366
|
+
async setActiveSingleton(capability, addonId, immediate = false) {
|
|
367
|
+
const state = this.capabilities.get(capability);
|
|
368
|
+
if (!state) {
|
|
369
|
+
throw new Error(`Unknown capability: ${capability}`);
|
|
370
|
+
}
|
|
371
|
+
if (state.declaration.mode !== "singleton") {
|
|
372
|
+
throw new Error(`Capability "${capability}" is not a singleton`);
|
|
741
373
|
}
|
|
374
|
+
const provider = state.available.get(addonId);
|
|
375
|
+
if (!provider) {
|
|
376
|
+
throw new Error(`No provider "${addonId}" registered for capability "${capability}"`);
|
|
377
|
+
}
|
|
378
|
+
if (immediate) {
|
|
379
|
+
await this.activateSingletonAsync(state, addonId, provider);
|
|
380
|
+
}
|
|
381
|
+
this.logger.info(`Singleton preference set: ${capability} \u2192 ${addonId}`);
|
|
742
382
|
}
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
"use strict";
|
|
749
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
750
|
-
exports.RUNTIME_DEFAULTS = exports.bootstrapSchema = exports.DEFAULT_DATA_PATH = void 0;
|
|
751
|
-
var zod_1 = __require("zod");
|
|
752
|
-
exports.DEFAULT_DATA_PATH = "camstack-data";
|
|
753
|
-
exports.bootstrapSchema = zod_1.z.object({
|
|
754
|
-
/** Server mode: 'hub' (full server) or 'agent' (worker node) */
|
|
755
|
-
mode: zod_1.z.enum(["hub", "agent"]).default("hub"),
|
|
756
|
-
server: zod_1.z.object({
|
|
757
|
-
port: zod_1.z.number().default(4443),
|
|
758
|
-
host: zod_1.z.string().default("0.0.0.0"),
|
|
759
|
-
dataPath: zod_1.z.string().default(exports.DEFAULT_DATA_PATH)
|
|
760
|
-
}).default({}),
|
|
761
|
-
auth: zod_1.z.object({
|
|
762
|
-
jwtSecret: zod_1.z.string().nullable().default(null),
|
|
763
|
-
adminUsername: zod_1.z.string().default("admin"),
|
|
764
|
-
adminPassword: zod_1.z.string().default(process.env.ADMIN_PASSWORD ?? "changeme")
|
|
765
|
-
}).default({}),
|
|
766
|
-
/** Hub connection config — only used when mode='agent' */
|
|
767
|
-
hub: zod_1.z.object({
|
|
768
|
-
url: zod_1.z.string().default("ws://localhost:4443/agent"),
|
|
769
|
-
token: zod_1.z.string().default("")
|
|
770
|
-
}).default({}),
|
|
771
|
-
/** Agent-specific config — only used when mode='agent' */
|
|
772
|
-
agent: zod_1.z.object({
|
|
773
|
-
name: zod_1.z.string().default(""),
|
|
774
|
-
/** Port for the agent status page (minimal HTML) */
|
|
775
|
-
statusPort: zod_1.z.number().default(4444)
|
|
776
|
-
}).default({}),
|
|
777
|
-
/** TLS configuration */
|
|
778
|
-
tls: zod_1.z.object({
|
|
779
|
-
/** Enable HTTPS (default: true) */
|
|
780
|
-
enabled: zod_1.z.boolean().default(true),
|
|
781
|
-
/** Path to custom cert file (PEM). If not set, auto-generates self-signed. */
|
|
782
|
-
certPath: zod_1.z.string().optional(),
|
|
783
|
-
/** Path to custom key file (PEM). Required if certPath is set. */
|
|
784
|
-
keyPath: zod_1.z.string().optional()
|
|
785
|
-
}).default({})
|
|
786
|
-
});
|
|
787
|
-
exports.RUNTIME_DEFAULTS = {
|
|
788
|
-
"features.streaming": true,
|
|
789
|
-
"features.notifications": true,
|
|
790
|
-
"features.objectDetection": false,
|
|
791
|
-
"features.remoteAccess": true,
|
|
792
|
-
"features.agentCluster": false,
|
|
793
|
-
"features.smartHome": true,
|
|
794
|
-
"features.recordings": true,
|
|
795
|
-
"features.backup": true,
|
|
796
|
-
"features.repl": true,
|
|
797
|
-
"retention.detectionEventsDays": 30,
|
|
798
|
-
"retention.audioLevelsDays": 7,
|
|
799
|
-
"logging.level": "info",
|
|
800
|
-
"logging.retentionDays": 30,
|
|
801
|
-
"eventBus.ringBufferSize": 1e4,
|
|
802
|
-
"storage.provider": "sqlite-storage",
|
|
803
|
-
"storage.locations": {
|
|
804
|
-
data: "camstack-data/data",
|
|
805
|
-
media: "camstack-data/media",
|
|
806
|
-
recordings: "camstack-data/recordings",
|
|
807
|
-
cache: "/tmp/camstack-cache",
|
|
808
|
-
logs: "camstack-data/logs",
|
|
809
|
-
models: "camstack-data/models"
|
|
810
|
-
},
|
|
811
|
-
"providers": [],
|
|
812
|
-
// Recording
|
|
813
|
-
"recording.segmentDurationSeconds": 4,
|
|
814
|
-
"recording.defaultRetentionDays": 30,
|
|
815
|
-
// Streaming ports are addon-specific (go2rtc owns its defaults)
|
|
816
|
-
// FFmpeg
|
|
817
|
-
"ffmpeg.binaryPath": "ffmpeg",
|
|
818
|
-
"ffmpeg.hwAccel": "auto",
|
|
819
|
-
"ffmpeg.threadCount": 0,
|
|
820
|
-
// Detection defaults
|
|
821
|
-
"detection.defaultMotionFps": 2,
|
|
822
|
-
"detection.defaultDetectionFps": 5,
|
|
823
|
-
"detection.defaultCooldownSeconds": 10,
|
|
824
|
-
"detection.defaultConfidenceThreshold": 0.4,
|
|
825
|
-
"detection.trackerMaxAgeFrames": 30,
|
|
826
|
-
"detection.trackerMinHits": 3,
|
|
827
|
-
"detection.trackerIouThreshold": 0.3,
|
|
828
|
-
// Backup retention is addon-specific (local-backup owns its default)
|
|
829
|
-
// Auth (runtime)
|
|
830
|
-
"auth.tokenExpiry": "24h"
|
|
831
|
-
};
|
|
383
|
+
/**
|
|
384
|
+
* Get the mode declared for a capability.
|
|
385
|
+
*/
|
|
386
|
+
getMode(capability) {
|
|
387
|
+
return this.capabilities.get(capability)?.declaration.mode;
|
|
832
388
|
}
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
389
|
+
/**
|
|
390
|
+
* List all registered capabilities with their providers.
|
|
391
|
+
*/
|
|
392
|
+
listCapabilities() {
|
|
393
|
+
const result = [];
|
|
394
|
+
for (const [name, state] of this.capabilities) {
|
|
395
|
+
result.push({
|
|
396
|
+
name,
|
|
397
|
+
mode: state.declaration.mode,
|
|
398
|
+
providers: [...state.available.keys()],
|
|
399
|
+
activeProvider: state.activeAddonId
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
return result;
|
|
403
|
+
}
|
|
404
|
+
/**
|
|
405
|
+
* Check if all dependencies for a capability are satisfied (have active providers).
|
|
406
|
+
*/
|
|
407
|
+
areDependenciesMet(declaration) {
|
|
408
|
+
if (!declaration.dependsOn?.length) return true;
|
|
409
|
+
return declaration.dependsOn.every((dep) => {
|
|
410
|
+
const state = this.capabilities.get(dep);
|
|
411
|
+
if (!state) return false;
|
|
412
|
+
if (state.declaration.mode === "singleton") {
|
|
413
|
+
return state.activeProvider !== null;
|
|
414
|
+
}
|
|
415
|
+
return state.activeCollection.length > 0;
|
|
856
416
|
});
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
CAMSTACK_PORT: "server.port",
|
|
883
|
-
CAMSTACK_HOST: "server.host",
|
|
884
|
-
CAMSTACK_DATA: "server.dataPath",
|
|
885
|
-
CAMSTACK_JWT_SECRET: "auth.jwtSecret",
|
|
886
|
-
CAMSTACK_ADMIN_USER: "auth.adminUsername",
|
|
887
|
-
CAMSTACK_ADMIN_PASS: "auth.adminPassword"
|
|
417
|
+
}
|
|
418
|
+
/**
|
|
419
|
+
* Get the dependency-ordered list of capability names for boot sequencing.
|
|
420
|
+
* Returns capabilities sorted topologically by dependsOn.
|
|
421
|
+
* Throws if a cycle is detected.
|
|
422
|
+
*/
|
|
423
|
+
getBootOrder() {
|
|
424
|
+
const visited = /* @__PURE__ */ new Set();
|
|
425
|
+
const visiting = /* @__PURE__ */ new Set();
|
|
426
|
+
const order = [];
|
|
427
|
+
const visit = (name) => {
|
|
428
|
+
if (visited.has(name)) return;
|
|
429
|
+
if (visiting.has(name)) {
|
|
430
|
+
throw new Error(`Circular dependency detected involving capability "${name}"`);
|
|
431
|
+
}
|
|
432
|
+
visiting.add(name);
|
|
433
|
+
const state = this.capabilities.get(name);
|
|
434
|
+
if (state?.declaration.dependsOn) {
|
|
435
|
+
for (const dep of state.declaration.dependsOn) {
|
|
436
|
+
visit(dep);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
visiting.delete(name);
|
|
440
|
+
visited.add(name);
|
|
441
|
+
order.push(name);
|
|
888
442
|
};
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
if (bootstrapValue !== void 0 && bootstrapValue !== null && typeof bootstrapValue === "object") {
|
|
955
|
-
return bootstrapValue;
|
|
956
|
-
}
|
|
957
|
-
return this.getFromRuntimeDefaults(section) ?? {};
|
|
958
|
-
}
|
|
959
|
-
/**
|
|
960
|
-
* Bulk-write a section of runtime settings to SQL system_settings.
|
|
961
|
-
* Each entry in `data` is stored as `section.key`.
|
|
962
|
-
*/
|
|
963
|
-
setSection(section, data) {
|
|
964
|
-
if (this.settingsStore === null) {
|
|
965
|
-
throw new Error("[ConfigManager] SettingsStore not initialized -- call setSettingsStore() first");
|
|
966
|
-
}
|
|
967
|
-
for (const [key, value] of Object.entries(data)) {
|
|
968
|
-
this.settingsStore.setSystem(`${section}.${key}`, value);
|
|
969
|
-
}
|
|
970
|
-
}
|
|
971
|
-
// ---------------------------------------------------------------------------
|
|
972
|
-
// Addon / Provider / Device scoped config
|
|
973
|
-
// ---------------------------------------------------------------------------
|
|
974
|
-
/** Read all config for an addon from addon_settings. */
|
|
975
|
-
getAddonConfig(addonId) {
|
|
976
|
-
if (this.settingsStore !== null) {
|
|
977
|
-
return this.settingsStore.getAllAddon(addonId);
|
|
978
|
-
}
|
|
979
|
-
return this.getFromBootstrap(`addons.${addonId}`) ?? {};
|
|
980
|
-
}
|
|
981
|
-
/** Write (bulk-replace) config for an addon to addon_settings. */
|
|
982
|
-
setAddonConfig(addonId, config) {
|
|
983
|
-
if (this.settingsStore === null) {
|
|
984
|
-
throw new Error("[ConfigManager] SettingsStore not initialized -- call setSettingsStore() first");
|
|
985
|
-
}
|
|
986
|
-
this.settingsStore.setAllAddon(addonId, config);
|
|
987
|
-
}
|
|
988
|
-
/** Read all config for a provider from provider_settings. */
|
|
989
|
-
getProviderConfig(providerId) {
|
|
990
|
-
if (this.settingsStore !== null) {
|
|
991
|
-
return this.settingsStore.getAllProvider(providerId);
|
|
992
|
-
}
|
|
993
|
-
return {};
|
|
994
|
-
}
|
|
995
|
-
/** Write (upsert) a single key for a provider to provider_settings. */
|
|
996
|
-
setProviderConfig(providerId, key, value) {
|
|
997
|
-
if (this.settingsStore === null) {
|
|
998
|
-
throw new Error("[ConfigManager] SettingsStore not initialized -- call setSettingsStore() first");
|
|
999
|
-
}
|
|
1000
|
-
this.settingsStore.setProvider(providerId, key, value);
|
|
1001
|
-
}
|
|
1002
|
-
/** Read all config for a device from device_settings. */
|
|
1003
|
-
getDeviceConfig(deviceId) {
|
|
1004
|
-
if (this.settingsStore !== null) {
|
|
1005
|
-
return this.settingsStore.getAllDevice(deviceId);
|
|
1006
|
-
}
|
|
1007
|
-
return {};
|
|
1008
|
-
}
|
|
1009
|
-
/** Write (upsert) a single key for a device to device_settings. */
|
|
1010
|
-
setDeviceConfig(deviceId, key, value) {
|
|
1011
|
-
if (this.settingsStore === null) {
|
|
1012
|
-
throw new Error("[ConfigManager] SettingsStore not initialized -- call setSettingsStore() first");
|
|
1013
|
-
}
|
|
1014
|
-
this.settingsStore.setDevice(deviceId, key, value);
|
|
1015
|
-
}
|
|
1016
|
-
/** Get a value from the parsed bootstrap config */
|
|
1017
|
-
getBootstrap(path) {
|
|
1018
|
-
return this.getFromBootstrap(path);
|
|
1019
|
-
}
|
|
1020
|
-
/** Features accessor -- reads from SQL when available, falls back to RUNTIME_DEFAULTS */
|
|
1021
|
-
get features() {
|
|
1022
|
-
const g = (key) => this.get(`features.${key}`) ?? config_schema_js_1.RUNTIME_DEFAULTS[`features.${key}`];
|
|
1023
|
-
return {
|
|
1024
|
-
streaming: g("streaming"),
|
|
1025
|
-
notifications: g("notifications"),
|
|
1026
|
-
objectDetection: g("objectDetection"),
|
|
1027
|
-
remoteAccess: g("remoteAccess"),
|
|
1028
|
-
agentCluster: g("agentCluster"),
|
|
1029
|
-
smartHome: g("smartHome"),
|
|
1030
|
-
recordings: g("recordings"),
|
|
1031
|
-
backup: g("backup"),
|
|
1032
|
-
repl: g("repl")
|
|
1033
|
-
};
|
|
1034
|
-
}
|
|
1035
|
-
/**
|
|
1036
|
-
* Returns a merged view of bootstrap config + runtime defaults for backward compat.
|
|
1037
|
-
*/
|
|
1038
|
-
get raw() {
|
|
1039
|
-
const features = {
|
|
1040
|
-
streaming: config_schema_js_1.RUNTIME_DEFAULTS["features.streaming"],
|
|
1041
|
-
notifications: config_schema_js_1.RUNTIME_DEFAULTS["features.notifications"],
|
|
1042
|
-
objectDetection: config_schema_js_1.RUNTIME_DEFAULTS["features.objectDetection"],
|
|
1043
|
-
remoteAccess: config_schema_js_1.RUNTIME_DEFAULTS["features.remoteAccess"],
|
|
1044
|
-
agentCluster: config_schema_js_1.RUNTIME_DEFAULTS["features.agentCluster"],
|
|
1045
|
-
smartHome: config_schema_js_1.RUNTIME_DEFAULTS["features.smartHome"],
|
|
1046
|
-
recordings: config_schema_js_1.RUNTIME_DEFAULTS["features.recordings"],
|
|
1047
|
-
backup: config_schema_js_1.RUNTIME_DEFAULTS["features.backup"],
|
|
1048
|
-
repl: config_schema_js_1.RUNTIME_DEFAULTS["features.repl"]
|
|
1049
|
-
};
|
|
1050
|
-
return {
|
|
1051
|
-
...this.bootstrapConfig,
|
|
1052
|
-
features,
|
|
1053
|
-
storage: config_schema_js_1.RUNTIME_DEFAULTS["storage.locations"] !== void 0 ? {
|
|
1054
|
-
provider: config_schema_js_1.RUNTIME_DEFAULTS["storage.provider"],
|
|
1055
|
-
locations: config_schema_js_1.RUNTIME_DEFAULTS["storage.locations"]
|
|
1056
|
-
} : { provider: "sqlite-storage", locations: {} },
|
|
1057
|
-
logging: {
|
|
1058
|
-
level: config_schema_js_1.RUNTIME_DEFAULTS["logging.level"],
|
|
1059
|
-
retentionDays: config_schema_js_1.RUNTIME_DEFAULTS["logging.retentionDays"]
|
|
1060
|
-
},
|
|
1061
|
-
eventBus: {
|
|
1062
|
-
ringBufferSize: config_schema_js_1.RUNTIME_DEFAULTS["eventBus.ringBufferSize"]
|
|
1063
|
-
},
|
|
1064
|
-
retention: {
|
|
1065
|
-
detectionEventsDays: config_schema_js_1.RUNTIME_DEFAULTS["retention.detectionEventsDays"],
|
|
1066
|
-
audioLevelsDays: config_schema_js_1.RUNTIME_DEFAULTS["retention.audioLevelsDays"]
|
|
1067
|
-
},
|
|
1068
|
-
providers: config_schema_js_1.RUNTIME_DEFAULTS["providers"]
|
|
1069
|
-
};
|
|
1070
|
-
}
|
|
1071
|
-
/** Sections that live in config.yaml. Everything else goes to SQL. */
|
|
1072
|
-
static BOOTSTRAP_SECTIONS = /* @__PURE__ */ new Set(["server", "auth", "mode"]);
|
|
1073
|
-
/**
|
|
1074
|
-
* Atomically update one top-level section of config.yaml and sync in-memory.
|
|
1075
|
-
* Only bootstrap sections (server, auth, mode) are written to YAML.
|
|
1076
|
-
* Runtime settings must use setSection() which writes to SQL.
|
|
1077
|
-
*/
|
|
1078
|
-
update(section, data) {
|
|
1079
|
-
if (!_ConfigManager.BOOTSTRAP_SECTIONS.has(section)) {
|
|
1080
|
-
throw new Error(`[ConfigManager] Section "${section}" is a runtime setting \u2014 use setSection() to write to DB, not update() which writes to config.yaml`);
|
|
1081
|
-
}
|
|
1082
|
-
let raw = {};
|
|
1083
|
-
if (fs.existsSync(this.configPath)) {
|
|
1084
|
-
raw = yaml.load(fs.readFileSync(this.configPath, "utf-8")) ?? {};
|
|
1085
|
-
}
|
|
1086
|
-
const existing = raw[section] ?? {};
|
|
1087
|
-
raw[section] = { ...existing, ...data };
|
|
1088
|
-
const validation = config_schema_js_1.bootstrapSchema.safeParse(raw);
|
|
1089
|
-
if (!validation.success) {
|
|
1090
|
-
throw new Error(`[ConfigManager] Invalid config update for section "${section}": ${validation.error.message}`);
|
|
1091
|
-
}
|
|
1092
|
-
const tmpPath = `${this.configPath}.tmp`;
|
|
1093
|
-
fs.writeFileSync(tmpPath, yaml.dump(raw, { lineWidth: 120, indent: 2, quotingType: '"' }), "utf-8");
|
|
1094
|
-
fs.renameSync(tmpPath, this.configPath);
|
|
1095
|
-
this.bootstrapConfig = validation.data;
|
|
1096
|
-
}
|
|
1097
|
-
/**
|
|
1098
|
-
* Deep-set a value in a nested plain object using a dot-notation path.
|
|
1099
|
-
* Returns a new object (immutable).
|
|
1100
|
-
*/
|
|
1101
|
-
setNested(obj, path, value) {
|
|
1102
|
-
const [head, ...rest] = path.split(".");
|
|
1103
|
-
if (!head)
|
|
1104
|
-
return obj;
|
|
1105
|
-
if (rest.length === 0) {
|
|
1106
|
-
return { ...obj, [head]: value };
|
|
1107
|
-
}
|
|
1108
|
-
const child = obj[head] ?? {};
|
|
1109
|
-
return { ...obj, [head]: this.setNested(child, rest.join("."), value) };
|
|
1110
|
-
}
|
|
1111
|
-
/**
|
|
1112
|
-
* Apply env var overrides onto the raw YAML object.
|
|
1113
|
-
* Only bootstrap-level env vars are applied.
|
|
1114
|
-
*/
|
|
1115
|
-
applyEnvOverrides(raw) {
|
|
1116
|
-
let result = { ...raw };
|
|
1117
|
-
for (const [envKey, configPath] of Object.entries(ENV_VAR_MAP)) {
|
|
1118
|
-
const envValue = process.env[envKey];
|
|
1119
|
-
if (envValue === void 0 || envValue === "")
|
|
1120
|
-
continue;
|
|
1121
|
-
const coerced = configPath === "server.port" ? Number(envValue) : envValue;
|
|
1122
|
-
result = this.setNested(result, configPath, coerced);
|
|
1123
|
-
console.log(`[ConfigManager] Env override applied: ${envKey} \u2192 ${configPath}`);
|
|
1124
|
-
}
|
|
1125
|
-
return result;
|
|
1126
|
-
}
|
|
1127
|
-
loadYaml() {
|
|
1128
|
-
if (!fs.existsSync(this.configPath)) {
|
|
1129
|
-
console.warn(`[ConfigManager] Config file not found at: ${this.configPath}
|
|
1130
|
-
\u2192 Using built-in defaults. Set CONFIG_PATH env var or create the file.
|
|
1131
|
-
\u2192 Example path from project root: ./server/backend/data/config.yaml`);
|
|
1132
|
-
return {};
|
|
1133
|
-
}
|
|
1134
|
-
const content = fs.readFileSync(this.configPath, "utf-8");
|
|
1135
|
-
const parsed = yaml.load(content) ?? {};
|
|
1136
|
-
console.log(`[ConfigManager] Loaded config from: ${this.configPath}`);
|
|
1137
|
-
return parsed;
|
|
1138
|
-
}
|
|
1139
|
-
warnDefaultCredentials() {
|
|
1140
|
-
if (this.bootstrapConfig.auth.adminPassword === "changeme") {
|
|
1141
|
-
console.warn(`[ConfigManager] Warning: Using default admin password "changeme". Set auth.adminPassword in your config.yaml or the ADMIN_PASSWORD env var.`);
|
|
1142
|
-
}
|
|
443
|
+
for (const name of this.capabilities.keys()) {
|
|
444
|
+
visit(name);
|
|
445
|
+
}
|
|
446
|
+
return order;
|
|
447
|
+
}
|
|
448
|
+
// ---- Per-device overrides ----
|
|
449
|
+
/**
|
|
450
|
+
* Set a per-device singleton override. When resolveForDevice is called for
|
|
451
|
+
* this device + capability, the specified addon's provider is returned
|
|
452
|
+
* instead of the global singleton.
|
|
453
|
+
*/
|
|
454
|
+
setDeviceOverride(deviceId, capability, addonId) {
|
|
455
|
+
const state = this.capabilities.get(capability);
|
|
456
|
+
if (!state) {
|
|
457
|
+
this.logger.warn(`Cannot set device override for undeclared capability "${capability}"`);
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
if (!state.available.has(addonId)) {
|
|
461
|
+
this.logger.warn(`Cannot set device override: addon "${addonId}" not registered for "${capability}"`);
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
let deviceMap = this.deviceOverrides.get(deviceId);
|
|
465
|
+
if (!deviceMap) {
|
|
466
|
+
deviceMap = /* @__PURE__ */ new Map();
|
|
467
|
+
this.deviceOverrides.set(deviceId, deviceMap);
|
|
468
|
+
}
|
|
469
|
+
deviceMap.set(capability, addonId);
|
|
470
|
+
this.logger.info(`Device override set: ${deviceId} \u2192 ${capability} = ${addonId}`);
|
|
471
|
+
}
|
|
472
|
+
/**
|
|
473
|
+
* Clear a per-device singleton override, reverting to the global singleton.
|
|
474
|
+
*/
|
|
475
|
+
clearDeviceOverride(deviceId, capability) {
|
|
476
|
+
const deviceMap = this.deviceOverrides.get(deviceId);
|
|
477
|
+
if (!deviceMap) return;
|
|
478
|
+
deviceMap.delete(capability);
|
|
479
|
+
if (deviceMap.size === 0) {
|
|
480
|
+
this.deviceOverrides.delete(deviceId);
|
|
481
|
+
}
|
|
482
|
+
this.logger.info(`Device override cleared: ${deviceId} \u2192 ${capability}`);
|
|
483
|
+
}
|
|
484
|
+
/**
|
|
485
|
+
* Get all per-device singleton overrides for a device.
|
|
486
|
+
* Returns a Map of capability name to addon ID.
|
|
487
|
+
*/
|
|
488
|
+
getDeviceOverrides(deviceId) {
|
|
489
|
+
return new Map(this.deviceOverrides.get(deviceId) ?? []);
|
|
490
|
+
}
|
|
491
|
+
/**
|
|
492
|
+
* Resolve a singleton provider for a specific device.
|
|
493
|
+
* 1. Check device override — return that addon's provider
|
|
494
|
+
* 2. Fallback to global singleton
|
|
495
|
+
*/
|
|
496
|
+
resolveForDevice(capability, deviceId) {
|
|
497
|
+
const state = this.capabilities.get(capability);
|
|
498
|
+
if (!state || state.declaration.mode !== "singleton") return null;
|
|
499
|
+
const deviceMap = this.deviceOverrides.get(deviceId);
|
|
500
|
+
if (deviceMap) {
|
|
501
|
+
const overrideAddonId = deviceMap.get(capability);
|
|
502
|
+
if (overrideAddonId) {
|
|
503
|
+
const provider = state.available.get(overrideAddonId);
|
|
504
|
+
if (provider) return provider;
|
|
505
|
+
this.logger.warn(
|
|
506
|
+
`Device override for ${deviceId}/${capability} references unregistered addon "${overrideAddonId}" \u2014 falling back to global`
|
|
507
|
+
);
|
|
1143
508
|
}
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
509
|
+
}
|
|
510
|
+
return state.activeProvider ?? null;
|
|
511
|
+
}
|
|
512
|
+
/**
|
|
513
|
+
* Set a per-device collection filter. When resolveCollectionForDevice is called
|
|
514
|
+
* for this device + capability, only providers from the specified addon IDs
|
|
515
|
+
* are returned instead of the full collection.
|
|
516
|
+
*/
|
|
517
|
+
setDeviceCollectionFilter(deviceId, capability, addonIds) {
|
|
518
|
+
const state = this.capabilities.get(capability);
|
|
519
|
+
if (!state) {
|
|
520
|
+
this.logger.warn(`Cannot set device collection filter for undeclared capability "${capability}"`);
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
let deviceMap = this.deviceCollectionFilters.get(deviceId);
|
|
524
|
+
if (!deviceMap) {
|
|
525
|
+
deviceMap = /* @__PURE__ */ new Map();
|
|
526
|
+
this.deviceCollectionFilters.set(deviceId, deviceMap);
|
|
527
|
+
}
|
|
528
|
+
deviceMap.set(capability, [...addonIds]);
|
|
529
|
+
this.logger.info(`Device collection filter set: ${deviceId} \u2192 ${capability} = [${addonIds.join(", ")}]`);
|
|
530
|
+
}
|
|
531
|
+
/**
|
|
532
|
+
* Clear a per-device collection filter, reverting to the full collection.
|
|
533
|
+
*/
|
|
534
|
+
clearDeviceCollectionFilter(deviceId, capability) {
|
|
535
|
+
const deviceMap = this.deviceCollectionFilters.get(deviceId);
|
|
536
|
+
if (!deviceMap) return;
|
|
537
|
+
deviceMap.delete(capability);
|
|
538
|
+
if (deviceMap.size === 0) {
|
|
539
|
+
this.deviceCollectionFilters.delete(deviceId);
|
|
540
|
+
}
|
|
541
|
+
this.logger.info(`Device collection filter cleared: ${deviceId} \u2192 ${capability}`);
|
|
542
|
+
}
|
|
543
|
+
/**
|
|
544
|
+
* Resolve collection providers for a specific device.
|
|
545
|
+
* If a filter exists for the device + capability, only those addon's providers are returned.
|
|
546
|
+
* If no filter exists, the full collection is returned.
|
|
547
|
+
*/
|
|
548
|
+
resolveCollectionForDevice(capability, deviceId) {
|
|
549
|
+
const state = this.capabilities.get(capability);
|
|
550
|
+
if (!state || state.declaration.mode !== "collection") return [];
|
|
551
|
+
const deviceMap = this.deviceCollectionFilters.get(deviceId);
|
|
552
|
+
if (deviceMap) {
|
|
553
|
+
const filterAddonIds = deviceMap.get(capability);
|
|
554
|
+
if (filterAddonIds) {
|
|
555
|
+
const filterSet = new Set(filterAddonIds);
|
|
556
|
+
return state.activeCollection.filter((e) => filterSet.has(e.addonId)).map((e) => e.provider);
|
|
1154
557
|
}
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
558
|
+
}
|
|
559
|
+
return state.activeCollection.map((e) => e.provider);
|
|
560
|
+
}
|
|
561
|
+
/**
|
|
562
|
+
* Get a specific addon's provider by addon ID, regardless of whether it's the active singleton.
|
|
563
|
+
* Useful for per-device overrides that need to look up any registered provider.
|
|
564
|
+
*/
|
|
565
|
+
getProviderByAddonId(capability, addonId) {
|
|
566
|
+
const state = this.capabilities.get(capability);
|
|
567
|
+
if (!state) return null;
|
|
568
|
+
const provider = state.available.get(addonId);
|
|
569
|
+
return provider ?? null;
|
|
570
|
+
}
|
|
571
|
+
activateSingleton(state, addonId, provider) {
|
|
572
|
+
state.activeAddonId = addonId;
|
|
573
|
+
state.activeProvider = provider;
|
|
574
|
+
this.logger.info(`Singleton activated: ${state.declaration.name} \u2192 ${addonId}`);
|
|
575
|
+
for (const consumer of state.consumers) {
|
|
576
|
+
if (consumer.onSet) {
|
|
577
|
+
try {
|
|
578
|
+
consumer.onSet(provider);
|
|
579
|
+
} catch (error) {
|
|
580
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
581
|
+
this.logger.error(`Consumer onSet failed for ${state.declaration.name}: ${msg}`);
|
|
1165
582
|
}
|
|
1166
|
-
return found ? result : void 0;
|
|
1167
583
|
}
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
if (key.startsWith(prefix)) {
|
|
1182
|
-
result[key.slice(prefix.length)] = value;
|
|
1183
|
-
found = true;
|
|
1184
|
-
}
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
async activateSingletonAsync(state, addonId, provider) {
|
|
587
|
+
state.activeAddonId = addonId;
|
|
588
|
+
state.activeProvider = provider;
|
|
589
|
+
this.logger.info(`Singleton activated (async): ${state.declaration.name} \u2192 ${addonId}`);
|
|
590
|
+
for (const consumer of state.consumers) {
|
|
591
|
+
if (consumer.onSet) {
|
|
592
|
+
try {
|
|
593
|
+
await consumer.onSet(provider);
|
|
594
|
+
} catch (error) {
|
|
595
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
596
|
+
this.logger.error(`Consumer onSet (async) failed for ${state.declaration.name}: ${msg}`);
|
|
1185
597
|
}
|
|
1186
|
-
return found ? result : void 0;
|
|
1187
598
|
}
|
|
1188
|
-
}
|
|
1189
|
-
exports.ConfigManager = ConfigManager2;
|
|
599
|
+
}
|
|
1190
600
|
}
|
|
601
|
+
};
|
|
602
|
+
|
|
603
|
+
// src/infra-capabilities.ts
|
|
604
|
+
var INFRA_CAPABILITIES = [
|
|
605
|
+
{ name: "storage", required: true },
|
|
606
|
+
{ name: "settings-store", required: true },
|
|
607
|
+
{ name: "log-destination", required: false }
|
|
608
|
+
];
|
|
609
|
+
var infraNames = new Set(INFRA_CAPABILITIES.map((c) => c.name));
|
|
610
|
+
function isInfraCapability(name) {
|
|
611
|
+
return infraNames.has(name);
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// src/config-manager.ts
|
|
615
|
+
import * as fs3 from "fs";
|
|
616
|
+
import * as yaml from "js-yaml";
|
|
617
|
+
|
|
618
|
+
// src/config-schema.ts
|
|
619
|
+
import { z } from "zod";
|
|
620
|
+
var DEFAULT_DATA_PATH = "camstack-data";
|
|
621
|
+
var bootstrapSchema = z.object({
|
|
622
|
+
/** Server mode: 'hub' (full server) or 'agent' (worker node) */
|
|
623
|
+
mode: z.enum(["hub", "agent"]).default("hub"),
|
|
624
|
+
server: z.object({
|
|
625
|
+
port: z.number().default(4443),
|
|
626
|
+
host: z.string().default("0.0.0.0"),
|
|
627
|
+
dataPath: z.string().default(DEFAULT_DATA_PATH)
|
|
628
|
+
}).default({}),
|
|
629
|
+
auth: z.object({
|
|
630
|
+
jwtSecret: z.string().nullable().default(null),
|
|
631
|
+
adminUsername: z.string().default("admin"),
|
|
632
|
+
adminPassword: z.string().default(process.env.ADMIN_PASSWORD ?? "changeme")
|
|
633
|
+
}).default({}),
|
|
634
|
+
/** Hub connection config — only used when mode='agent' */
|
|
635
|
+
hub: z.object({
|
|
636
|
+
url: z.string().default("ws://localhost:4443/agent"),
|
|
637
|
+
token: z.string().default("")
|
|
638
|
+
}).default({}),
|
|
639
|
+
/** Agent-specific config — only used when mode='agent' */
|
|
640
|
+
agent: z.object({
|
|
641
|
+
name: z.string().default(""),
|
|
642
|
+
/** Port for the agent status page (minimal HTML) */
|
|
643
|
+
statusPort: z.number().default(4444)
|
|
644
|
+
}).default({}),
|
|
645
|
+
/** TLS configuration */
|
|
646
|
+
tls: z.object({
|
|
647
|
+
/** Enable HTTPS (default: true) */
|
|
648
|
+
enabled: z.boolean().default(true),
|
|
649
|
+
/** Path to custom cert file (PEM). If not set, auto-generates self-signed. */
|
|
650
|
+
certPath: z.string().optional(),
|
|
651
|
+
/** Path to custom key file (PEM). Required if certPath is set. */
|
|
652
|
+
keyPath: z.string().optional()
|
|
653
|
+
}).default({})
|
|
1191
654
|
});
|
|
655
|
+
var RUNTIME_DEFAULTS = {
|
|
656
|
+
"features.streaming": true,
|
|
657
|
+
"features.notifications": true,
|
|
658
|
+
"features.objectDetection": false,
|
|
659
|
+
"features.remoteAccess": true,
|
|
660
|
+
"features.agentCluster": false,
|
|
661
|
+
"features.smartHome": true,
|
|
662
|
+
"features.recordings": true,
|
|
663
|
+
"features.backup": true,
|
|
664
|
+
"features.repl": true,
|
|
665
|
+
"retention.detectionEventsDays": 30,
|
|
666
|
+
"retention.audioLevelsDays": 7,
|
|
667
|
+
"logging.level": "info",
|
|
668
|
+
"logging.retentionDays": 30,
|
|
669
|
+
"eventBus.ringBufferSize": 1e4,
|
|
670
|
+
"storage.provider": "sqlite-storage",
|
|
671
|
+
"storage.locations": {
|
|
672
|
+
data: "camstack-data/data",
|
|
673
|
+
media: "camstack-data/media",
|
|
674
|
+
recordings: "camstack-data/recordings",
|
|
675
|
+
cache: "/tmp/camstack-cache",
|
|
676
|
+
logs: "camstack-data/logs",
|
|
677
|
+
models: "camstack-data/models"
|
|
678
|
+
},
|
|
679
|
+
"providers": [],
|
|
680
|
+
// Recording
|
|
681
|
+
"recording.segmentDurationSeconds": 4,
|
|
682
|
+
"recording.defaultRetentionDays": 30,
|
|
683
|
+
// Streaming ports are addon-specific (go2rtc owns its defaults)
|
|
684
|
+
// FFmpeg
|
|
685
|
+
"ffmpeg.binaryPath": "ffmpeg",
|
|
686
|
+
"ffmpeg.hwAccel": "auto",
|
|
687
|
+
"ffmpeg.threadCount": 0,
|
|
688
|
+
// Detection defaults
|
|
689
|
+
"detection.defaultMotionFps": 2,
|
|
690
|
+
"detection.defaultDetectionFps": 5,
|
|
691
|
+
"detection.defaultCooldownSeconds": 10,
|
|
692
|
+
"detection.defaultConfidenceThreshold": 0.4,
|
|
693
|
+
"detection.trackerMaxAgeFrames": 30,
|
|
694
|
+
"detection.trackerMinHits": 3,
|
|
695
|
+
"detection.trackerIouThreshold": 0.3,
|
|
696
|
+
// Backup retention is addon-specific (local-backup owns its default)
|
|
697
|
+
// Auth (runtime)
|
|
698
|
+
"auth.tokenExpiry": "24h"
|
|
699
|
+
};
|
|
1192
700
|
|
|
1193
|
-
// src/
|
|
1194
|
-
var
|
|
1195
|
-
"
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
701
|
+
// src/config-manager.ts
|
|
702
|
+
var ENV_VAR_MAP = {
|
|
703
|
+
CAMSTACK_PORT: "server.port",
|
|
704
|
+
CAMSTACK_HOST: "server.host",
|
|
705
|
+
CAMSTACK_DATA: "server.dataPath",
|
|
706
|
+
CAMSTACK_JWT_SECRET: "auth.jwtSecret",
|
|
707
|
+
CAMSTACK_ADMIN_USER: "auth.adminUsername",
|
|
708
|
+
CAMSTACK_ADMIN_PASS: "auth.adminPassword"
|
|
709
|
+
};
|
|
710
|
+
var ConfigManager = class _ConfigManager {
|
|
711
|
+
constructor(configPath) {
|
|
712
|
+
this.configPath = configPath;
|
|
713
|
+
const rawYaml = this.loadYaml();
|
|
714
|
+
const merged = this.applyEnvOverrides(rawYaml);
|
|
715
|
+
this.bootstrapConfig = bootstrapSchema.parse(merged);
|
|
716
|
+
this.warnDefaultCredentials();
|
|
717
|
+
}
|
|
718
|
+
// Non-readonly so update() can sync the in-memory view after a write.
|
|
719
|
+
bootstrapConfig;
|
|
720
|
+
settingsStore = null;
|
|
721
|
+
/** Called by main.ts after the SQLite DB is ready (Phase 2). */
|
|
722
|
+
setSettingsStore(store) {
|
|
723
|
+
this.settingsStore = store;
|
|
724
|
+
}
|
|
725
|
+
/**
|
|
726
|
+
* Get a config value by dot-notation path.
|
|
727
|
+
* Priority: bootstrap config -> SQL system_settings -> RUNTIME_DEFAULTS fallback.
|
|
728
|
+
*/
|
|
729
|
+
get(path3) {
|
|
730
|
+
const bootstrapValue = this.getFromBootstrap(path3);
|
|
731
|
+
if (bootstrapValue !== void 0) {
|
|
732
|
+
return bootstrapValue;
|
|
733
|
+
}
|
|
734
|
+
if (this.settingsStore !== null) {
|
|
735
|
+
const sqlValue = this.settingsStore.getSystem(path3);
|
|
736
|
+
if (sqlValue !== void 0) {
|
|
737
|
+
return sqlValue;
|
|
1223
738
|
}
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
return
|
|
739
|
+
const sqlNested = this.getNestedFromSystemSettings(path3);
|
|
740
|
+
if (sqlNested !== void 0) {
|
|
741
|
+
return sqlNested;
|
|
1227
742
|
}
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
743
|
+
}
|
|
744
|
+
if (path3 in RUNTIME_DEFAULTS) {
|
|
745
|
+
return RUNTIME_DEFAULTS[path3];
|
|
746
|
+
}
|
|
747
|
+
const nested = this.getFromRuntimeDefaults(path3);
|
|
748
|
+
if (nested !== void 0) {
|
|
749
|
+
return nested;
|
|
750
|
+
}
|
|
751
|
+
return void 0;
|
|
752
|
+
}
|
|
753
|
+
/**
|
|
754
|
+
* Write a value to SQL system_settings.
|
|
755
|
+
* Throws if the settings store is not yet wired.
|
|
756
|
+
*/
|
|
757
|
+
set(key, value) {
|
|
758
|
+
if (this.settingsStore === null) {
|
|
759
|
+
throw new Error("[ConfigManager] SettingsStore not initialized -- call setSettingsStore() first");
|
|
760
|
+
}
|
|
761
|
+
this.settingsStore.setSystem(key, value);
|
|
762
|
+
}
|
|
763
|
+
/**
|
|
764
|
+
* Bulk-read all system_settings keys that belong to a logical section.
|
|
765
|
+
* A "section" is the first segment of a dot-notation key (e.g. 'features', 'logging').
|
|
766
|
+
*/
|
|
767
|
+
getSection(section) {
|
|
768
|
+
if (this.settingsStore !== null) {
|
|
769
|
+
const nested = this.getNestedFromSystemSettings(section);
|
|
770
|
+
if (nested !== void 0) return nested;
|
|
771
|
+
}
|
|
772
|
+
const bootstrapValue = this.bootstrapConfig[section];
|
|
773
|
+
if (bootstrapValue !== void 0 && bootstrapValue !== null && typeof bootstrapValue === "object") {
|
|
774
|
+
return bootstrapValue;
|
|
775
|
+
}
|
|
776
|
+
return this.getFromRuntimeDefaults(section) ?? {};
|
|
777
|
+
}
|
|
778
|
+
/**
|
|
779
|
+
* Bulk-write a section of runtime settings to SQL system_settings.
|
|
780
|
+
* Each entry in `data` is stored as `section.key`.
|
|
781
|
+
*/
|
|
782
|
+
setSection(section, data) {
|
|
783
|
+
if (this.settingsStore === null) {
|
|
784
|
+
throw new Error("[ConfigManager] SettingsStore not initialized -- call setSettingsStore() first");
|
|
785
|
+
}
|
|
786
|
+
for (const [key, value] of Object.entries(data)) {
|
|
787
|
+
this.settingsStore.setSystem(`${section}.${key}`, value);
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
// ---------------------------------------------------------------------------
|
|
791
|
+
// Addon / Provider / Device scoped config
|
|
792
|
+
// ---------------------------------------------------------------------------
|
|
793
|
+
/** Read all config for an addon from addon_settings. */
|
|
794
|
+
getAddonConfig(addonId) {
|
|
795
|
+
if (this.settingsStore !== null) {
|
|
796
|
+
return this.settingsStore.getAllAddon(addonId);
|
|
797
|
+
}
|
|
798
|
+
return this.getFromBootstrap(`addons.${addonId}`) ?? {};
|
|
799
|
+
}
|
|
800
|
+
/** Write (bulk-replace) config for an addon to addon_settings. */
|
|
801
|
+
setAddonConfig(addonId, config) {
|
|
802
|
+
if (this.settingsStore === null) {
|
|
803
|
+
throw new Error("[ConfigManager] SettingsStore not initialized -- call setSettingsStore() first");
|
|
804
|
+
}
|
|
805
|
+
this.settingsStore.setAllAddon(addonId, config);
|
|
806
|
+
}
|
|
807
|
+
/** Read all config for a provider from provider_settings. */
|
|
808
|
+
getProviderConfig(providerId) {
|
|
809
|
+
if (this.settingsStore !== null) {
|
|
810
|
+
return this.settingsStore.getAllProvider(providerId);
|
|
811
|
+
}
|
|
812
|
+
return {};
|
|
813
|
+
}
|
|
814
|
+
/** Write (upsert) a single key for a provider to provider_settings. */
|
|
815
|
+
setProviderConfig(providerId, key, value) {
|
|
816
|
+
if (this.settingsStore === null) {
|
|
817
|
+
throw new Error("[ConfigManager] SettingsStore not initialized -- call setSettingsStore() first");
|
|
818
|
+
}
|
|
819
|
+
this.settingsStore.setProvider(providerId, key, value);
|
|
820
|
+
}
|
|
821
|
+
/** Read all config for a device from device_settings. */
|
|
822
|
+
getDeviceConfig(deviceId) {
|
|
823
|
+
if (this.settingsStore !== null) {
|
|
824
|
+
return this.settingsStore.getAllDevice(deviceId);
|
|
825
|
+
}
|
|
826
|
+
return {};
|
|
827
|
+
}
|
|
828
|
+
/** Write (upsert) a single key for a device to device_settings. */
|
|
829
|
+
setDeviceConfig(deviceId, key, value) {
|
|
830
|
+
if (this.settingsStore === null) {
|
|
831
|
+
throw new Error("[ConfigManager] SettingsStore not initialized -- call setSettingsStore() first");
|
|
832
|
+
}
|
|
833
|
+
this.settingsStore.setDevice(deviceId, key, value);
|
|
834
|
+
}
|
|
835
|
+
/** Get a value from the parsed bootstrap config */
|
|
836
|
+
getBootstrap(path3) {
|
|
837
|
+
return this.getFromBootstrap(path3);
|
|
838
|
+
}
|
|
839
|
+
/** Features accessor -- reads from SQL when available, falls back to RUNTIME_DEFAULTS */
|
|
840
|
+
get features() {
|
|
841
|
+
const g = (key) => this.get(`features.${key}`) ?? RUNTIME_DEFAULTS[`features.${key}`];
|
|
842
|
+
return {
|
|
843
|
+
streaming: g("streaming"),
|
|
844
|
+
notifications: g("notifications"),
|
|
845
|
+
objectDetection: g("objectDetection"),
|
|
846
|
+
remoteAccess: g("remoteAccess"),
|
|
847
|
+
agentCluster: g("agentCluster"),
|
|
848
|
+
smartHome: g("smartHome"),
|
|
849
|
+
recordings: g("recordings"),
|
|
850
|
+
backup: g("backup"),
|
|
851
|
+
repl: g("repl")
|
|
852
|
+
};
|
|
853
|
+
}
|
|
854
|
+
/**
|
|
855
|
+
* Returns a merged view of bootstrap config + runtime defaults for backward compat.
|
|
856
|
+
*/
|
|
857
|
+
get raw() {
|
|
858
|
+
const features = {
|
|
859
|
+
streaming: RUNTIME_DEFAULTS["features.streaming"],
|
|
860
|
+
notifications: RUNTIME_DEFAULTS["features.notifications"],
|
|
861
|
+
objectDetection: RUNTIME_DEFAULTS["features.objectDetection"],
|
|
862
|
+
remoteAccess: RUNTIME_DEFAULTS["features.remoteAccess"],
|
|
863
|
+
agentCluster: RUNTIME_DEFAULTS["features.agentCluster"],
|
|
864
|
+
smartHome: RUNTIME_DEFAULTS["features.smartHome"],
|
|
865
|
+
recordings: RUNTIME_DEFAULTS["features.recordings"],
|
|
866
|
+
backup: RUNTIME_DEFAULTS["features.backup"],
|
|
867
|
+
repl: RUNTIME_DEFAULTS["features.repl"]
|
|
868
|
+
};
|
|
869
|
+
return {
|
|
870
|
+
...this.bootstrapConfig,
|
|
871
|
+
features,
|
|
872
|
+
storage: RUNTIME_DEFAULTS["storage.locations"] !== void 0 ? {
|
|
873
|
+
provider: RUNTIME_DEFAULTS["storage.provider"],
|
|
874
|
+
locations: RUNTIME_DEFAULTS["storage.locations"]
|
|
875
|
+
} : { provider: "sqlite-storage", locations: {} },
|
|
876
|
+
logging: {
|
|
877
|
+
level: RUNTIME_DEFAULTS["logging.level"],
|
|
878
|
+
retentionDays: RUNTIME_DEFAULTS["logging.retentionDays"]
|
|
879
|
+
},
|
|
880
|
+
eventBus: {
|
|
881
|
+
ringBufferSize: RUNTIME_DEFAULTS["eventBus.ringBufferSize"]
|
|
882
|
+
},
|
|
883
|
+
retention: {
|
|
884
|
+
detectionEventsDays: RUNTIME_DEFAULTS["retention.detectionEventsDays"],
|
|
885
|
+
audioLevelsDays: RUNTIME_DEFAULTS["retention.audioLevelsDays"]
|
|
886
|
+
},
|
|
887
|
+
providers: RUNTIME_DEFAULTS["providers"]
|
|
888
|
+
};
|
|
889
|
+
}
|
|
890
|
+
/** Sections that live in config.yaml. Everything else goes to SQL. */
|
|
891
|
+
static BOOTSTRAP_SECTIONS = /* @__PURE__ */ new Set(["server", "auth", "mode"]);
|
|
892
|
+
/**
|
|
893
|
+
* Atomically update one top-level section of config.yaml and sync in-memory.
|
|
894
|
+
* Only bootstrap sections (server, auth, mode) are written to YAML.
|
|
895
|
+
* Runtime settings must use setSection() which writes to SQL.
|
|
896
|
+
*/
|
|
897
|
+
update(section, data) {
|
|
898
|
+
if (!_ConfigManager.BOOTSTRAP_SECTIONS.has(section)) {
|
|
899
|
+
throw new Error(
|
|
900
|
+
`[ConfigManager] Section "${section}" is a runtime setting \u2014 use setSection() to write to DB, not update() which writes to config.yaml`
|
|
901
|
+
);
|
|
902
|
+
}
|
|
903
|
+
let raw = {};
|
|
904
|
+
if (fs3.existsSync(this.configPath)) {
|
|
905
|
+
raw = yaml.load(fs3.readFileSync(this.configPath, "utf-8")) ?? {};
|
|
906
|
+
}
|
|
907
|
+
const existing = raw[section] ?? {};
|
|
908
|
+
raw[section] = { ...existing, ...data };
|
|
909
|
+
const validation = bootstrapSchema.safeParse(raw);
|
|
910
|
+
if (!validation.success) {
|
|
911
|
+
throw new Error(`[ConfigManager] Invalid config update for section "${section}": ${validation.error.message}`);
|
|
912
|
+
}
|
|
913
|
+
const tmpPath = `${this.configPath}.tmp`;
|
|
914
|
+
fs3.writeFileSync(tmpPath, yaml.dump(raw, { lineWidth: 120, indent: 2, quotingType: '"' }), "utf-8");
|
|
915
|
+
fs3.renameSync(tmpPath, this.configPath);
|
|
916
|
+
this.bootstrapConfig = validation.data;
|
|
917
|
+
}
|
|
918
|
+
/**
|
|
919
|
+
* Deep-set a value in a nested plain object using a dot-notation path.
|
|
920
|
+
* Returns a new object (immutable).
|
|
921
|
+
*/
|
|
922
|
+
setNested(obj, path3, value) {
|
|
923
|
+
const [head, ...rest] = path3.split(".");
|
|
924
|
+
if (!head) return obj;
|
|
925
|
+
if (rest.length === 0) {
|
|
926
|
+
return { ...obj, [head]: value };
|
|
927
|
+
}
|
|
928
|
+
const child = obj[head] ?? {};
|
|
929
|
+
return { ...obj, [head]: this.setNested(child, rest.join("."), value) };
|
|
930
|
+
}
|
|
931
|
+
/**
|
|
932
|
+
* Apply env var overrides onto the raw YAML object.
|
|
933
|
+
* Only bootstrap-level env vars are applied.
|
|
934
|
+
*/
|
|
935
|
+
applyEnvOverrides(raw) {
|
|
936
|
+
let result = { ...raw };
|
|
937
|
+
for (const [envKey, configPath] of Object.entries(ENV_VAR_MAP)) {
|
|
938
|
+
const envValue = process.env[envKey];
|
|
939
|
+
if (envValue === void 0 || envValue === "") continue;
|
|
940
|
+
const coerced = configPath === "server.port" ? Number(envValue) : envValue;
|
|
941
|
+
result = this.setNested(result, configPath, coerced);
|
|
942
|
+
console.log(`[ConfigManager] Env override applied: ${envKey} \u2192 ${configPath}`);
|
|
943
|
+
}
|
|
944
|
+
return result;
|
|
945
|
+
}
|
|
946
|
+
loadYaml() {
|
|
947
|
+
if (!fs3.existsSync(this.configPath)) {
|
|
948
|
+
console.warn(
|
|
949
|
+
`[ConfigManager] Config file not found at: ${this.configPath}
|
|
950
|
+
\u2192 Using built-in defaults. Set CONFIG_PATH env var or create the file.
|
|
951
|
+
\u2192 Example path from project root: ./server/backend/data/config.yaml`
|
|
952
|
+
);
|
|
953
|
+
return {};
|
|
954
|
+
}
|
|
955
|
+
const content = fs3.readFileSync(this.configPath, "utf-8");
|
|
956
|
+
const parsed = yaml.load(content) ?? {};
|
|
957
|
+
console.log(`[ConfigManager] Loaded config from: ${this.configPath}`);
|
|
958
|
+
return parsed;
|
|
959
|
+
}
|
|
960
|
+
warnDefaultCredentials() {
|
|
961
|
+
if (this.bootstrapConfig.auth.adminPassword === "changeme") {
|
|
962
|
+
console.warn(
|
|
963
|
+
`[ConfigManager] Warning: Using default admin password "changeme". Set auth.adminPassword in your config.yaml or the ADMIN_PASSWORD env var.`
|
|
964
|
+
);
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
getFromBootstrap(path3) {
|
|
968
|
+
const keys = path3.split(".");
|
|
969
|
+
let current = this.bootstrapConfig;
|
|
970
|
+
for (const key of keys) {
|
|
971
|
+
if (current === null || current === void 0 || typeof current !== "object") {
|
|
972
|
+
return void 0;
|
|
1231
973
|
}
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
974
|
+
current = current[key];
|
|
975
|
+
}
|
|
976
|
+
return current;
|
|
977
|
+
}
|
|
978
|
+
getFromRuntimeDefaults(path3) {
|
|
979
|
+
const prefix = path3 + ".";
|
|
980
|
+
const result = {};
|
|
981
|
+
let found = false;
|
|
982
|
+
for (const [key, value] of Object.entries(RUNTIME_DEFAULTS)) {
|
|
983
|
+
if (key.startsWith(prefix)) {
|
|
984
|
+
const subKey = key.slice(prefix.length);
|
|
985
|
+
result[subKey] = value;
|
|
986
|
+
found = true;
|
|
1235
987
|
}
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
return false;
|
|
1255
|
-
return true;
|
|
988
|
+
}
|
|
989
|
+
return found ? result : void 0;
|
|
990
|
+
}
|
|
991
|
+
/**
|
|
992
|
+
* Perform a prefix-based nested lookup against SQL system_settings.
|
|
993
|
+
* e.g. path='features' matches keys 'features.streaming', 'features.notifications', etc.
|
|
994
|
+
* Returns an object keyed by the sub-key, or undefined if nothing is found.
|
|
995
|
+
*/
|
|
996
|
+
getNestedFromSystemSettings(path3) {
|
|
997
|
+
if (this.settingsStore === null) return void 0;
|
|
998
|
+
const all = this.settingsStore.getAllSystem();
|
|
999
|
+
const prefix = path3 + ".";
|
|
1000
|
+
const result = {};
|
|
1001
|
+
let found = false;
|
|
1002
|
+
for (const [key, value] of Object.entries(all)) {
|
|
1003
|
+
if (key.startsWith(prefix)) {
|
|
1004
|
+
result[key.slice(prefix.length)] = value;
|
|
1005
|
+
found = true;
|
|
1256
1006
|
}
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1007
|
+
}
|
|
1008
|
+
return found ? result : void 0;
|
|
1009
|
+
}
|
|
1010
|
+
};
|
|
1011
|
+
|
|
1012
|
+
// src/worker/addon-worker-host.ts
|
|
1013
|
+
import { fork } from "child_process";
|
|
1014
|
+
var INFRA_ADDON_IDS = /* @__PURE__ */ new Set([
|
|
1015
|
+
"filesystem-storage",
|
|
1016
|
+
"sqlite-settings",
|
|
1017
|
+
"winston-logging"
|
|
1018
|
+
]);
|
|
1019
|
+
var AddonWorkerHost = class {
|
|
1020
|
+
workers = /* @__PURE__ */ new Map();
|
|
1021
|
+
options;
|
|
1022
|
+
heartbeatTimer = null;
|
|
1023
|
+
/** Set of addons that failed to fork and fell back to in-process */
|
|
1024
|
+
fallbackInProcess = /* @__PURE__ */ new Set();
|
|
1025
|
+
constructor(options) {
|
|
1026
|
+
this.options = {
|
|
1027
|
+
workerEntryPath: options.workerEntryPath,
|
|
1028
|
+
devMode: options.devMode ?? false,
|
|
1029
|
+
forceInProcess: options.forceInProcess ?? process.env.CAMSTACK_FORCE_INPROCESS === "true",
|
|
1030
|
+
heartbeatIntervalMs: options.heartbeatIntervalMs ?? 5e3,
|
|
1031
|
+
heartbeatTimeoutMs: options.heartbeatTimeoutMs ?? 3e4,
|
|
1032
|
+
maxCrashesInWindow: options.maxCrashesInWindow ?? 3,
|
|
1033
|
+
crashWindowMs: options.crashWindowMs ?? 6e4,
|
|
1034
|
+
shutdownTimeoutMs: options.shutdownTimeoutMs ?? 1e4,
|
|
1035
|
+
onWorkerLog: options.onWorkerLog
|
|
1036
|
+
};
|
|
1037
|
+
}
|
|
1038
|
+
/** Check if an addon is infrastructure (must stay in-process) */
|
|
1039
|
+
isInfraAddon(addonId) {
|
|
1040
|
+
return INFRA_ADDON_IDS.has(addonId);
|
|
1041
|
+
}
|
|
1042
|
+
/** Check if an addon fell back to in-process after fork failure */
|
|
1043
|
+
isFallbackInProcess(addonId) {
|
|
1044
|
+
return this.fallbackInProcess.has(addonId);
|
|
1045
|
+
}
|
|
1046
|
+
/** Mark an addon as fallen back to in-process */
|
|
1047
|
+
markFallbackInProcess(addonId) {
|
|
1048
|
+
this.fallbackInProcess.add(addonId);
|
|
1049
|
+
}
|
|
1050
|
+
/**
|
|
1051
|
+
* Determine if an addon should be forked.
|
|
1052
|
+
* Default: fork everything except infra addons.
|
|
1053
|
+
* Addons can opt out with `inProcess: true` in declaration.
|
|
1054
|
+
* Emergency fallback: CAMSTACK_FORCE_INPROCESS=true disables all forking.
|
|
1055
|
+
*/
|
|
1056
|
+
shouldFork(addonId, declaration) {
|
|
1057
|
+
if (this.options.forceInProcess) return false;
|
|
1058
|
+
if (this.options.devMode) return false;
|
|
1059
|
+
if (this.isInfraAddon(addonId)) return false;
|
|
1060
|
+
if (this.fallbackInProcess.has(addonId)) return false;
|
|
1061
|
+
if (declaration?.inProcess === true) return false;
|
|
1062
|
+
if (declaration?.forkable !== true) return false;
|
|
1063
|
+
return true;
|
|
1064
|
+
}
|
|
1065
|
+
/** Fork a worker for an addon */
|
|
1066
|
+
async forkWorker(addonId, addonDir, config, storagePaths, dataDir, locationPaths, workerToken) {
|
|
1067
|
+
if (this.workers.has(addonId)) {
|
|
1068
|
+
throw new Error(`Worker for addon "${addonId}" already exists`);
|
|
1069
|
+
}
|
|
1070
|
+
const workerEnv = {
|
|
1071
|
+
PATH: process.env.PATH ?? "",
|
|
1072
|
+
HOME: process.env.HOME ?? "",
|
|
1073
|
+
NODE_ENV: process.env.NODE_ENV ?? "production",
|
|
1074
|
+
NODE_TLS_REJECT_UNAUTHORIZED: "0",
|
|
1075
|
+
// Accept self-signed cert
|
|
1076
|
+
CAMSTACK_WORKER_HUB_URL: `wss://localhost:${process.env.CAMSTACK_PORT ?? "4443"}/trpc`,
|
|
1077
|
+
CAMSTACK_WORKER_TOKEN: workerToken ?? "",
|
|
1078
|
+
CAMSTACK_ADDON_ID: addonId,
|
|
1079
|
+
CAMSTACK_ADDON_DIR: addonDir,
|
|
1080
|
+
CAMSTACK_ADDON_CONFIG: JSON.stringify(config),
|
|
1081
|
+
CAMSTACK_DATA_DIR: dataDir ?? `camstack-data/addons-data/${addonId}`,
|
|
1082
|
+
CAMSTACK_LOCATION_PATHS: JSON.stringify(locationPaths ?? storagePaths)
|
|
1083
|
+
};
|
|
1084
|
+
const forkOptions = {
|
|
1085
|
+
stdio: ["inherit", "inherit", "inherit", "ipc"],
|
|
1086
|
+
execArgv: [],
|
|
1087
|
+
env: workerEnv
|
|
1088
|
+
};
|
|
1089
|
+
const child = fork(this.options.workerEntryPath, [], forkOptions);
|
|
1090
|
+
const worker = {
|
|
1091
|
+
addonId,
|
|
1092
|
+
process: child,
|
|
1093
|
+
state: "starting",
|
|
1094
|
+
startedAt: Date.now(),
|
|
1095
|
+
lastHeartbeat: Date.now(),
|
|
1096
|
+
restartCount: 0,
|
|
1097
|
+
crashTimestamps: [],
|
|
1098
|
+
cpuPercent: 0,
|
|
1099
|
+
memoryRss: 0,
|
|
1100
|
+
workerToken,
|
|
1101
|
+
forkConfig: { addonDir, config, storagePaths, dataDir, locationPaths, workerToken }
|
|
1102
|
+
};
|
|
1103
|
+
this.workers.set(addonId, worker);
|
|
1104
|
+
child.on("message", (msg) => {
|
|
1105
|
+
if (msg.type === "LOG" && this.options.onWorkerLog) {
|
|
1106
|
+
this.options.onWorkerLog({
|
|
1283
1107
|
addonId,
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
restartCount: 0,
|
|
1289
|
-
crashTimestamps: [],
|
|
1290
|
-
cpuPercent: 0,
|
|
1291
|
-
memoryRss: 0,
|
|
1292
|
-
workerToken
|
|
1293
|
-
};
|
|
1294
|
-
this.workers.set(addonId, worker);
|
|
1295
|
-
child.on("message", (msg) => {
|
|
1296
|
-
if (msg.type === "LOG" && this.options.onWorkerLog) {
|
|
1297
|
-
this.options.onWorkerLog({
|
|
1298
|
-
addonId,
|
|
1299
|
-
level: msg.level,
|
|
1300
|
-
message: msg.message,
|
|
1301
|
-
scope: ["hub", addonId],
|
|
1302
|
-
meta: msg.context
|
|
1303
|
-
});
|
|
1304
|
-
} else if (msg.type === "STATS") {
|
|
1305
|
-
worker.cpuPercent = msg.cpu;
|
|
1306
|
-
worker.memoryRss = msg.memory;
|
|
1307
|
-
}
|
|
1308
|
-
});
|
|
1309
|
-
child.stdout?.on("data", (chunk) => {
|
|
1310
|
-
const lines = chunk.toString().trim();
|
|
1311
|
-
if (!lines)
|
|
1312
|
-
return;
|
|
1313
|
-
if (this.options.onWorkerLog) {
|
|
1314
|
-
this.options.onWorkerLog({
|
|
1315
|
-
addonId,
|
|
1316
|
-
level: "info",
|
|
1317
|
-
message: lines,
|
|
1318
|
-
scope: ["hub", addonId, "__stdout"]
|
|
1319
|
-
});
|
|
1320
|
-
} else {
|
|
1321
|
-
console.log(`[Worker:${addonId}] ${lines}`);
|
|
1322
|
-
}
|
|
1323
|
-
});
|
|
1324
|
-
child.stderr?.on("data", (chunk) => {
|
|
1325
|
-
const lines = chunk.toString().trim();
|
|
1326
|
-
if (!lines)
|
|
1327
|
-
return;
|
|
1328
|
-
if (this.options.onWorkerLog) {
|
|
1329
|
-
this.options.onWorkerLog({
|
|
1330
|
-
addonId,
|
|
1331
|
-
level: "error",
|
|
1332
|
-
message: lines,
|
|
1333
|
-
scope: ["hub", addonId, "__stderr"]
|
|
1334
|
-
});
|
|
1335
|
-
} else {
|
|
1336
|
-
console.error(`[Worker:${addonId}:ERR] ${lines}`);
|
|
1337
|
-
}
|
|
1108
|
+
level: msg.level,
|
|
1109
|
+
message: msg.message,
|
|
1110
|
+
scope: ["hub", addonId],
|
|
1111
|
+
meta: msg.context
|
|
1338
1112
|
});
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
child.on("error", (err) => {
|
|
1343
|
-
console.error(`[WorkerHost] Worker ${addonId} error:`, err.message);
|
|
1344
|
-
});
|
|
1345
|
-
await new Promise((resolve, reject) => {
|
|
1346
|
-
const timeout = setTimeout(() => {
|
|
1347
|
-
reject(new Error(`Worker ${addonId} did not become ready within 30s`));
|
|
1348
|
-
}, 3e4);
|
|
1349
|
-
const readyCheck = (msg) => {
|
|
1350
|
-
if (msg.type === "READY") {
|
|
1351
|
-
clearTimeout(timeout);
|
|
1352
|
-
child.off("message", readyCheck);
|
|
1353
|
-
worker.state = "running";
|
|
1354
|
-
resolve();
|
|
1355
|
-
}
|
|
1356
|
-
};
|
|
1357
|
-
child.on("message", readyCheck);
|
|
1358
|
-
});
|
|
1359
|
-
}
|
|
1360
|
-
/** List all workers with stats */
|
|
1361
|
-
listWorkers() {
|
|
1362
|
-
return [...this.workers.values()].map((w) => ({
|
|
1363
|
-
addonId: w.addonId,
|
|
1364
|
-
pid: w.process.pid ?? 0,
|
|
1365
|
-
state: w.state,
|
|
1366
|
-
cpuPercent: w.cpuPercent,
|
|
1367
|
-
memoryRss: w.memoryRss,
|
|
1368
|
-
uptimeSeconds: Math.round((Date.now() - w.startedAt) / 1e3),
|
|
1369
|
-
restartCount: w.restartCount,
|
|
1370
|
-
subProcesses: []
|
|
1371
|
-
}));
|
|
1372
|
-
}
|
|
1373
|
-
/** Gracefully shutdown a single worker by addon ID */
|
|
1374
|
-
async shutdownWorkerById(addonId) {
|
|
1375
|
-
const worker = this.workers.get(addonId);
|
|
1376
|
-
if (!worker) {
|
|
1377
|
-
throw new Error(`No worker found for addon "${addonId}"`);
|
|
1378
|
-
}
|
|
1379
|
-
await this.shutdownWorker(addonId, worker);
|
|
1380
|
-
this.workers.delete(addonId);
|
|
1381
|
-
}
|
|
1382
|
-
/** Gracefully shutdown all workers */
|
|
1383
|
-
async shutdownAll() {
|
|
1384
|
-
if (this.heartbeatTimer) {
|
|
1385
|
-
clearInterval(this.heartbeatTimer);
|
|
1386
|
-
this.heartbeatTimer = null;
|
|
1387
|
-
}
|
|
1388
|
-
const shutdowns = [...this.workers.entries()].map(([addonId, worker]) => this.shutdownWorker(addonId, worker));
|
|
1389
|
-
await Promise.allSettled(shutdowns);
|
|
1390
|
-
this.workers.clear();
|
|
1391
|
-
}
|
|
1392
|
-
/** Start heartbeat monitoring — workers now report heartbeat via tRPC, not IPC PING */
|
|
1393
|
-
startHeartbeatMonitoring() {
|
|
1394
|
-
this.heartbeatTimer = setInterval(() => {
|
|
1395
|
-
const now = Date.now();
|
|
1396
|
-
for (const [addonId, worker] of this.workers) {
|
|
1397
|
-
if (worker.state !== "running")
|
|
1398
|
-
continue;
|
|
1399
|
-
if (now - worker.lastHeartbeat > this.options.heartbeatTimeoutMs) {
|
|
1400
|
-
console.warn(`[WorkerHost] Worker ${addonId} heartbeat timeout \u2014 marking unresponsive`);
|
|
1401
|
-
worker.state = "crashed";
|
|
1402
|
-
worker.process.kill("SIGKILL");
|
|
1403
|
-
}
|
|
1404
|
-
}
|
|
1405
|
-
}, this.options.heartbeatIntervalMs);
|
|
1113
|
+
} else if (msg.type === "STATS") {
|
|
1114
|
+
worker.cpuPercent = msg.cpu;
|
|
1115
|
+
worker.memoryRss = msg.memory;
|
|
1406
1116
|
}
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
if (!worker)
|
|
1421
|
-
return;
|
|
1422
|
-
if (worker.state === "stopping") {
|
|
1423
|
-
worker.state = "stopped";
|
|
1424
|
-
return;
|
|
1425
|
-
}
|
|
1426
|
-
worker.state = "crashed";
|
|
1427
|
-
console.error(`[WorkerHost] Worker ${addonId} exited with code ${code}`);
|
|
1428
|
-
const now = Date.now();
|
|
1429
|
-
worker.crashTimestamps.push(now);
|
|
1430
|
-
const recentCrashes = worker.crashTimestamps.filter((t) => now - t < this.options.crashWindowMs);
|
|
1431
|
-
worker.crashTimestamps = recentCrashes;
|
|
1432
|
-
if (recentCrashes.length >= this.options.maxCrashesInWindow) {
|
|
1433
|
-
console.error(`[WorkerHost] Worker ${addonId} crashed ${recentCrashes.length} times in ${this.options.crashWindowMs}ms \u2014 not restarting`);
|
|
1434
|
-
return;
|
|
1435
|
-
}
|
|
1436
|
-
const backoff = Math.min(2e3 * (worker.restartCount + 1), 3e4);
|
|
1437
|
-
console.log(`[WorkerHost] Restarting worker ${addonId} in ${backoff}ms`);
|
|
1438
|
-
setTimeout(() => {
|
|
1439
|
-
worker.restartCount++;
|
|
1440
|
-
this.workers.delete(addonId);
|
|
1441
|
-
this.forkWorker(addonId, addonDir, config, storagePaths, dataDir, locationPaths, workerToken).catch((err) => {
|
|
1442
|
-
console.error(`[WorkerHost] Failed to restart worker ${addonId}:`, err);
|
|
1443
|
-
});
|
|
1444
|
-
}, backoff);
|
|
1117
|
+
});
|
|
1118
|
+
child.stdout?.on("data", (chunk) => {
|
|
1119
|
+
const lines = chunk.toString().trim();
|
|
1120
|
+
if (!lines) return;
|
|
1121
|
+
if (this.options.onWorkerLog) {
|
|
1122
|
+
this.options.onWorkerLog({
|
|
1123
|
+
addonId,
|
|
1124
|
+
level: "info",
|
|
1125
|
+
message: lines,
|
|
1126
|
+
scope: ["hub", addonId, "__stdout"]
|
|
1127
|
+
});
|
|
1128
|
+
} else {
|
|
1129
|
+
console.log(`[Worker:${addonId}] ${lines}`);
|
|
1445
1130
|
}
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
clearTimeout(timeout);
|
|
1457
|
-
resolve();
|
|
1458
|
-
});
|
|
1131
|
+
});
|
|
1132
|
+
child.stderr?.on("data", (chunk) => {
|
|
1133
|
+
const lines = chunk.toString().trim();
|
|
1134
|
+
if (!lines) return;
|
|
1135
|
+
if (this.options.onWorkerLog) {
|
|
1136
|
+
this.options.onWorkerLog({
|
|
1137
|
+
addonId,
|
|
1138
|
+
level: "error",
|
|
1139
|
+
message: lines,
|
|
1140
|
+
scope: ["hub", addonId, "__stderr"]
|
|
1459
1141
|
});
|
|
1142
|
+
} else {
|
|
1143
|
+
console.error(`[Worker:${addonId}:ERR] ${lines}`);
|
|
1460
1144
|
}
|
|
1461
|
-
};
|
|
1462
|
-
|
|
1145
|
+
});
|
|
1146
|
+
child.on("exit", (code) => {
|
|
1147
|
+
this.handleWorkerExit(addonId, code, addonDir, config, storagePaths, dataDir, locationPaths, workerToken);
|
|
1148
|
+
});
|
|
1149
|
+
child.on("error", (err) => {
|
|
1150
|
+
console.error(`[WorkerHost] Worker ${addonId} error:`, err.message);
|
|
1151
|
+
});
|
|
1152
|
+
await new Promise((resolve3, reject) => {
|
|
1153
|
+
const timeout = setTimeout(() => {
|
|
1154
|
+
reject(new Error(`Worker ${addonId} did not become ready within 30s`));
|
|
1155
|
+
}, 3e4);
|
|
1156
|
+
const readyCheck = (msg) => {
|
|
1157
|
+
if (msg.type === "READY") {
|
|
1158
|
+
clearTimeout(timeout);
|
|
1159
|
+
child.off("message", readyCheck);
|
|
1160
|
+
worker.state = "running";
|
|
1161
|
+
resolve3();
|
|
1162
|
+
}
|
|
1163
|
+
};
|
|
1164
|
+
child.on("message", readyCheck);
|
|
1165
|
+
});
|
|
1463
1166
|
}
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1167
|
+
/** List all workers with stats */
|
|
1168
|
+
listWorkers() {
|
|
1169
|
+
return [...this.workers.values()].map((w) => ({
|
|
1170
|
+
addonId: w.addonId,
|
|
1171
|
+
pid: w.process.pid ?? 0,
|
|
1172
|
+
state: w.state,
|
|
1173
|
+
cpuPercent: w.cpuPercent,
|
|
1174
|
+
memoryRss: w.memoryRss,
|
|
1175
|
+
uptimeSeconds: Math.round((Date.now() - w.startedAt) / 1e3),
|
|
1176
|
+
restartCount: w.restartCount,
|
|
1177
|
+
subProcesses: []
|
|
1178
|
+
}));
|
|
1179
|
+
}
|
|
1180
|
+
/** Gracefully shutdown a single worker by addon ID */
|
|
1181
|
+
async shutdownWorkerById(addonId) {
|
|
1182
|
+
const worker = this.workers.get(addonId);
|
|
1183
|
+
if (!worker) {
|
|
1184
|
+
throw new Error(`No worker found for addon "${addonId}"`);
|
|
1185
|
+
}
|
|
1186
|
+
await this.shutdownWorker(addonId, worker);
|
|
1187
|
+
this.workers.delete(addonId);
|
|
1188
|
+
}
|
|
1189
|
+
/** Gracefully restart a single worker by addon ID (stop then re-fork with same config) */
|
|
1190
|
+
async restartWorker(addonId) {
|
|
1191
|
+
const worker = this.workers.get(addonId);
|
|
1192
|
+
if (!worker) {
|
|
1193
|
+
throw new Error(`No worker found for addon "${addonId}"`);
|
|
1194
|
+
}
|
|
1195
|
+
const forkConfig = worker.forkConfig;
|
|
1196
|
+
if (!forkConfig) {
|
|
1197
|
+
throw new Error(`No fork config stored for addon "${addonId}" \u2014 cannot restart`);
|
|
1198
|
+
}
|
|
1199
|
+
await this.shutdownWorker(addonId, worker);
|
|
1200
|
+
this.workers.delete(addonId);
|
|
1201
|
+
await this.forkWorker(
|
|
1202
|
+
addonId,
|
|
1203
|
+
forkConfig.addonDir,
|
|
1204
|
+
forkConfig.config,
|
|
1205
|
+
forkConfig.storagePaths,
|
|
1206
|
+
forkConfig.dataDir,
|
|
1207
|
+
forkConfig.locationPaths,
|
|
1208
|
+
forkConfig.workerToken
|
|
1209
|
+
);
|
|
1210
|
+
}
|
|
1211
|
+
/** Gracefully shutdown all workers */
|
|
1212
|
+
async shutdownAll() {
|
|
1213
|
+
if (this.heartbeatTimer) {
|
|
1214
|
+
clearInterval(this.heartbeatTimer);
|
|
1215
|
+
this.heartbeatTimer = null;
|
|
1216
|
+
}
|
|
1217
|
+
const shutdowns = [...this.workers.entries()].map(
|
|
1218
|
+
([addonId, worker]) => this.shutdownWorker(addonId, worker)
|
|
1219
|
+
);
|
|
1220
|
+
await Promise.allSettled(shutdowns);
|
|
1221
|
+
this.workers.clear();
|
|
1222
|
+
}
|
|
1223
|
+
/** Start heartbeat monitoring — workers now report heartbeat via tRPC, not IPC PING */
|
|
1224
|
+
startHeartbeatMonitoring() {
|
|
1225
|
+
this.heartbeatTimer = setInterval(() => {
|
|
1226
|
+
const now = Date.now();
|
|
1227
|
+
for (const [addonId, worker] of this.workers) {
|
|
1228
|
+
if (worker.state !== "running") continue;
|
|
1229
|
+
if (now - worker.lastHeartbeat > this.options.heartbeatTimeoutMs) {
|
|
1230
|
+
console.warn(`[WorkerHost] Worker ${addonId} heartbeat timeout \u2014 marking unresponsive`);
|
|
1231
|
+
worker.state = "crashed";
|
|
1232
|
+
worker.process.kill("SIGKILL");
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
}, this.options.heartbeatIntervalMs);
|
|
1236
|
+
}
|
|
1237
|
+
/** Update heartbeat timestamp for a worker (called when agent.heartbeat arrives) */
|
|
1238
|
+
updateWorkerHeartbeat(addonId, cpuPercent, memoryRss) {
|
|
1239
|
+
const worker = this.workers.get(addonId);
|
|
1240
|
+
if (!worker) return;
|
|
1241
|
+
worker.lastHeartbeat = Date.now();
|
|
1242
|
+
if (cpuPercent !== void 0) worker.cpuPercent = cpuPercent;
|
|
1243
|
+
if (memoryRss !== void 0) worker.memoryRss = memoryRss;
|
|
1244
|
+
}
|
|
1245
|
+
handleWorkerExit(addonId, code, addonDir, config, storagePaths, dataDir, locationPaths, workerToken) {
|
|
1246
|
+
const worker = this.workers.get(addonId);
|
|
1247
|
+
if (!worker) return;
|
|
1248
|
+
if (worker.state === "stopping") {
|
|
1249
|
+
worker.state = "stopped";
|
|
1250
|
+
return;
|
|
1251
|
+
}
|
|
1252
|
+
worker.state = "crashed";
|
|
1253
|
+
console.error(`[WorkerHost] Worker ${addonId} exited with code ${code}`);
|
|
1254
|
+
const now = Date.now();
|
|
1255
|
+
worker.crashTimestamps.push(now);
|
|
1256
|
+
const recentCrashes = worker.crashTimestamps.filter(
|
|
1257
|
+
(t) => now - t < this.options.crashWindowMs
|
|
1258
|
+
);
|
|
1259
|
+
worker.crashTimestamps = recentCrashes;
|
|
1260
|
+
if (recentCrashes.length >= this.options.maxCrashesInWindow) {
|
|
1261
|
+
console.error(`[WorkerHost] Worker ${addonId} crashed ${recentCrashes.length} times in ${this.options.crashWindowMs}ms \u2014 not restarting`);
|
|
1262
|
+
return;
|
|
1263
|
+
}
|
|
1264
|
+
const backoff = Math.min(2e3 * (worker.restartCount + 1), 3e4);
|
|
1265
|
+
console.log(`[WorkerHost] Restarting worker ${addonId} in ${backoff}ms`);
|
|
1266
|
+
setTimeout(() => {
|
|
1267
|
+
worker.restartCount++;
|
|
1268
|
+
this.workers.delete(addonId);
|
|
1269
|
+
this.forkWorker(addonId, addonDir, config, storagePaths, dataDir, locationPaths, workerToken).catch((err) => {
|
|
1270
|
+
console.error(`[WorkerHost] Failed to restart worker ${addonId}:`, err);
|
|
1271
|
+
});
|
|
1272
|
+
}, backoff);
|
|
1273
|
+
}
|
|
1274
|
+
async shutdownWorker(addonId, worker) {
|
|
1275
|
+
worker.state = "stopping";
|
|
1276
|
+
worker.process.kill("SIGTERM");
|
|
1277
|
+
await new Promise((resolve3) => {
|
|
1278
|
+
const timeout = setTimeout(() => {
|
|
1279
|
+
console.warn(`[WorkerHost] Worker ${addonId} did not exit within ${this.options.shutdownTimeoutMs}ms \u2014 SIGKILL`);
|
|
1280
|
+
worker.process.kill("SIGKILL");
|
|
1281
|
+
resolve3();
|
|
1282
|
+
}, this.options.shutdownTimeoutMs);
|
|
1283
|
+
worker.process.on("exit", () => {
|
|
1284
|
+
clearTimeout(timeout);
|
|
1285
|
+
resolve3();
|
|
1286
|
+
});
|
|
1287
|
+
});
|
|
1288
|
+
}
|
|
1289
|
+
};
|
|
1499
1290
|
export {
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1291
|
+
AddonEngineManager,
|
|
1292
|
+
AddonInstaller,
|
|
1293
|
+
AddonLoader,
|
|
1294
|
+
AddonWorkerHost,
|
|
1295
|
+
CapabilityRegistry,
|
|
1296
|
+
ConfigManager,
|
|
1297
|
+
DEFAULT_DATA_PATH,
|
|
1298
|
+
INFRA_CAPABILITIES,
|
|
1299
|
+
RUNTIME_DEFAULTS,
|
|
1300
|
+
WorkerProcessManager,
|
|
1301
|
+
bootstrapSchema,
|
|
1302
|
+
copyDirRecursive,
|
|
1303
|
+
copyExtraFileDirs,
|
|
1304
|
+
detectWorkspacePackagesDir,
|
|
1305
|
+
ensureDir,
|
|
1306
|
+
ensureLibraryBuilt,
|
|
1307
|
+
installPackageFromNpmSync,
|
|
1308
|
+
isInfraCapability,
|
|
1309
|
+
isSourceNewer,
|
|
1310
|
+
stripCamstackDeps,
|
|
1311
|
+
symlinkAddonsToNodeModules
|
|
1521
1312
|
};
|
|
1522
1313
|
//# sourceMappingURL=index.mjs.map
|