camstack 0.3.1 → 0.5.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/chunk-Y4LXGZZ5.js +155 -0
- package/dist/cli.js +269 -76
- package/dist/discover-NPUMWBRW.js +11 -0
- package/package.json +4 -1
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/commands/discover.ts
|
|
4
|
+
import * as dgram from "dgram";
|
|
5
|
+
import * as os from "os";
|
|
6
|
+
import { parseArgs } from "util";
|
|
7
|
+
var DEFAULT_MULTICAST_GROUP = "239.0.0.0";
|
|
8
|
+
var DEFAULT_UDP_PORT = 4445;
|
|
9
|
+
var DEFAULT_HUB_HTTPS_PORT = 4443;
|
|
10
|
+
var PACKET_FIELD_COUNT = 3;
|
|
11
|
+
async function discoverNodes(opts) {
|
|
12
|
+
const namespaceFilter = opts.namespace;
|
|
13
|
+
const port = opts.udpPort ?? DEFAULT_UDP_PORT;
|
|
14
|
+
const group = opts.multicastGroup ?? DEFAULT_MULTICAST_GROUP;
|
|
15
|
+
const timeoutMs = opts.timeoutMs ?? 6e3;
|
|
16
|
+
const found = /* @__PURE__ */ new Map();
|
|
17
|
+
const socket = dgram.createSocket({ type: "udp4", reuseAddr: true });
|
|
18
|
+
await new Promise((resolve, reject) => {
|
|
19
|
+
socket.once("error", reject);
|
|
20
|
+
socket.bind(port, () => {
|
|
21
|
+
try {
|
|
22
|
+
for (const ip of getInterfaceIPv4Addresses()) {
|
|
23
|
+
try {
|
|
24
|
+
socket.addMembership(group, ip);
|
|
25
|
+
} catch {
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
resolve();
|
|
29
|
+
} catch (err) {
|
|
30
|
+
reject(err);
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
socket.on("message", (data, rinfo) => {
|
|
35
|
+
const parsed = parsePacket(data.toString("utf8"));
|
|
36
|
+
if (!parsed) return;
|
|
37
|
+
if (namespaceFilter !== void 0 && parsed.namespace !== namespaceFilter) return;
|
|
38
|
+
const key = `${parsed.nodeID}@${rinfo.address}`;
|
|
39
|
+
if (!found.has(key)) {
|
|
40
|
+
found.set(key, { nodeID: parsed.nodeID, address: rinfo.address, tcpPort: parsed.tcpPort, namespace: parsed.namespace });
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
await new Promise((resolve) => setTimeout(resolve, timeoutMs));
|
|
44
|
+
socket.close();
|
|
45
|
+
return Array.from(found.values()).sort((a, b) => a.nodeID.localeCompare(b.nodeID));
|
|
46
|
+
}
|
|
47
|
+
async function resolveHubFromDiscovered(nodes, httpsPort = DEFAULT_HUB_HTTPS_PORT) {
|
|
48
|
+
const explicitHub = nodes.find((n) => n.nodeID === "hub" || n.nodeID.startsWith("hub-"));
|
|
49
|
+
const candidates = explicitHub ? [explicitHub] : nodes;
|
|
50
|
+
for (const node of candidates) {
|
|
51
|
+
if (await probeHttps(node.address, httpsPort)) {
|
|
52
|
+
return { address: node.address, port: httpsPort, nodeID: node.nodeID };
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
function parsePacket(text) {
|
|
58
|
+
const parts = text.split("|");
|
|
59
|
+
if (parts.length !== PACKET_FIELD_COUNT) return null;
|
|
60
|
+
const [ns, id, portStr] = parts;
|
|
61
|
+
if (typeof ns !== "string" || typeof id !== "string" || typeof portStr !== "string") return null;
|
|
62
|
+
const tcpPort = parseInt(portStr, 10);
|
|
63
|
+
if (Number.isNaN(tcpPort)) return null;
|
|
64
|
+
return { namespace: ns, nodeID: id, tcpPort };
|
|
65
|
+
}
|
|
66
|
+
function getInterfaceIPv4Addresses() {
|
|
67
|
+
const out = [];
|
|
68
|
+
const ifaces = os.networkInterfaces();
|
|
69
|
+
for (const name of Object.keys(ifaces)) {
|
|
70
|
+
const list = ifaces[name] ?? [];
|
|
71
|
+
for (const info of list) {
|
|
72
|
+
if (info.family === "IPv4" && !info.internal) out.push(info.address);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
async function probeHttps(host, port) {
|
|
78
|
+
const { request } = await import("https");
|
|
79
|
+
return new Promise((resolve) => {
|
|
80
|
+
const req = request({
|
|
81
|
+
host,
|
|
82
|
+
port,
|
|
83
|
+
method: "HEAD",
|
|
84
|
+
path: "/trpc/health",
|
|
85
|
+
timeout: 1500,
|
|
86
|
+
rejectUnauthorized: false
|
|
87
|
+
}, (res) => {
|
|
88
|
+
resolve(typeof res.statusCode === "number");
|
|
89
|
+
res.resume();
|
|
90
|
+
});
|
|
91
|
+
req.on("error", () => resolve(false));
|
|
92
|
+
req.on("timeout", () => {
|
|
93
|
+
req.destroy();
|
|
94
|
+
resolve(false);
|
|
95
|
+
});
|
|
96
|
+
req.end();
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
async function runDiscover(args) {
|
|
100
|
+
const { values } = parseArgs({
|
|
101
|
+
args: [...args],
|
|
102
|
+
options: {
|
|
103
|
+
namespace: { type: "string", short: "n" },
|
|
104
|
+
timeout: { type: "string", short: "t" },
|
|
105
|
+
"udp-port": { type: "string" },
|
|
106
|
+
"multicast-group": { type: "string" },
|
|
107
|
+
json: { type: "boolean" }
|
|
108
|
+
},
|
|
109
|
+
strict: true,
|
|
110
|
+
allowPositionals: false
|
|
111
|
+
});
|
|
112
|
+
const namespace = typeof values.namespace === "string" ? values.namespace : process.env.CAMSTACK_NAMESPACE;
|
|
113
|
+
const timeoutMs = typeof values.timeout === "string" ? parseInt(values.timeout, 10) * 1e3 : void 0;
|
|
114
|
+
const udpPort = typeof values["udp-port"] === "string" ? parseInt(values["udp-port"], 10) : void 0;
|
|
115
|
+
const group = typeof values["multicast-group"] === "string" ? values["multicast-group"] : void 0;
|
|
116
|
+
const filterLabel = namespace ? `namespace "${namespace}"` : "all namespaces";
|
|
117
|
+
console.log(`[camstack] Listening for camstack nodes on ${filterLabel} (multicast ${group ?? DEFAULT_MULTICAST_GROUP}:${udpPort ?? DEFAULT_UDP_PORT}, ${(timeoutMs ?? 6e3) / 1e3}s)\u2026`);
|
|
118
|
+
const nodes = await discoverNodes({
|
|
119
|
+
...namespace !== void 0 ? { namespace } : {},
|
|
120
|
+
...timeoutMs !== void 0 ? { timeoutMs } : {},
|
|
121
|
+
...udpPort !== void 0 ? { udpPort } : {},
|
|
122
|
+
...group !== void 0 ? { multicastGroup: group } : {}
|
|
123
|
+
});
|
|
124
|
+
if (values.json === true) {
|
|
125
|
+
console.log(JSON.stringify(nodes, null, 2));
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
if (nodes.length === 0) {
|
|
129
|
+
console.log("[camstack] No nodes responded. Verify the hub is running with a matching namespace + that you are on the same LAN.");
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
console.log(`[camstack] Found ${nodes.length} node(s):`);
|
|
133
|
+
const idWidth = Math.max(...nodes.map((n) => n.nodeID.length));
|
|
134
|
+
const nsWidth = Math.max(...nodes.map((n) => n.namespace.length));
|
|
135
|
+
for (const n of nodes) {
|
|
136
|
+
console.log(` \u2022 ${n.nodeID.padEnd(idWidth)} ${n.address} ns=${n.namespace.padEnd(nsWidth)} (moleculer tcp :${n.tcpPort})`);
|
|
137
|
+
}
|
|
138
|
+
if (namespace) {
|
|
139
|
+
const hub = await resolveHubFromDiscovered(nodes);
|
|
140
|
+
if (hub) {
|
|
141
|
+
console.log(``);
|
|
142
|
+
console.log(`[camstack] Hub HTTPS surface: https://${hub.address}:${hub.port}`);
|
|
143
|
+
console.log(`[camstack] \u2192 camstack login -s https://${hub.address}:${hub.port}`);
|
|
144
|
+
} else {
|
|
145
|
+
console.log(``);
|
|
146
|
+
console.log(`[camstack] No node responded on default HTTPS port ${DEFAULT_HUB_HTTPS_PORT}. Pass --server manually.`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export {
|
|
152
|
+
discoverNodes,
|
|
153
|
+
resolveHubFromDiscovered,
|
|
154
|
+
runDiscover
|
|
155
|
+
};
|
package/dist/cli.js
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
runDiscover
|
|
4
|
+
} from "./chunk-Y4LXGZZ5.js";
|
|
2
5
|
|
|
3
6
|
// src/cli.ts
|
|
4
7
|
import { createRequire } from "module";
|
|
5
8
|
import { fileURLToPath } from "url";
|
|
6
9
|
import { dirname, resolve as resolve2 } from "path";
|
|
7
|
-
import * as
|
|
10
|
+
import * as os4 from "os";
|
|
8
11
|
import { parseArgs as parseArgs4 } from "util";
|
|
9
12
|
|
|
10
13
|
// src/commands/serve.ts
|
|
@@ -318,7 +321,7 @@ async function deployAddon(addonPath, opts) {
|
|
|
318
321
|
import * as fs3 from "fs";
|
|
319
322
|
import * as path3 from "path";
|
|
320
323
|
import * as os2 from "os";
|
|
321
|
-
import * as
|
|
324
|
+
import * as clack from "@clack/prompts";
|
|
322
325
|
var SESSION_DIR = path3.join(os2.homedir(), ".camstack");
|
|
323
326
|
function sessionFileForServer(serverUrl) {
|
|
324
327
|
const slug = serverUrl.replace(/^https?:\/\//, "").replace(/[:/?#]+/g, "_").replace(/_+$/, "");
|
|
@@ -359,49 +362,34 @@ function clearSession(serverUrl) {
|
|
|
359
362
|
}
|
|
360
363
|
return false;
|
|
361
364
|
}
|
|
362
|
-
function
|
|
363
|
-
const
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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);
|
|
365
|
+
async function askText(message, defaultValue) {
|
|
366
|
+
const result = await clack.text({
|
|
367
|
+
message,
|
|
368
|
+
defaultValue: defaultValue ?? "",
|
|
369
|
+
placeholder: defaultValue ?? ""
|
|
404
370
|
});
|
|
371
|
+
if (clack.isCancel(result)) {
|
|
372
|
+
clack.cancel("Cancelled.");
|
|
373
|
+
process.exit(130);
|
|
374
|
+
}
|
|
375
|
+
return result;
|
|
376
|
+
}
|
|
377
|
+
async function askPassword(message) {
|
|
378
|
+
const result = await clack.password({ message });
|
|
379
|
+
if (clack.isCancel(result)) {
|
|
380
|
+
clack.cancel("Cancelled.");
|
|
381
|
+
process.exit(130);
|
|
382
|
+
}
|
|
383
|
+
return result;
|
|
384
|
+
}
|
|
385
|
+
async function askSelect(message, options) {
|
|
386
|
+
const mutable = options.map((o) => o.hint ? { value: o.value, label: o.label, hint: o.hint } : { value: o.value, label: o.label });
|
|
387
|
+
const result = await clack.select({ message, options: mutable });
|
|
388
|
+
if (clack.isCancel(result)) {
|
|
389
|
+
clack.cancel("Cancelled.");
|
|
390
|
+
process.exit(130);
|
|
391
|
+
}
|
|
392
|
+
return result;
|
|
405
393
|
}
|
|
406
394
|
async function callTrpcMutation(url, authorization, payload, isPayload) {
|
|
407
395
|
const headers = { "Content-Type": "application/json" };
|
|
@@ -411,7 +399,7 @@ async function callTrpcMutation(url, authorization, payload, isPayload) {
|
|
|
411
399
|
headers,
|
|
412
400
|
body: JSON.stringify({ "0": { json: payload } })
|
|
413
401
|
});
|
|
414
|
-
const
|
|
402
|
+
const text2 = await res.text();
|
|
415
403
|
const parseJson = (raw) => {
|
|
416
404
|
if (!raw) return null;
|
|
417
405
|
try {
|
|
@@ -420,9 +408,9 @@ async function callTrpcMutation(url, authorization, payload, isPayload) {
|
|
|
420
408
|
return raw;
|
|
421
409
|
}
|
|
422
410
|
};
|
|
423
|
-
const body = parseJson(
|
|
411
|
+
const body = parseJson(text2);
|
|
424
412
|
if (!res.ok) {
|
|
425
|
-
const errMsg2 = body !== null && typeof body === "object" && "error" in body ? String(body.error) :
|
|
413
|
+
const errMsg2 = body !== null && typeof body === "object" && "error" in body ? String(body.error) : text2.slice(0, 200);
|
|
426
414
|
throw new Error(`${res.status} ${res.statusText}: ${errMsg2}`);
|
|
427
415
|
}
|
|
428
416
|
const first = Array.isArray(body) ? body[0] : body;
|
|
@@ -453,19 +441,90 @@ function isCreateScopedTokenPayload(value) {
|
|
|
453
441
|
function isUnknown(_value) {
|
|
454
442
|
return true;
|
|
455
443
|
}
|
|
456
|
-
async function
|
|
457
|
-
const
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
444
|
+
async function resolveServerInteractive(presetNamespace) {
|
|
445
|
+
const { discoverNodes, resolveHubFromDiscovered } = await import("./discover-NPUMWBRW.js");
|
|
446
|
+
if (presetNamespace) {
|
|
447
|
+
const spinner3 = clack.spinner();
|
|
448
|
+
spinner3.start(`Discovering hub on LAN (namespace "${presetNamespace}")`);
|
|
449
|
+
const filtered = await discoverNodes({ namespace: presetNamespace });
|
|
450
|
+
if (filtered.length === 0) {
|
|
451
|
+
spinner3.stop(`No nodes responded for namespace "${presetNamespace}".`);
|
|
452
|
+
throw new Error(`Hub running? Same LAN?`);
|
|
453
|
+
}
|
|
454
|
+
const hub = await resolveHubFromDiscovered(filtered);
|
|
455
|
+
if (!hub) {
|
|
456
|
+
spinner3.stop(`Found ${filtered.length} node(s) but none responded on HTTPS :4443.`);
|
|
457
|
+
throw new Error("Pass --server explicitly if the hub uses a non-default port.");
|
|
458
|
+
}
|
|
459
|
+
spinner3.stop(`Hub: ${hub.nodeID} @ https://${hub.address}:${hub.port}`);
|
|
460
|
+
return `https://${hub.address}:${hub.port}`;
|
|
461
|
+
}
|
|
462
|
+
const spinner2 = clack.spinner();
|
|
463
|
+
spinner2.start("Scanning LAN for camstack nodes (UDP multicast)");
|
|
464
|
+
const all = await discoverNodes({});
|
|
465
|
+
if (all.length === 0) {
|
|
466
|
+
spinner2.stop("No camstack nodes responded on LAN.");
|
|
467
|
+
throw new Error("Either pass --server or verify the hub is running on the same network.");
|
|
468
|
+
}
|
|
469
|
+
spinner2.stop(`Found ${all.length} node(s).`);
|
|
470
|
+
const chosen = await askSelect(
|
|
471
|
+
"Select a hub to log into",
|
|
472
|
+
all.map((n) => ({
|
|
473
|
+
value: `${n.address}|${n.tcpPort}|${n.nodeID}|${n.namespace}`,
|
|
474
|
+
label: `${n.nodeID} ${n.address} (ns: ${n.namespace || "(none)"})`,
|
|
475
|
+
hint: `tcp :${n.tcpPort}`
|
|
476
|
+
}))
|
|
466
477
|
);
|
|
467
|
-
const
|
|
468
|
-
const
|
|
478
|
+
const [addr, , nodeID] = chosen.split("|");
|
|
479
|
+
const chosenNode = all.find((n) => n.address === addr && n.nodeID === nodeID);
|
|
480
|
+
const probeSpinner = clack.spinner();
|
|
481
|
+
probeSpinner.start(`Probing https://${chosenNode.address}:4443`);
|
|
482
|
+
const probed = await resolveHubFromDiscovered([chosenNode]);
|
|
483
|
+
if (!probed) {
|
|
484
|
+
probeSpinner.stop(`Node ${chosenNode.nodeID} at ${chosenNode.address} did not respond on HTTPS :4443.`);
|
|
485
|
+
throw new Error("Pass --server explicitly if the hub uses a non-default port.");
|
|
486
|
+
}
|
|
487
|
+
probeSpinner.stop(`Reachable at https://${probed.address}:${probed.port}`);
|
|
488
|
+
return `https://${probed.address}:${probed.port}`;
|
|
489
|
+
}
|
|
490
|
+
async function loginCommand(opts) {
|
|
491
|
+
clack.intro("camstack login");
|
|
492
|
+
let server = opts.server;
|
|
493
|
+
if (!server) {
|
|
494
|
+
server = await resolveServerInteractive(opts.namespace);
|
|
495
|
+
} else {
|
|
496
|
+
clack.log.info(`Server: ${server}`);
|
|
497
|
+
}
|
|
498
|
+
const username = opts.username ?? await askText("Username", "admin");
|
|
499
|
+
const MAX_ATTEMPTS = 3;
|
|
500
|
+
let password2 = opts.password ?? await askPassword("Password");
|
|
501
|
+
let attempt = 1;
|
|
502
|
+
let jwt;
|
|
503
|
+
let displayName;
|
|
504
|
+
while (true) {
|
|
505
|
+
const authSpinner = clack.spinner();
|
|
506
|
+
authSpinner.start(`Authenticating as ${username}${attempt > 1 ? ` (attempt ${attempt}/${MAX_ATTEMPTS})` : ""}`);
|
|
507
|
+
try {
|
|
508
|
+
const login = await callTrpcMutation(
|
|
509
|
+
`${server}/trpc/auth.login?batch=1`,
|
|
510
|
+
void 0,
|
|
511
|
+
{ username, password: password2 },
|
|
512
|
+
isLoginPayload
|
|
513
|
+
);
|
|
514
|
+
jwt = login.token;
|
|
515
|
+
displayName = login.user.username;
|
|
516
|
+
authSpinner.stop(`Authenticated as ${displayName}`);
|
|
517
|
+
break;
|
|
518
|
+
} catch (err) {
|
|
519
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
520
|
+
authSpinner.stop(`Auth failed: ${msg}`);
|
|
521
|
+
if (opts.password !== void 0 || attempt >= MAX_ATTEMPTS) {
|
|
522
|
+
throw err;
|
|
523
|
+
}
|
|
524
|
+
attempt++;
|
|
525
|
+
password2 = await askPassword("Password (retry)");
|
|
526
|
+
}
|
|
527
|
+
}
|
|
469
528
|
const prior = loadSession(server);
|
|
470
529
|
if (prior && prior.tokenId) {
|
|
471
530
|
try {
|
|
@@ -476,10 +535,11 @@ async function loginCommand(opts) {
|
|
|
476
535
|
isUnknown
|
|
477
536
|
);
|
|
478
537
|
} catch (err) {
|
|
479
|
-
|
|
538
|
+
clack.log.warn(`Failed to revoke prior token (${prior.tokenId}): ${err instanceof Error ? err.message : String(err)}`);
|
|
480
539
|
}
|
|
481
540
|
}
|
|
482
|
-
|
|
541
|
+
const mintSpinner = clack.spinner();
|
|
542
|
+
mintSpinner.start("Creating upload-only scoped token");
|
|
483
543
|
const tokenName = opts.tokenName ?? `camstack-cli@${os2.hostname()}`;
|
|
484
544
|
const scopes = [{ type: "route-prefix", target: "/api/addons/upload" }];
|
|
485
545
|
const created = await callTrpcMutation(
|
|
@@ -490,19 +550,19 @@ async function loginCommand(opts) {
|
|
|
490
550
|
);
|
|
491
551
|
const scopedToken = created.token;
|
|
492
552
|
const tokenId = created.record.id;
|
|
553
|
+
mintSpinner.stop(`Token created (${scopedToken.slice(0, 12)}\u2026)`);
|
|
493
554
|
const session = {
|
|
494
555
|
server,
|
|
495
|
-
username:
|
|
556
|
+
username: displayName,
|
|
496
557
|
tokenId,
|
|
497
558
|
token: scopedToken,
|
|
498
559
|
scopes,
|
|
499
560
|
createdAt: Date.now()
|
|
500
561
|
};
|
|
501
562
|
const file = saveSession(session);
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
console.log(`[camstack] Scope: upload addons only \u2014 no other API surface granted.`);
|
|
563
|
+
clack.outro(`\u2713 Logged in as ${displayName} \u2192 ${server}
|
|
564
|
+
Cached at ${file}
|
|
565
|
+
Scope: upload addons only`);
|
|
506
566
|
}
|
|
507
567
|
async function logoutCommand(opts) {
|
|
508
568
|
const session = loadSession(opts.server);
|
|
@@ -510,6 +570,9 @@ async function logoutCommand(opts) {
|
|
|
510
570
|
console.log(`[camstack] No active session for ${opts.server}.`);
|
|
511
571
|
return;
|
|
512
572
|
}
|
|
573
|
+
clack.intro("camstack logout");
|
|
574
|
+
const spinner2 = clack.spinner();
|
|
575
|
+
spinner2.start(`Revoking token on ${session.server}`);
|
|
513
576
|
try {
|
|
514
577
|
await callTrpcMutation(
|
|
515
578
|
`${session.server}/trpc/userManagement.revokeScopedToken?batch=1`,
|
|
@@ -517,11 +580,12 @@ async function logoutCommand(opts) {
|
|
|
517
580
|
{ id: session.tokenId },
|
|
518
581
|
isUnknown
|
|
519
582
|
);
|
|
583
|
+
spinner2.stop("Token revoked server-side.");
|
|
520
584
|
} catch (err) {
|
|
521
|
-
|
|
585
|
+
spinner2.stop(`Server-side revoke failed (${err instanceof Error ? err.message : String(err)}). Removing local cache anyway.`);
|
|
522
586
|
}
|
|
523
587
|
clearSession(session.server);
|
|
524
|
-
|
|
588
|
+
clack.outro(`\u2713 Logged out of ${session.server}`);
|
|
525
589
|
}
|
|
526
590
|
function whoamiCommand(opts) {
|
|
527
591
|
const session = loadSession(opts.server);
|
|
@@ -537,6 +601,105 @@ function whoamiCommand(opts) {
|
|
|
537
601
|
console.log(` createdAt: ${new Date(session.createdAt).toISOString()}`);
|
|
538
602
|
}
|
|
539
603
|
|
|
604
|
+
// src/update-notifier.ts
|
|
605
|
+
import * as fs4 from "fs";
|
|
606
|
+
import * as path4 from "path";
|
|
607
|
+
import * as os3 from "os";
|
|
608
|
+
var CACHE_DIR = path4.join(os3.homedir(), ".camstack");
|
|
609
|
+
var CACHE_FILE = path4.join(CACHE_DIR, "update-check.json");
|
|
610
|
+
var CHECK_INTERVAL_MS = 24 * 60 * 60 * 1e3;
|
|
611
|
+
var REGISTRY_BASE = "https://registry.npmjs.org";
|
|
612
|
+
var FETCH_TIMEOUT_MS = 2500;
|
|
613
|
+
function readCache() {
|
|
614
|
+
try {
|
|
615
|
+
if (!fs4.existsSync(CACHE_FILE)) return null;
|
|
616
|
+
const parsed = JSON.parse(fs4.readFileSync(CACHE_FILE, "utf8"));
|
|
617
|
+
if (!parsed || typeof parsed !== "object") return null;
|
|
618
|
+
const c = parsed;
|
|
619
|
+
if (typeof c.lastCheckAt !== "number") return null;
|
|
620
|
+
return {
|
|
621
|
+
lastCheckAt: c.lastCheckAt,
|
|
622
|
+
latestVersion: typeof c.latestVersion === "string" ? c.latestVersion : null,
|
|
623
|
+
lastNotifiedVersion: typeof c.lastNotifiedVersion === "string" ? c.lastNotifiedVersion : null
|
|
624
|
+
};
|
|
625
|
+
} catch {
|
|
626
|
+
return null;
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
function writeCache(c) {
|
|
630
|
+
try {
|
|
631
|
+
if (!fs4.existsSync(CACHE_DIR)) fs4.mkdirSync(CACHE_DIR, { recursive: true, mode: 448 });
|
|
632
|
+
fs4.writeFileSync(CACHE_FILE, JSON.stringify(c, null, 2));
|
|
633
|
+
} catch {
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
function isNewer(current, latest) {
|
|
637
|
+
const a = parseSemver(current);
|
|
638
|
+
const b = parseSemver(latest);
|
|
639
|
+
if (!a || !b) return false;
|
|
640
|
+
for (let i = 0; i < 3; i++) {
|
|
641
|
+
if ((b[i] ?? 0) > (a[i] ?? 0)) return true;
|
|
642
|
+
if ((b[i] ?? 0) < (a[i] ?? 0)) return false;
|
|
643
|
+
}
|
|
644
|
+
return false;
|
|
645
|
+
}
|
|
646
|
+
function parseSemver(v) {
|
|
647
|
+
const m = v.replace(/^v/, "").split(/[-+]/)[0]?.split(".");
|
|
648
|
+
if (!m || m.length < 3) return null;
|
|
649
|
+
const parts = m.map((s) => parseInt(s, 10));
|
|
650
|
+
if (parts.some(Number.isNaN)) return null;
|
|
651
|
+
return [parts[0], parts[1], parts[2]];
|
|
652
|
+
}
|
|
653
|
+
async function fetchLatestVersion(pkg) {
|
|
654
|
+
try {
|
|
655
|
+
const ac = new AbortController();
|
|
656
|
+
const timer = setTimeout(() => ac.abort(), FETCH_TIMEOUT_MS);
|
|
657
|
+
const res = await fetch(`${REGISTRY_BASE}/${pkg}/latest`, {
|
|
658
|
+
signal: ac.signal,
|
|
659
|
+
headers: { accept: "application/json" }
|
|
660
|
+
});
|
|
661
|
+
clearTimeout(timer);
|
|
662
|
+
if (!res.ok) return null;
|
|
663
|
+
const body = await res.json();
|
|
664
|
+
return typeof body.version === "string" ? body.version : null;
|
|
665
|
+
} catch {
|
|
666
|
+
return null;
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
function maybeNotifyUpdate(pkgName, currentVersion) {
|
|
670
|
+
if (process.env.CAMSTACK_NO_UPDATE_CHECK || process.env.NO_UPDATE_NOTIFIER) return;
|
|
671
|
+
if (!process.stdout.isTTY) return;
|
|
672
|
+
const cache = readCache();
|
|
673
|
+
const now = Date.now();
|
|
674
|
+
if (cache?.latestVersion && cache.latestVersion !== cache.lastNotifiedVersion && isNewer(currentVersion, cache.latestVersion)) {
|
|
675
|
+
printBanner(pkgName, currentVersion, cache.latestVersion);
|
|
676
|
+
writeCache({ ...cache, lastNotifiedVersion: cache.latestVersion });
|
|
677
|
+
}
|
|
678
|
+
const isStale = !cache || now - cache.lastCheckAt > CHECK_INTERVAL_MS;
|
|
679
|
+
if (isStale) {
|
|
680
|
+
fetchLatestVersion(pkgName).then((latest) => {
|
|
681
|
+
if (!latest) return;
|
|
682
|
+
writeCache({
|
|
683
|
+
lastCheckAt: Date.now(),
|
|
684
|
+
latestVersion: latest,
|
|
685
|
+
lastNotifiedVersion: cache?.lastNotifiedVersion ?? null
|
|
686
|
+
});
|
|
687
|
+
}).catch(() => {
|
|
688
|
+
});
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
function printBanner(pkgName, current, latest) {
|
|
692
|
+
const lines = [
|
|
693
|
+
"",
|
|
694
|
+
` \u250C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510`,
|
|
695
|
+
` \u2502 Update available: ${current} \u2192 ${latest}`.padEnd(60) + "\u2502",
|
|
696
|
+
` \u2502 Run: npx ${pkgName}@latest`.padEnd(60) + "\u2502",
|
|
697
|
+
` \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518`,
|
|
698
|
+
""
|
|
699
|
+
];
|
|
700
|
+
process.stderr.write(lines.join("\n"));
|
|
701
|
+
}
|
|
702
|
+
|
|
540
703
|
// src/cli.ts
|
|
541
704
|
var __dirname = dirname(fileURLToPath(import.meta.url));
|
|
542
705
|
var require2 = createRequire(import.meta.url);
|
|
@@ -619,11 +782,32 @@ function buildCommands() {
|
|
|
619
782
|
help: () => [
|
|
620
783
|
"Usage: camstack login [options]",
|
|
621
784
|
"",
|
|
785
|
+
"Default flow (no flags): scans the LAN for camstack nodes via UDP multicast,",
|
|
786
|
+
" prompts you to pick one (arrow-key list), then user + password.",
|
|
787
|
+
"",
|
|
622
788
|
"Options:",
|
|
623
|
-
" -s, --server <url>
|
|
789
|
+
" -s, --server <url> Skip discovery, use this URL directly ($CAMSTACK_SERVER)",
|
|
790
|
+
" -n, --namespace <ns> Skip the interactive picker \u2014 auto-resolves the hub for this namespace ($CAMSTACK_NAMESPACE)",
|
|
624
791
|
" -u, --username <name> Username (skips prompt)",
|
|
625
792
|
" -p, --password <pwd> Password (skips prompt \u2014 prefer letting the CLI prompt)",
|
|
626
|
-
` --token-name <name> Name shown server-side (default: camstack-cli@${
|
|
793
|
+
` --token-name <name> Name shown server-side (default: camstack-cli@${os4.hostname()})`
|
|
794
|
+
].join("\n")
|
|
795
|
+
},
|
|
796
|
+
{
|
|
797
|
+
name: "discover",
|
|
798
|
+
summary: "Probe the LAN for camstack hubs/agents via UDP multicast",
|
|
799
|
+
run: runDiscover,
|
|
800
|
+
help: () => [
|
|
801
|
+
"Usage: camstack discover [options]",
|
|
802
|
+
"",
|
|
803
|
+
"With no flags: lists every camstack node visible on the LAN (any namespace).",
|
|
804
|
+
"",
|
|
805
|
+
"Options:",
|
|
806
|
+
" -n, --namespace <ns> Filter results to one namespace ($CAMSTACK_NAMESPACE)",
|
|
807
|
+
" -t, --timeout <seconds> Listen window (default: 6)",
|
|
808
|
+
" --udp-port <port> UDP discovery port (default: 4445)",
|
|
809
|
+
" --multicast-group <ip> Multicast group (default: 239.0.0.0)",
|
|
810
|
+
" --json Emit JSON instead of human-readable list"
|
|
627
811
|
].join("\n")
|
|
628
812
|
},
|
|
629
813
|
{
|
|
@@ -673,10 +857,10 @@ function buildCommands() {
|
|
|
673
857
|
summary: "Print detailed version and platform info",
|
|
674
858
|
run: () => {
|
|
675
859
|
console.log(`camstack v${pkgVersion}`);
|
|
676
|
-
console.log(`Platform: ${
|
|
860
|
+
console.log(`Platform: ${os4.platform()}-${os4.arch()}`);
|
|
677
861
|
console.log(`Node.js: ${process.version}`);
|
|
678
|
-
console.log(`CPU: ${
|
|
679
|
-
console.log(`Memory: ${Math.round(
|
|
862
|
+
console.log(`CPU: ${os4.cpus()[0]?.model ?? "unknown"} (${os4.cpus().length} cores)`);
|
|
863
|
+
console.log(`Memory: ${Math.round(os4.totalmem() / 1024 / 1024)} MB`);
|
|
680
864
|
},
|
|
681
865
|
help: () => "Usage: camstack info"
|
|
682
866
|
}
|
|
@@ -698,6 +882,7 @@ function parseSubcommandArgs(args, options, allowPositionals) {
|
|
|
698
882
|
async function runLogin(args) {
|
|
699
883
|
const parsed = parseSubcommandArgs(args, {
|
|
700
884
|
server: { type: "string", short: "s" },
|
|
885
|
+
namespace: { type: "string", short: "n" },
|
|
701
886
|
username: { type: "string", short: "u" },
|
|
702
887
|
password: { type: "string", short: "p" },
|
|
703
888
|
"token-name": { type: "string" }
|
|
@@ -706,9 +891,11 @@ async function runLogin(args) {
|
|
|
706
891
|
console.log(commandHelp("login"));
|
|
707
892
|
return;
|
|
708
893
|
}
|
|
709
|
-
const
|
|
894
|
+
const explicitServer = stringOpt(parsed.values, "server") ?? process.env.CAMSTACK_SERVER;
|
|
895
|
+
const namespace = stringOpt(parsed.values, "namespace") ?? process.env.CAMSTACK_NAMESPACE;
|
|
710
896
|
await loginCommand({
|
|
711
|
-
server,
|
|
897
|
+
...explicitServer ? { server: explicitServer } : {},
|
|
898
|
+
...namespace ? { namespace } : {},
|
|
712
899
|
...optionalString(parsed.values, "username"),
|
|
713
900
|
...optionalString(parsed.values, "password"),
|
|
714
901
|
...optionalString(parsed.values, "token-name", "tokenName")
|
|
@@ -785,6 +972,7 @@ function optionalString(values, key, outKey) {
|
|
|
785
972
|
return { [outKey ?? key]: v };
|
|
786
973
|
}
|
|
787
974
|
async function main() {
|
|
975
|
+
maybeNotifyUpdate("camstack", pkgVersion);
|
|
788
976
|
const argv = process.argv.slice(2);
|
|
789
977
|
const commands = buildCommands();
|
|
790
978
|
if (argv.length === 0 || argv.length === 1 && HELP_FLAGS.has(argv[0] ?? "")) {
|
|
@@ -802,8 +990,13 @@ async function main() {
|
|
|
802
990
|
console.error("Run `camstack --help` for the list of commands.");
|
|
803
991
|
process.exit(1);
|
|
804
992
|
}
|
|
993
|
+
const subArgs = argv.slice(1);
|
|
994
|
+
if (subArgs.some((a) => HELP_FLAGS.has(a))) {
|
|
995
|
+
console.log(cmd.help());
|
|
996
|
+
return;
|
|
997
|
+
}
|
|
805
998
|
try {
|
|
806
|
-
await cmd.run(
|
|
999
|
+
await cmd.run(subArgs);
|
|
807
1000
|
} catch (err) {
|
|
808
1001
|
console.error(`[camstack] ${cmd.name} failed:`, errMsg(err));
|
|
809
1002
|
process.exit(1);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "camstack",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.1",
|
|
4
4
|
"description": "CLI tool for managing and running CamStack server",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"camstack",
|
|
@@ -40,5 +40,8 @@
|
|
|
40
40
|
"tsup": "^8.5.1",
|
|
41
41
|
"typescript": "^5.7.0",
|
|
42
42
|
"vitest": "^3.0.0"
|
|
43
|
+
},
|
|
44
|
+
"dependencies": {
|
|
45
|
+
"@clack/prompts": "^1.3.0"
|
|
43
46
|
}
|
|
44
47
|
}
|