camstack 0.3.1
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/cli.js +812 -0
- package/package.json +44 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,812 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { createRequire } from "module";
|
|
5
|
+
import { fileURLToPath } from "url";
|
|
6
|
+
import { dirname, resolve as resolve2 } from "path";
|
|
7
|
+
import * as os3 from "os";
|
|
8
|
+
import { parseArgs as parseArgs4 } from "util";
|
|
9
|
+
|
|
10
|
+
// src/commands/serve.ts
|
|
11
|
+
import { parseArgs } from "util";
|
|
12
|
+
function buildServeEnv(opts) {
|
|
13
|
+
const env = {
|
|
14
|
+
CAMSTACK_MODE: "hub"
|
|
15
|
+
};
|
|
16
|
+
if (opts.port) env.PORT = opts.port;
|
|
17
|
+
if (opts.data) env.CAMSTACK_DATA = opts.data;
|
|
18
|
+
return env;
|
|
19
|
+
}
|
|
20
|
+
async function runServe(args) {
|
|
21
|
+
const { values } = parseArgs({
|
|
22
|
+
args: [...args],
|
|
23
|
+
options: {
|
|
24
|
+
port: { type: "string", short: "p" },
|
|
25
|
+
data: { type: "string", short: "d" }
|
|
26
|
+
},
|
|
27
|
+
strict: true,
|
|
28
|
+
allowPositionals: false
|
|
29
|
+
});
|
|
30
|
+
const opts = {
|
|
31
|
+
...typeof values.port === "string" ? { port: values.port } : {},
|
|
32
|
+
...typeof values.data === "string" ? { data: values.data } : {}
|
|
33
|
+
};
|
|
34
|
+
Object.assign(process.env, buildServeEnv(opts));
|
|
35
|
+
await import("@camstack/server/main.js");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// src/commands/agent.ts
|
|
39
|
+
import * as os from "os";
|
|
40
|
+
import { parseArgs as parseArgs2 } from "util";
|
|
41
|
+
function buildAgentEnv(opts) {
|
|
42
|
+
const hubPort = opts.port ?? "4443";
|
|
43
|
+
const agentName = opts.name ?? `${os.hostname()}-${os.arch()}`;
|
|
44
|
+
return {
|
|
45
|
+
CAMSTACK_MODE: "agent",
|
|
46
|
+
CAMSTACK_HUB_URL: `ws://${opts.hub}:${hubPort}/agent`,
|
|
47
|
+
CAMSTACK_HUB_TOKEN: opts.token,
|
|
48
|
+
CAMSTACK_AGENT_NAME: agentName
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
async function runAgent(args) {
|
|
52
|
+
const { values } = parseArgs2({
|
|
53
|
+
args: [...args],
|
|
54
|
+
options: {
|
|
55
|
+
hub: { type: "string" },
|
|
56
|
+
token: { type: "string" },
|
|
57
|
+
name: { type: "string" },
|
|
58
|
+
port: { type: "string" }
|
|
59
|
+
},
|
|
60
|
+
strict: true,
|
|
61
|
+
allowPositionals: false
|
|
62
|
+
});
|
|
63
|
+
if (typeof values.hub !== "string" || values.hub.length === 0) {
|
|
64
|
+
throw new Error("--hub is required (e.g. --hub 192.168.1.10)");
|
|
65
|
+
}
|
|
66
|
+
if (typeof values.token !== "string" || values.token.length === 0) {
|
|
67
|
+
throw new Error("--token is required");
|
|
68
|
+
}
|
|
69
|
+
const opts = {
|
|
70
|
+
hub: values.hub,
|
|
71
|
+
token: values.token,
|
|
72
|
+
...typeof values.name === "string" ? { name: values.name } : {},
|
|
73
|
+
...typeof values.port === "string" ? { port: values.port } : {}
|
|
74
|
+
};
|
|
75
|
+
Object.assign(process.env, buildAgentEnv(opts));
|
|
76
|
+
await import("@camstack/server/main.js");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// src/commands/setup.ts
|
|
80
|
+
import { execFileSync } from "child_process";
|
|
81
|
+
import * as path from "path";
|
|
82
|
+
import * as fs from "fs";
|
|
83
|
+
import { parseArgs as parseArgs3 } from "util";
|
|
84
|
+
function checkFfmpegAvailable() {
|
|
85
|
+
try {
|
|
86
|
+
execFileSync("ffmpeg", ["-version"], { stdio: "pipe", timeout: 5e3 });
|
|
87
|
+
return true;
|
|
88
|
+
} catch {
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
function getDefaultDataPath() {
|
|
93
|
+
const root = process.env.CAMSTACK_ROOT;
|
|
94
|
+
if (root) return path.join(root, "data");
|
|
95
|
+
return path.join(process.cwd(), "camstack-data");
|
|
96
|
+
}
|
|
97
|
+
function runSetup(args) {
|
|
98
|
+
const { values } = parseArgs3({
|
|
99
|
+
args: [...args],
|
|
100
|
+
options: {
|
|
101
|
+
data: { type: "string", short: "d" }
|
|
102
|
+
},
|
|
103
|
+
strict: true,
|
|
104
|
+
allowPositionals: false
|
|
105
|
+
});
|
|
106
|
+
const dataPath = typeof values.data === "string" ? values.data : getDefaultDataPath();
|
|
107
|
+
console.log("[camstack] Running setup...");
|
|
108
|
+
console.log(`[camstack] Data directory: ${dataPath}`);
|
|
109
|
+
fs.mkdirSync(dataPath, { recursive: true });
|
|
110
|
+
console.log("[camstack] Data directory: OK");
|
|
111
|
+
if (checkFfmpegAvailable()) {
|
|
112
|
+
console.log("[camstack] ffmpeg: found in PATH");
|
|
113
|
+
} else {
|
|
114
|
+
console.warn("[camstack] ffmpeg: NOT FOUND \u2014 install ffmpeg for decode/transcode support");
|
|
115
|
+
console.warn("[camstack] macOS: brew install ffmpeg");
|
|
116
|
+
console.warn("[camstack] Linux: apt install ffmpeg / dnf install ffmpeg");
|
|
117
|
+
}
|
|
118
|
+
let pythonFound = false;
|
|
119
|
+
for (const bin of ["python3", "python"]) {
|
|
120
|
+
try {
|
|
121
|
+
const version = execFileSync(bin, ["--version"], { encoding: "utf8", timeout: 5e3 }).trim();
|
|
122
|
+
console.log(`[camstack] Python: ${version} (${bin})`);
|
|
123
|
+
pythonFound = true;
|
|
124
|
+
break;
|
|
125
|
+
} catch {
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
if (!pythonFound) {
|
|
129
|
+
console.log("[camstack] Python: not found (optional \u2014 needed for ML detection addons)");
|
|
130
|
+
}
|
|
131
|
+
console.log("[camstack] Setup complete.");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// src/commands/deploy.ts
|
|
135
|
+
import { execSync } from "child_process";
|
|
136
|
+
import * as fs2 from "fs";
|
|
137
|
+
import * as path2 from "path";
|
|
138
|
+
function isRecord(value) {
|
|
139
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
140
|
+
}
|
|
141
|
+
function toDeployResult(value) {
|
|
142
|
+
if (!isRecord(value)) return { success: false, error: "Invalid response shape" };
|
|
143
|
+
return {
|
|
144
|
+
success: value["success"] === true,
|
|
145
|
+
packageName: typeof value["packageName"] === "string" ? value["packageName"] : void 0,
|
|
146
|
+
version: typeof value["version"] === "string" ? value["version"] : void 0,
|
|
147
|
+
target: typeof value["target"] === "string" ? value["target"] : void 0,
|
|
148
|
+
addonId: typeof value["addonId"] === "string" ? value["addonId"] : void 0,
|
|
149
|
+
capabilities: Array.isArray(value["capabilities"]) ? value["capabilities"].filter((c) => typeof c === "string") : void 0,
|
|
150
|
+
error: typeof value["error"] === "string" ? value["error"] : void 0
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
function toPackFilenames(raw) {
|
|
154
|
+
try {
|
|
155
|
+
const parsed = JSON.parse(raw);
|
|
156
|
+
if (!Array.isArray(parsed)) return [];
|
|
157
|
+
return parsed.flatMap((entry) => {
|
|
158
|
+
if (!isRecord(entry)) return [];
|
|
159
|
+
return typeof entry["filename"] === "string" ? [entry["filename"]] : [];
|
|
160
|
+
});
|
|
161
|
+
} catch {
|
|
162
|
+
return [];
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
function resolveAddonPath(arg) {
|
|
166
|
+
const absolute = path2.resolve(arg);
|
|
167
|
+
if (fs2.existsSync(absolute)) return absolute;
|
|
168
|
+
for (const candidate of [
|
|
169
|
+
path2.resolve("packages", `addon-${arg}`),
|
|
170
|
+
path2.resolve("packages", arg)
|
|
171
|
+
]) {
|
|
172
|
+
if (fs2.existsSync(candidate) && fs2.existsSync(path2.join(candidate, "package.json"))) {
|
|
173
|
+
return candidate;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
function packAddon(addonDir) {
|
|
179
|
+
const pkgJsonPath = path2.join(addonDir, "package.json");
|
|
180
|
+
if (!fs2.existsSync(pkgJsonPath)) {
|
|
181
|
+
throw new Error(`No package.json found in: ${addonDir}`);
|
|
182
|
+
}
|
|
183
|
+
console.log(`[camstack] Packing addon from ${addonDir}...`);
|
|
184
|
+
const packOutput = execSync("npm pack --json 2>/dev/null || npm pack", {
|
|
185
|
+
cwd: addonDir,
|
|
186
|
+
encoding: "utf8",
|
|
187
|
+
timeout: 6e4
|
|
188
|
+
}).trim();
|
|
189
|
+
const parsedFilenames = toPackFilenames(packOutput);
|
|
190
|
+
let tgzFilename;
|
|
191
|
+
if (parsedFilenames.length > 0) {
|
|
192
|
+
tgzFilename = parsedFilenames[0] ?? "";
|
|
193
|
+
} else {
|
|
194
|
+
const lines = packOutput.split("\n");
|
|
195
|
+
tgzFilename = lines[lines.length - 1]?.trim() ?? "";
|
|
196
|
+
}
|
|
197
|
+
if (!tgzFilename) throw new Error("npm pack did not produce a .tgz file");
|
|
198
|
+
const tgzPath = path2.join(addonDir, tgzFilename);
|
|
199
|
+
if (!fs2.existsSync(tgzPath)) {
|
|
200
|
+
throw new Error(`Expected tgz file not found: ${tgzPath}`);
|
|
201
|
+
}
|
|
202
|
+
console.log(`[camstack] Packed: ${tgzFilename}`);
|
|
203
|
+
return {
|
|
204
|
+
tgzPath,
|
|
205
|
+
cleanup: () => {
|
|
206
|
+
try {
|
|
207
|
+
if (fs2.existsSync(tgzPath)) fs2.unlinkSync(tgzPath);
|
|
208
|
+
} catch {
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
async function fetchOnlineAgents(serverUrl, token) {
|
|
214
|
+
const url = `${serverUrl}/trpc/nodes.topology?input=${encodeURIComponent('{"json":null}')}`;
|
|
215
|
+
const res = await fetch(url, {
|
|
216
|
+
headers: { Authorization: `Bearer ${token}` }
|
|
217
|
+
});
|
|
218
|
+
if (!res.ok) {
|
|
219
|
+
throw new Error(`nodes.topology returned ${res.status}: ${await res.text().catch(() => res.statusText)}`);
|
|
220
|
+
}
|
|
221
|
+
const body = await res.json();
|
|
222
|
+
const nodes = body.result?.data?.json ?? [];
|
|
223
|
+
return nodes.filter((n) => n.isOnline && !n.isHub);
|
|
224
|
+
}
|
|
225
|
+
async function uploadToTarget(args) {
|
|
226
|
+
const { serverUrl, token, tgzPath, nodeId, addonId } = args;
|
|
227
|
+
const fileBuffer = fs2.readFileSync(tgzPath);
|
|
228
|
+
const formData = new FormData();
|
|
229
|
+
const blob = new Blob([fileBuffer], { type: "application/gzip" });
|
|
230
|
+
formData.append("file", blob, path2.basename(tgzPath));
|
|
231
|
+
if (nodeId) formData.append("nodeId", nodeId);
|
|
232
|
+
if (addonId) formData.append("addonId", addonId);
|
|
233
|
+
const response = await fetch(`${serverUrl}/api/addons/upload`, {
|
|
234
|
+
method: "POST",
|
|
235
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
236
|
+
body: formData
|
|
237
|
+
});
|
|
238
|
+
const result = toDeployResult(await response.json().catch(() => ({})));
|
|
239
|
+
if (!response.ok) {
|
|
240
|
+
return {
|
|
241
|
+
success: false,
|
|
242
|
+
error: result.error ?? `Server returned ${response.status} ${response.statusText}`
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
return result;
|
|
246
|
+
}
|
|
247
|
+
async function deployAddon(addonPath, opts) {
|
|
248
|
+
const resolvedPath = resolveAddonPath(addonPath);
|
|
249
|
+
if (!resolvedPath) {
|
|
250
|
+
throw new Error(`Path not resolved: ${addonPath} (tried as-is + packages/addon-${addonPath} + packages/${addonPath})`);
|
|
251
|
+
}
|
|
252
|
+
let tgzPath;
|
|
253
|
+
let cleanup = null;
|
|
254
|
+
if (resolvedPath.endsWith(".tgz") || resolvedPath.endsWith(".tar.gz")) {
|
|
255
|
+
if (!fs2.existsSync(resolvedPath)) throw new Error(`File not found: ${resolvedPath}`);
|
|
256
|
+
tgzPath = resolvedPath;
|
|
257
|
+
} else {
|
|
258
|
+
const packed = packAddon(resolvedPath);
|
|
259
|
+
tgzPath = packed.tgzPath;
|
|
260
|
+
cleanup = packed.cleanup;
|
|
261
|
+
}
|
|
262
|
+
try {
|
|
263
|
+
const targets = [];
|
|
264
|
+
if (opts.nodeId) {
|
|
265
|
+
targets.push({ nodeId: opts.nodeId, label: opts.nodeId });
|
|
266
|
+
} else if (opts.cluster) {
|
|
267
|
+
targets.push({ label: "hub" });
|
|
268
|
+
try {
|
|
269
|
+
const agents = await fetchOnlineAgents(opts.serverUrl, opts.token);
|
|
270
|
+
for (const a of agents) {
|
|
271
|
+
targets.push({ nodeId: a.id, label: `${a.name} (${a.id})` });
|
|
272
|
+
}
|
|
273
|
+
if (agents.length === 0) {
|
|
274
|
+
console.log("[camstack] --cluster: no online agents found, pushing to hub only");
|
|
275
|
+
} else {
|
|
276
|
+
console.log(`[camstack] --cluster: pushing to hub + ${agents.length} online agent(s)`);
|
|
277
|
+
}
|
|
278
|
+
} catch (err) {
|
|
279
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
280
|
+
console.error(`[camstack] --cluster: failed to enumerate agents \u2014 falling back to hub only (${msg})`);
|
|
281
|
+
}
|
|
282
|
+
} else {
|
|
283
|
+
targets.push({ label: "hub" });
|
|
284
|
+
}
|
|
285
|
+
const outcomes = [];
|
|
286
|
+
for (const t of targets) {
|
|
287
|
+
console.log(`[camstack] \u2192 Deploying ${path2.basename(tgzPath)} to ${t.label}...`);
|
|
288
|
+
const result = await uploadToTarget({
|
|
289
|
+
serverUrl: opts.serverUrl,
|
|
290
|
+
token: opts.token,
|
|
291
|
+
tgzPath,
|
|
292
|
+
...t.nodeId ? { nodeId: t.nodeId } : {}
|
|
293
|
+
});
|
|
294
|
+
if (result.success) {
|
|
295
|
+
const id = result.packageName ?? result.addonId ?? "?";
|
|
296
|
+
const ver = result.version ? `@${result.version}` : "";
|
|
297
|
+
const detail = `${id}${ver}`;
|
|
298
|
+
console.log(`[camstack] \u2713 ${t.label}: ${detail}`);
|
|
299
|
+
outcomes.push({ target: t.label, ok: true, detail });
|
|
300
|
+
} else {
|
|
301
|
+
const detail = result.error ?? "unknown";
|
|
302
|
+
console.error(`[camstack] \u2717 ${t.label}: ${detail}`);
|
|
303
|
+
outcomes.push({ target: t.label, ok: false, detail });
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
const failed = outcomes.filter((o) => !o.ok);
|
|
307
|
+
if (failed.length > 0) {
|
|
308
|
+
console.error(`[camstack] ${failed.length}/${outcomes.length} target(s) failed`);
|
|
309
|
+
process.exit(1);
|
|
310
|
+
}
|
|
311
|
+
console.log(`[camstack] \u2713 Deployed to ${outcomes.length} target(s)`);
|
|
312
|
+
} finally {
|
|
313
|
+
if (cleanup) cleanup();
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// src/commands/auth.ts
|
|
318
|
+
import * as fs3 from "fs";
|
|
319
|
+
import * as path3 from "path";
|
|
320
|
+
import * as os2 from "os";
|
|
321
|
+
import * as readline from "readline";
|
|
322
|
+
var SESSION_DIR = path3.join(os2.homedir(), ".camstack");
|
|
323
|
+
function sessionFileForServer(serverUrl) {
|
|
324
|
+
const slug = serverUrl.replace(/^https?:\/\//, "").replace(/[:/?#]+/g, "_").replace(/_+$/, "");
|
|
325
|
+
return path3.join(SESSION_DIR, `${slug}.json`);
|
|
326
|
+
}
|
|
327
|
+
function resolveSessionFile(serverUrl) {
|
|
328
|
+
if (serverUrl) {
|
|
329
|
+
const p = sessionFileForServer(serverUrl);
|
|
330
|
+
return fs3.existsSync(p) ? p : null;
|
|
331
|
+
}
|
|
332
|
+
if (!fs3.existsSync(SESSION_DIR)) return null;
|
|
333
|
+
const files = fs3.readdirSync(SESSION_DIR).filter((f) => f.endsWith(".json")).map((f) => path3.join(SESSION_DIR, f));
|
|
334
|
+
if (files.length === 0) return null;
|
|
335
|
+
return files.map((f) => ({ f, mtime: fs3.statSync(f).mtimeMs })).sort((a, b) => b.mtime - a.mtime)[0].f;
|
|
336
|
+
}
|
|
337
|
+
function loadSession(serverUrl) {
|
|
338
|
+
const file = resolveSessionFile(serverUrl);
|
|
339
|
+
if (!file) return null;
|
|
340
|
+
try {
|
|
341
|
+
return JSON.parse(fs3.readFileSync(file, "utf8"));
|
|
342
|
+
} catch {
|
|
343
|
+
return null;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
function saveSession(session) {
|
|
347
|
+
if (!fs3.existsSync(SESSION_DIR)) {
|
|
348
|
+
fs3.mkdirSync(SESSION_DIR, { recursive: true, mode: 448 });
|
|
349
|
+
}
|
|
350
|
+
const file = sessionFileForServer(session.server);
|
|
351
|
+
fs3.writeFileSync(file, JSON.stringify(session, null, 2), { mode: 384 });
|
|
352
|
+
return file;
|
|
353
|
+
}
|
|
354
|
+
function clearSession(serverUrl) {
|
|
355
|
+
const file = sessionFileForServer(serverUrl);
|
|
356
|
+
if (fs3.existsSync(file)) {
|
|
357
|
+
fs3.unlinkSync(file);
|
|
358
|
+
return true;
|
|
359
|
+
}
|
|
360
|
+
return false;
|
|
361
|
+
}
|
|
362
|
+
function prompt(question, masked = false) {
|
|
363
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
364
|
+
return new Promise((resolve3) => {
|
|
365
|
+
if (!masked) {
|
|
366
|
+
rl.question(question, (answer) => {
|
|
367
|
+
rl.close();
|
|
368
|
+
resolve3(answer);
|
|
369
|
+
});
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
process.stdout.write(question);
|
|
373
|
+
const stdin = process.stdin;
|
|
374
|
+
let value = "";
|
|
375
|
+
const onData = (chunk) => {
|
|
376
|
+
const s = chunk.toString("utf8");
|
|
377
|
+
for (const ch of s) {
|
|
378
|
+
if (ch === "\r" || ch === "\n") {
|
|
379
|
+
stdin.removeListener("data", onData);
|
|
380
|
+
stdin.setRawMode(false);
|
|
381
|
+
stdin.pause();
|
|
382
|
+
rl.close();
|
|
383
|
+
process.stdout.write("\n");
|
|
384
|
+
resolve3(value);
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
if (ch === "") {
|
|
388
|
+
process.exit(130);
|
|
389
|
+
}
|
|
390
|
+
if (ch === "\x7F" || ch === "\b") {
|
|
391
|
+
if (value.length > 0) {
|
|
392
|
+
value = value.slice(0, -1);
|
|
393
|
+
process.stdout.write("\b \b");
|
|
394
|
+
}
|
|
395
|
+
continue;
|
|
396
|
+
}
|
|
397
|
+
value += ch;
|
|
398
|
+
process.stdout.write("*");
|
|
399
|
+
}
|
|
400
|
+
};
|
|
401
|
+
stdin.setRawMode(true);
|
|
402
|
+
stdin.resume();
|
|
403
|
+
stdin.on("data", onData);
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
async function callTrpcMutation(url, authorization, payload, isPayload) {
|
|
407
|
+
const headers = { "Content-Type": "application/json" };
|
|
408
|
+
if (authorization) headers["Authorization"] = authorization;
|
|
409
|
+
const res = await fetch(url, {
|
|
410
|
+
method: "POST",
|
|
411
|
+
headers,
|
|
412
|
+
body: JSON.stringify({ "0": { json: payload } })
|
|
413
|
+
});
|
|
414
|
+
const text = await res.text();
|
|
415
|
+
const parseJson = (raw) => {
|
|
416
|
+
if (!raw) return null;
|
|
417
|
+
try {
|
|
418
|
+
return JSON.parse(raw);
|
|
419
|
+
} catch {
|
|
420
|
+
return raw;
|
|
421
|
+
}
|
|
422
|
+
};
|
|
423
|
+
const body = parseJson(text);
|
|
424
|
+
if (!res.ok) {
|
|
425
|
+
const errMsg2 = body !== null && typeof body === "object" && "error" in body ? String(body.error) : text.slice(0, 200);
|
|
426
|
+
throw new Error(`${res.status} ${res.statusText}: ${errMsg2}`);
|
|
427
|
+
}
|
|
428
|
+
const first = Array.isArray(body) ? body[0] : body;
|
|
429
|
+
if (first === null || typeof first !== "object") throw new Error(`${url}: response missing envelope`);
|
|
430
|
+
const result = first.result;
|
|
431
|
+
if (result === null || result === void 0 || typeof result !== "object") throw new Error(`${url}: response missing result`);
|
|
432
|
+
const data = result.data;
|
|
433
|
+
if (data === null || data === void 0 || typeof data !== "object") throw new Error(`${url}: response missing result.data`);
|
|
434
|
+
const json = data.json;
|
|
435
|
+
if (!isPayload(json)) throw new Error(`${url}: response payload shape mismatch`);
|
|
436
|
+
return json;
|
|
437
|
+
}
|
|
438
|
+
function isLoginPayload(value) {
|
|
439
|
+
if (value === null || typeof value !== "object") return false;
|
|
440
|
+
const v = value;
|
|
441
|
+
if (typeof v.token !== "string") return false;
|
|
442
|
+
if (v.user === null || typeof v.user !== "object") return false;
|
|
443
|
+
const u = v.user;
|
|
444
|
+
return typeof u.id === "string" && typeof u.username === "string";
|
|
445
|
+
}
|
|
446
|
+
function isCreateScopedTokenPayload(value) {
|
|
447
|
+
if (value === null || typeof value !== "object") return false;
|
|
448
|
+
const v = value;
|
|
449
|
+
if (typeof v.token !== "string") return false;
|
|
450
|
+
if (v.record === null || typeof v.record !== "object") return false;
|
|
451
|
+
return typeof v.record.id === "string";
|
|
452
|
+
}
|
|
453
|
+
function isUnknown(_value) {
|
|
454
|
+
return true;
|
|
455
|
+
}
|
|
456
|
+
async function loginCommand(opts) {
|
|
457
|
+
const server = opts.server;
|
|
458
|
+
const username = opts.username ?? await prompt(`Username for ${server}: `);
|
|
459
|
+
const password = opts.password ?? await prompt(`Password: `, true);
|
|
460
|
+
console.log(`[camstack] Authenticating against ${server}\u2026`);
|
|
461
|
+
const login = await callTrpcMutation(
|
|
462
|
+
`${server}/trpc/auth.login?batch=1`,
|
|
463
|
+
void 0,
|
|
464
|
+
{ username, password },
|
|
465
|
+
isLoginPayload
|
|
466
|
+
);
|
|
467
|
+
const jwt = login.token;
|
|
468
|
+
const user = login.user;
|
|
469
|
+
const prior = loadSession(server);
|
|
470
|
+
if (prior && prior.tokenId) {
|
|
471
|
+
try {
|
|
472
|
+
await callTrpcMutation(
|
|
473
|
+
`${server}/trpc/userManagement.revokeScopedToken?batch=1`,
|
|
474
|
+
`Bearer ${jwt}`,
|
|
475
|
+
{ id: prior.tokenId },
|
|
476
|
+
isUnknown
|
|
477
|
+
);
|
|
478
|
+
} catch (err) {
|
|
479
|
+
console.warn(`[camstack] Failed to revoke prior token (${prior.tokenId}): ${err instanceof Error ? err.message : String(err)}`);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
console.log(`[camstack] Creating upload-only scoped token\u2026`);
|
|
483
|
+
const tokenName = opts.tokenName ?? `camstack-cli@${os2.hostname()}`;
|
|
484
|
+
const scopes = [{ type: "route-prefix", target: "/api/addons/upload" }];
|
|
485
|
+
const created = await callTrpcMutation(
|
|
486
|
+
`${server}/trpc/userManagement.createScopedToken?batch=1`,
|
|
487
|
+
`Bearer ${jwt}`,
|
|
488
|
+
{ name: tokenName, scopes },
|
|
489
|
+
isCreateScopedTokenPayload
|
|
490
|
+
);
|
|
491
|
+
const scopedToken = created.token;
|
|
492
|
+
const tokenId = created.record.id;
|
|
493
|
+
const session = {
|
|
494
|
+
server,
|
|
495
|
+
username: user.username,
|
|
496
|
+
tokenId,
|
|
497
|
+
token: scopedToken,
|
|
498
|
+
scopes,
|
|
499
|
+
createdAt: Date.now()
|
|
500
|
+
};
|
|
501
|
+
const file = saveSession(session);
|
|
502
|
+
console.log(`[camstack] \u2713 Logged in as ${user.username} \u2192 ${server}`);
|
|
503
|
+
console.log(`[camstack] Scoped token: ${scopedToken.slice(0, 12)}\u2026 (id: ${tokenId})`);
|
|
504
|
+
console.log(`[camstack] Cached at: ${file}`);
|
|
505
|
+
console.log(`[camstack] Scope: upload addons only \u2014 no other API surface granted.`);
|
|
506
|
+
}
|
|
507
|
+
async function logoutCommand(opts) {
|
|
508
|
+
const session = loadSession(opts.server);
|
|
509
|
+
if (!session) {
|
|
510
|
+
console.log(`[camstack] No active session for ${opts.server}.`);
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
try {
|
|
514
|
+
await callTrpcMutation(
|
|
515
|
+
`${session.server}/trpc/userManagement.revokeScopedToken?batch=1`,
|
|
516
|
+
`Bearer ${session.token}`,
|
|
517
|
+
{ id: session.tokenId },
|
|
518
|
+
isUnknown
|
|
519
|
+
);
|
|
520
|
+
} catch (err) {
|
|
521
|
+
console.warn(`[camstack] Server-side revoke failed (${err instanceof Error ? err.message : String(err)}). Removing local cache anyway.`);
|
|
522
|
+
}
|
|
523
|
+
clearSession(session.server);
|
|
524
|
+
console.log(`[camstack] \u2713 Logged out of ${session.server}`);
|
|
525
|
+
}
|
|
526
|
+
function whoamiCommand(opts) {
|
|
527
|
+
const session = loadSession(opts.server);
|
|
528
|
+
if (!session) {
|
|
529
|
+
console.log(`[camstack] Not logged in${opts.server ? ` to ${opts.server}` : ""}.`);
|
|
530
|
+
process.exit(1);
|
|
531
|
+
}
|
|
532
|
+
console.log(`[camstack] Active session:`);
|
|
533
|
+
console.log(` server: ${session.server}`);
|
|
534
|
+
console.log(` username: ${session.username}`);
|
|
535
|
+
console.log(` token: ${session.token.slice(0, 12)}\u2026 (id: ${session.tokenId})`);
|
|
536
|
+
console.log(` scopes: ${session.scopes.map((s) => `${s.type}:${s.target}`).join(", ")}`);
|
|
537
|
+
console.log(` createdAt: ${new Date(session.createdAt).toISOString()}`);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// src/cli.ts
|
|
541
|
+
var __dirname = dirname(fileURLToPath(import.meta.url));
|
|
542
|
+
var require2 = createRequire(import.meta.url);
|
|
543
|
+
var pkgVersion = "0.1.0";
|
|
544
|
+
try {
|
|
545
|
+
const pkg = require2(resolve2(__dirname, "..", "package.json"));
|
|
546
|
+
pkgVersion = pkg.version;
|
|
547
|
+
} catch {
|
|
548
|
+
}
|
|
549
|
+
function errMsg(err) {
|
|
550
|
+
if (err instanceof Error) return err.message;
|
|
551
|
+
if (typeof err === "string") return err;
|
|
552
|
+
return String(err);
|
|
553
|
+
}
|
|
554
|
+
var HELP_FLAGS = /* @__PURE__ */ new Set(["-h", "--help"]);
|
|
555
|
+
function printGlobalHelp(commands) {
|
|
556
|
+
const lines = [];
|
|
557
|
+
lines.push(`camstack v${pkgVersion} \u2014 self-hosted camera platform`);
|
|
558
|
+
lines.push("");
|
|
559
|
+
lines.push("Usage: camstack <command> [options]");
|
|
560
|
+
lines.push("");
|
|
561
|
+
lines.push("Commands:");
|
|
562
|
+
const widest = Math.max(...commands.map((c) => c.name.length));
|
|
563
|
+
for (const cmd of commands) {
|
|
564
|
+
const padded = cmd.name.padEnd(widest, " ");
|
|
565
|
+
const aliasNote = cmd.aliases && cmd.aliases.length > 0 ? ` (alias: ${cmd.aliases.join(", ")})` : "";
|
|
566
|
+
lines.push(` ${padded} ${cmd.summary}${aliasNote}`);
|
|
567
|
+
}
|
|
568
|
+
lines.push("");
|
|
569
|
+
lines.push("Global flags:");
|
|
570
|
+
lines.push(" -v, --version Print version");
|
|
571
|
+
lines.push(" -h, --help Show this help, or per-command help with `camstack <cmd> --help`");
|
|
572
|
+
console.log(lines.join("\n"));
|
|
573
|
+
}
|
|
574
|
+
function buildCommands() {
|
|
575
|
+
return [
|
|
576
|
+
{
|
|
577
|
+
name: "serve",
|
|
578
|
+
summary: "Start CamStack in hub mode",
|
|
579
|
+
run: runServe,
|
|
580
|
+
help: () => [
|
|
581
|
+
"Usage: camstack serve [options]",
|
|
582
|
+
"",
|
|
583
|
+
"Options:",
|
|
584
|
+
" -p, --port <port> Port to listen on",
|
|
585
|
+
" -d, --data <path> Data directory path"
|
|
586
|
+
].join("\n")
|
|
587
|
+
},
|
|
588
|
+
{
|
|
589
|
+
name: "agent",
|
|
590
|
+
summary: "Start CamStack in agent mode (worker node)",
|
|
591
|
+
run: runAgent,
|
|
592
|
+
help: () => [
|
|
593
|
+
"Usage: camstack agent [options]",
|
|
594
|
+
"",
|
|
595
|
+
"Required:",
|
|
596
|
+
" --hub <ip> Hub server IP address",
|
|
597
|
+
" --token <token> Authentication token",
|
|
598
|
+
"",
|
|
599
|
+
"Optional:",
|
|
600
|
+
" --name <name> Agent name (default: hostname-arch)",
|
|
601
|
+
" --port <port> Hub port (default: 4443)"
|
|
602
|
+
].join("\n")
|
|
603
|
+
},
|
|
604
|
+
{
|
|
605
|
+
name: "setup",
|
|
606
|
+
summary: "Check dependencies and create default config",
|
|
607
|
+
run: runSetup,
|
|
608
|
+
help: () => [
|
|
609
|
+
"Usage: camstack setup [options]",
|
|
610
|
+
"",
|
|
611
|
+
"Options:",
|
|
612
|
+
" -d, --data <path> Data directory path"
|
|
613
|
+
].join("\n")
|
|
614
|
+
},
|
|
615
|
+
{
|
|
616
|
+
name: "login",
|
|
617
|
+
summary: "Authenticate and cache a long-lived upload-only token at ~/.camstack/<host>.json",
|
|
618
|
+
run: runLogin,
|
|
619
|
+
help: () => [
|
|
620
|
+
"Usage: camstack login [options]",
|
|
621
|
+
"",
|
|
622
|
+
"Options:",
|
|
623
|
+
" -s, --server <url> CamStack server URL (default: $CAMSTACK_SERVER or https://localhost:4443)",
|
|
624
|
+
" -u, --username <name> Username (skips prompt)",
|
|
625
|
+
" -p, --password <pwd> Password (skips prompt \u2014 prefer letting the CLI prompt)",
|
|
626
|
+
` --token-name <name> Name shown server-side (default: camstack-cli@${os3.hostname()})`
|
|
627
|
+
].join("\n")
|
|
628
|
+
},
|
|
629
|
+
{
|
|
630
|
+
name: "logout",
|
|
631
|
+
summary: "Revoke the cached scoped token and drop the local session file",
|
|
632
|
+
run: runLogout,
|
|
633
|
+
help: () => [
|
|
634
|
+
"Usage: camstack logout [options]",
|
|
635
|
+
"",
|
|
636
|
+
"Options:",
|
|
637
|
+
" -s, --server <url> CamStack server URL (default: $CAMSTACK_SERVER or https://localhost:4443)"
|
|
638
|
+
].join("\n")
|
|
639
|
+
},
|
|
640
|
+
{
|
|
641
|
+
name: "whoami",
|
|
642
|
+
summary: "Print the active cached session (server + username + scopes)",
|
|
643
|
+
run: runWhoami,
|
|
644
|
+
help: () => [
|
|
645
|
+
"Usage: camstack whoami [options]",
|
|
646
|
+
"",
|
|
647
|
+
"Options:",
|
|
648
|
+
" -s, --server <url> Pick a specific session (default: most recently used)"
|
|
649
|
+
].join("\n")
|
|
650
|
+
},
|
|
651
|
+
{
|
|
652
|
+
name: "deploy",
|
|
653
|
+
aliases: ["push"],
|
|
654
|
+
summary: "Build and deploy an addon to a CamStack server (hub by default, or cluster-wide)",
|
|
655
|
+
run: runDeploy,
|
|
656
|
+
help: () => [
|
|
657
|
+
"Usage: camstack deploy [options] [path]",
|
|
658
|
+
" camstack push [options] [path] (alias)",
|
|
659
|
+
"",
|
|
660
|
+
"Argument:",
|
|
661
|
+
' path Path to addon dir, .tgz, or workspace name (e.g. "tailscale")',
|
|
662
|
+
' Default: "." (current dir)',
|
|
663
|
+
"",
|
|
664
|
+
"Options:",
|
|
665
|
+
" -s, --server <url> Server URL (default: cached session from `camstack login`)",
|
|
666
|
+
" -t, --token <token> Auth token override ($CAMSTACK_TOKEN, then cached scoped token)",
|
|
667
|
+
" -n, --node <id> Push to a specific node. Mutually exclusive with --cluster",
|
|
668
|
+
" -c, --cluster Push to hub + every online agent (requires admin token)"
|
|
669
|
+
].join("\n")
|
|
670
|
+
},
|
|
671
|
+
{
|
|
672
|
+
name: "info",
|
|
673
|
+
summary: "Print detailed version and platform info",
|
|
674
|
+
run: () => {
|
|
675
|
+
console.log(`camstack v${pkgVersion}`);
|
|
676
|
+
console.log(`Platform: ${os3.platform()}-${os3.arch()}`);
|
|
677
|
+
console.log(`Node.js: ${process.version}`);
|
|
678
|
+
console.log(`CPU: ${os3.cpus()[0]?.model ?? "unknown"} (${os3.cpus().length} cores)`);
|
|
679
|
+
console.log(`Memory: ${Math.round(os3.totalmem() / 1024 / 1024)} MB`);
|
|
680
|
+
},
|
|
681
|
+
help: () => "Usage: camstack info"
|
|
682
|
+
}
|
|
683
|
+
];
|
|
684
|
+
}
|
|
685
|
+
function parseSubcommandArgs(args, options, allowPositionals) {
|
|
686
|
+
if (args.some((a) => HELP_FLAGS.has(a))) return null;
|
|
687
|
+
const parsed = parseArgs4({
|
|
688
|
+
args: [...args],
|
|
689
|
+
options,
|
|
690
|
+
allowPositionals,
|
|
691
|
+
strict: true
|
|
692
|
+
});
|
|
693
|
+
return {
|
|
694
|
+
values: parsed.values,
|
|
695
|
+
positionals: parsed.positionals
|
|
696
|
+
};
|
|
697
|
+
}
|
|
698
|
+
async function runLogin(args) {
|
|
699
|
+
const parsed = parseSubcommandArgs(args, {
|
|
700
|
+
server: { type: "string", short: "s" },
|
|
701
|
+
username: { type: "string", short: "u" },
|
|
702
|
+
password: { type: "string", short: "p" },
|
|
703
|
+
"token-name": { type: "string" }
|
|
704
|
+
}, false);
|
|
705
|
+
if (!parsed) {
|
|
706
|
+
console.log(commandHelp("login"));
|
|
707
|
+
return;
|
|
708
|
+
}
|
|
709
|
+
const server = stringOpt(parsed.values, "server") ?? process.env.CAMSTACK_SERVER ?? "https://localhost:4443";
|
|
710
|
+
await loginCommand({
|
|
711
|
+
server,
|
|
712
|
+
...optionalString(parsed.values, "username"),
|
|
713
|
+
...optionalString(parsed.values, "password"),
|
|
714
|
+
...optionalString(parsed.values, "token-name", "tokenName")
|
|
715
|
+
});
|
|
716
|
+
}
|
|
717
|
+
async function runLogout(args) {
|
|
718
|
+
const parsed = parseSubcommandArgs(args, {
|
|
719
|
+
server: { type: "string", short: "s" }
|
|
720
|
+
}, false);
|
|
721
|
+
if (!parsed) {
|
|
722
|
+
console.log(commandHelp("logout"));
|
|
723
|
+
return;
|
|
724
|
+
}
|
|
725
|
+
const server = stringOpt(parsed.values, "server") ?? process.env.CAMSTACK_SERVER ?? "https://localhost:4443";
|
|
726
|
+
await logoutCommand({ server });
|
|
727
|
+
}
|
|
728
|
+
function runWhoami(args) {
|
|
729
|
+
const parsed = parseSubcommandArgs(args, {
|
|
730
|
+
server: { type: "string", short: "s" }
|
|
731
|
+
}, false);
|
|
732
|
+
if (!parsed) {
|
|
733
|
+
console.log(commandHelp("whoami"));
|
|
734
|
+
return;
|
|
735
|
+
}
|
|
736
|
+
const server = stringOpt(parsed.values, "server");
|
|
737
|
+
whoamiCommand(server ? { server } : {});
|
|
738
|
+
}
|
|
739
|
+
async function runDeploy(args) {
|
|
740
|
+
const parsed = parseSubcommandArgs(args, {
|
|
741
|
+
server: { type: "string", short: "s" },
|
|
742
|
+
token: { type: "string", short: "t" },
|
|
743
|
+
node: { type: "string", short: "n" },
|
|
744
|
+
cluster: { type: "boolean", short: "c" }
|
|
745
|
+
}, true);
|
|
746
|
+
if (!parsed) {
|
|
747
|
+
console.log(commandHelp("deploy"));
|
|
748
|
+
return;
|
|
749
|
+
}
|
|
750
|
+
const addonPath = parsed.positionals[0] ?? ".";
|
|
751
|
+
const session = loadSession(stringOpt(parsed.values, "server"));
|
|
752
|
+
const server = stringOpt(parsed.values, "server") ?? process.env.CAMSTACK_SERVER ?? session?.server ?? "https://localhost:4443";
|
|
753
|
+
const token = stringOpt(parsed.values, "token") ?? process.env.CAMSTACK_TOKEN ?? session?.token;
|
|
754
|
+
if (!token) {
|
|
755
|
+
console.error("[camstack] Error: No token. Run `camstack login` first, pass --token, or set CAMSTACK_TOKEN.");
|
|
756
|
+
process.exit(1);
|
|
757
|
+
}
|
|
758
|
+
const nodeId = stringOpt(parsed.values, "node");
|
|
759
|
+
const cluster = boolOpt(parsed.values, "cluster");
|
|
760
|
+
if (nodeId && cluster) {
|
|
761
|
+
console.error("[camstack] Error: --node and --cluster are mutually exclusive.");
|
|
762
|
+
process.exit(1);
|
|
763
|
+
}
|
|
764
|
+
await deployAddon(addonPath, {
|
|
765
|
+
serverUrl: server,
|
|
766
|
+
token,
|
|
767
|
+
...nodeId ? { nodeId } : {},
|
|
768
|
+
...cluster ? { cluster: true } : {}
|
|
769
|
+
});
|
|
770
|
+
}
|
|
771
|
+
function commandHelp(name) {
|
|
772
|
+
const cmd = buildCommands().find((c) => c.name === name);
|
|
773
|
+
return cmd ? cmd.help() : "";
|
|
774
|
+
}
|
|
775
|
+
function stringOpt(values, key) {
|
|
776
|
+
const v = values[key];
|
|
777
|
+
return typeof v === "string" ? v : void 0;
|
|
778
|
+
}
|
|
779
|
+
function boolOpt(values, key) {
|
|
780
|
+
return values[key] === true;
|
|
781
|
+
}
|
|
782
|
+
function optionalString(values, key, outKey) {
|
|
783
|
+
const v = values[key];
|
|
784
|
+
if (typeof v !== "string") return {};
|
|
785
|
+
return { [outKey ?? key]: v };
|
|
786
|
+
}
|
|
787
|
+
async function main() {
|
|
788
|
+
const argv = process.argv.slice(2);
|
|
789
|
+
const commands = buildCommands();
|
|
790
|
+
if (argv.length === 0 || argv.length === 1 && HELP_FLAGS.has(argv[0] ?? "")) {
|
|
791
|
+
printGlobalHelp(commands);
|
|
792
|
+
return;
|
|
793
|
+
}
|
|
794
|
+
if (argv[0] === "-v" || argv[0] === "--version") {
|
|
795
|
+
console.log(pkgVersion);
|
|
796
|
+
return;
|
|
797
|
+
}
|
|
798
|
+
const cmdName = argv[0] ?? "";
|
|
799
|
+
const cmd = commands.find((c) => c.name === cmdName || (c.aliases?.includes(cmdName) ?? false));
|
|
800
|
+
if (!cmd) {
|
|
801
|
+
console.error(`[camstack] Unknown command: ${cmdName}`);
|
|
802
|
+
console.error("Run `camstack --help` for the list of commands.");
|
|
803
|
+
process.exit(1);
|
|
804
|
+
}
|
|
805
|
+
try {
|
|
806
|
+
await cmd.run(argv.slice(1));
|
|
807
|
+
} catch (err) {
|
|
808
|
+
console.error(`[camstack] ${cmd.name} failed:`, errMsg(err));
|
|
809
|
+
process.exit(1);
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
void main();
|
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "camstack",
|
|
3
|
+
"version": "0.3.1",
|
|
4
|
+
"description": "CLI tool for managing and running CamStack server",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"camstack",
|
|
7
|
+
"cli",
|
|
8
|
+
"camera",
|
|
9
|
+
"nvr",
|
|
10
|
+
"detection"
|
|
11
|
+
],
|
|
12
|
+
"license": "MIT",
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "https://github.com/camstack/server"
|
|
16
|
+
},
|
|
17
|
+
"type": "module",
|
|
18
|
+
"bin": {
|
|
19
|
+
"camstack": "dist/cli.js"
|
|
20
|
+
},
|
|
21
|
+
"preferGlobal": true,
|
|
22
|
+
"engines": {
|
|
23
|
+
"node": ">=18.3"
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"dist"
|
|
27
|
+
],
|
|
28
|
+
"scripts": {
|
|
29
|
+
"build": "tsup",
|
|
30
|
+
"typecheck": "tsc --noEmit",
|
|
31
|
+
"test": "vitest run",
|
|
32
|
+
"publish": "npm publish"
|
|
33
|
+
},
|
|
34
|
+
"optionalDependencies": {
|
|
35
|
+
"@camstack/server": "^0.1.0"
|
|
36
|
+
},
|
|
37
|
+
"devDependencies": {
|
|
38
|
+
"@camstack/server": "*",
|
|
39
|
+
"@types/node": "^22",
|
|
40
|
+
"tsup": "^8.5.1",
|
|
41
|
+
"typescript": "^5.7.0",
|
|
42
|
+
"vitest": "^3.0.0"
|
|
43
|
+
}
|
|
44
|
+
}
|