camstack 0.3.1 → 0.5.0
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 +259 -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,80 @@ 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 password2 = opts.password ?? await askPassword("Password");
|
|
500
|
+
const authSpinner = clack.spinner();
|
|
501
|
+
authSpinner.start(`Authenticating as ${username}`);
|
|
502
|
+
let jwt;
|
|
503
|
+
let displayName;
|
|
504
|
+
try {
|
|
505
|
+
const login = await callTrpcMutation(
|
|
506
|
+
`${server}/trpc/auth.login?batch=1`,
|
|
507
|
+
void 0,
|
|
508
|
+
{ username, password: password2 },
|
|
509
|
+
isLoginPayload
|
|
510
|
+
);
|
|
511
|
+
jwt = login.token;
|
|
512
|
+
displayName = login.user.username;
|
|
513
|
+
authSpinner.stop(`Authenticated as ${displayName}`);
|
|
514
|
+
} catch (err) {
|
|
515
|
+
authSpinner.stop(`Auth failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
516
|
+
throw err;
|
|
517
|
+
}
|
|
469
518
|
const prior = loadSession(server);
|
|
470
519
|
if (prior && prior.tokenId) {
|
|
471
520
|
try {
|
|
@@ -476,10 +525,11 @@ async function loginCommand(opts) {
|
|
|
476
525
|
isUnknown
|
|
477
526
|
);
|
|
478
527
|
} catch (err) {
|
|
479
|
-
|
|
528
|
+
clack.log.warn(`Failed to revoke prior token (${prior.tokenId}): ${err instanceof Error ? err.message : String(err)}`);
|
|
480
529
|
}
|
|
481
530
|
}
|
|
482
|
-
|
|
531
|
+
const mintSpinner = clack.spinner();
|
|
532
|
+
mintSpinner.start("Creating upload-only scoped token");
|
|
483
533
|
const tokenName = opts.tokenName ?? `camstack-cli@${os2.hostname()}`;
|
|
484
534
|
const scopes = [{ type: "route-prefix", target: "/api/addons/upload" }];
|
|
485
535
|
const created = await callTrpcMutation(
|
|
@@ -490,19 +540,19 @@ async function loginCommand(opts) {
|
|
|
490
540
|
);
|
|
491
541
|
const scopedToken = created.token;
|
|
492
542
|
const tokenId = created.record.id;
|
|
543
|
+
mintSpinner.stop(`Token created (${scopedToken.slice(0, 12)}\u2026)`);
|
|
493
544
|
const session = {
|
|
494
545
|
server,
|
|
495
|
-
username:
|
|
546
|
+
username: displayName,
|
|
496
547
|
tokenId,
|
|
497
548
|
token: scopedToken,
|
|
498
549
|
scopes,
|
|
499
550
|
createdAt: Date.now()
|
|
500
551
|
};
|
|
501
552
|
const file = saveSession(session);
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
console.log(`[camstack] Scope: upload addons only \u2014 no other API surface granted.`);
|
|
553
|
+
clack.outro(`\u2713 Logged in as ${displayName} \u2192 ${server}
|
|
554
|
+
Cached at ${file}
|
|
555
|
+
Scope: upload addons only`);
|
|
506
556
|
}
|
|
507
557
|
async function logoutCommand(opts) {
|
|
508
558
|
const session = loadSession(opts.server);
|
|
@@ -510,6 +560,9 @@ async function logoutCommand(opts) {
|
|
|
510
560
|
console.log(`[camstack] No active session for ${opts.server}.`);
|
|
511
561
|
return;
|
|
512
562
|
}
|
|
563
|
+
clack.intro("camstack logout");
|
|
564
|
+
const spinner2 = clack.spinner();
|
|
565
|
+
spinner2.start(`Revoking token on ${session.server}`);
|
|
513
566
|
try {
|
|
514
567
|
await callTrpcMutation(
|
|
515
568
|
`${session.server}/trpc/userManagement.revokeScopedToken?batch=1`,
|
|
@@ -517,11 +570,12 @@ async function logoutCommand(opts) {
|
|
|
517
570
|
{ id: session.tokenId },
|
|
518
571
|
isUnknown
|
|
519
572
|
);
|
|
573
|
+
spinner2.stop("Token revoked server-side.");
|
|
520
574
|
} catch (err) {
|
|
521
|
-
|
|
575
|
+
spinner2.stop(`Server-side revoke failed (${err instanceof Error ? err.message : String(err)}). Removing local cache anyway.`);
|
|
522
576
|
}
|
|
523
577
|
clearSession(session.server);
|
|
524
|
-
|
|
578
|
+
clack.outro(`\u2713 Logged out of ${session.server}`);
|
|
525
579
|
}
|
|
526
580
|
function whoamiCommand(opts) {
|
|
527
581
|
const session = loadSession(opts.server);
|
|
@@ -537,6 +591,105 @@ function whoamiCommand(opts) {
|
|
|
537
591
|
console.log(` createdAt: ${new Date(session.createdAt).toISOString()}`);
|
|
538
592
|
}
|
|
539
593
|
|
|
594
|
+
// src/update-notifier.ts
|
|
595
|
+
import * as fs4 from "fs";
|
|
596
|
+
import * as path4 from "path";
|
|
597
|
+
import * as os3 from "os";
|
|
598
|
+
var CACHE_DIR = path4.join(os3.homedir(), ".camstack");
|
|
599
|
+
var CACHE_FILE = path4.join(CACHE_DIR, "update-check.json");
|
|
600
|
+
var CHECK_INTERVAL_MS = 24 * 60 * 60 * 1e3;
|
|
601
|
+
var REGISTRY_BASE = "https://registry.npmjs.org";
|
|
602
|
+
var FETCH_TIMEOUT_MS = 2500;
|
|
603
|
+
function readCache() {
|
|
604
|
+
try {
|
|
605
|
+
if (!fs4.existsSync(CACHE_FILE)) return null;
|
|
606
|
+
const parsed = JSON.parse(fs4.readFileSync(CACHE_FILE, "utf8"));
|
|
607
|
+
if (!parsed || typeof parsed !== "object") return null;
|
|
608
|
+
const c = parsed;
|
|
609
|
+
if (typeof c.lastCheckAt !== "number") return null;
|
|
610
|
+
return {
|
|
611
|
+
lastCheckAt: c.lastCheckAt,
|
|
612
|
+
latestVersion: typeof c.latestVersion === "string" ? c.latestVersion : null,
|
|
613
|
+
lastNotifiedVersion: typeof c.lastNotifiedVersion === "string" ? c.lastNotifiedVersion : null
|
|
614
|
+
};
|
|
615
|
+
} catch {
|
|
616
|
+
return null;
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
function writeCache(c) {
|
|
620
|
+
try {
|
|
621
|
+
if (!fs4.existsSync(CACHE_DIR)) fs4.mkdirSync(CACHE_DIR, { recursive: true, mode: 448 });
|
|
622
|
+
fs4.writeFileSync(CACHE_FILE, JSON.stringify(c, null, 2));
|
|
623
|
+
} catch {
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
function isNewer(current, latest) {
|
|
627
|
+
const a = parseSemver(current);
|
|
628
|
+
const b = parseSemver(latest);
|
|
629
|
+
if (!a || !b) return false;
|
|
630
|
+
for (let i = 0; i < 3; i++) {
|
|
631
|
+
if ((b[i] ?? 0) > (a[i] ?? 0)) return true;
|
|
632
|
+
if ((b[i] ?? 0) < (a[i] ?? 0)) return false;
|
|
633
|
+
}
|
|
634
|
+
return false;
|
|
635
|
+
}
|
|
636
|
+
function parseSemver(v) {
|
|
637
|
+
const m = v.replace(/^v/, "").split(/[-+]/)[0]?.split(".");
|
|
638
|
+
if (!m || m.length < 3) return null;
|
|
639
|
+
const parts = m.map((s) => parseInt(s, 10));
|
|
640
|
+
if (parts.some(Number.isNaN)) return null;
|
|
641
|
+
return [parts[0], parts[1], parts[2]];
|
|
642
|
+
}
|
|
643
|
+
async function fetchLatestVersion(pkg) {
|
|
644
|
+
try {
|
|
645
|
+
const ac = new AbortController();
|
|
646
|
+
const timer = setTimeout(() => ac.abort(), FETCH_TIMEOUT_MS);
|
|
647
|
+
const res = await fetch(`${REGISTRY_BASE}/${pkg}/latest`, {
|
|
648
|
+
signal: ac.signal,
|
|
649
|
+
headers: { accept: "application/json" }
|
|
650
|
+
});
|
|
651
|
+
clearTimeout(timer);
|
|
652
|
+
if (!res.ok) return null;
|
|
653
|
+
const body = await res.json();
|
|
654
|
+
return typeof body.version === "string" ? body.version : null;
|
|
655
|
+
} catch {
|
|
656
|
+
return null;
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
function maybeNotifyUpdate(pkgName, currentVersion) {
|
|
660
|
+
if (process.env.CAMSTACK_NO_UPDATE_CHECK || process.env.NO_UPDATE_NOTIFIER) return;
|
|
661
|
+
if (!process.stdout.isTTY) return;
|
|
662
|
+
const cache = readCache();
|
|
663
|
+
const now = Date.now();
|
|
664
|
+
if (cache?.latestVersion && cache.latestVersion !== cache.lastNotifiedVersion && isNewer(currentVersion, cache.latestVersion)) {
|
|
665
|
+
printBanner(pkgName, currentVersion, cache.latestVersion);
|
|
666
|
+
writeCache({ ...cache, lastNotifiedVersion: cache.latestVersion });
|
|
667
|
+
}
|
|
668
|
+
const isStale = !cache || now - cache.lastCheckAt > CHECK_INTERVAL_MS;
|
|
669
|
+
if (isStale) {
|
|
670
|
+
fetchLatestVersion(pkgName).then((latest) => {
|
|
671
|
+
if (!latest) return;
|
|
672
|
+
writeCache({
|
|
673
|
+
lastCheckAt: Date.now(),
|
|
674
|
+
latestVersion: latest,
|
|
675
|
+
lastNotifiedVersion: cache?.lastNotifiedVersion ?? null
|
|
676
|
+
});
|
|
677
|
+
}).catch(() => {
|
|
678
|
+
});
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
function printBanner(pkgName, current, latest) {
|
|
682
|
+
const lines = [
|
|
683
|
+
"",
|
|
684
|
+
` \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`,
|
|
685
|
+
` \u2502 Update available: ${current} \u2192 ${latest}`.padEnd(60) + "\u2502",
|
|
686
|
+
` \u2502 Run: npx ${pkgName}@latest`.padEnd(60) + "\u2502",
|
|
687
|
+
` \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`,
|
|
688
|
+
""
|
|
689
|
+
];
|
|
690
|
+
process.stderr.write(lines.join("\n"));
|
|
691
|
+
}
|
|
692
|
+
|
|
540
693
|
// src/cli.ts
|
|
541
694
|
var __dirname = dirname(fileURLToPath(import.meta.url));
|
|
542
695
|
var require2 = createRequire(import.meta.url);
|
|
@@ -619,11 +772,32 @@ function buildCommands() {
|
|
|
619
772
|
help: () => [
|
|
620
773
|
"Usage: camstack login [options]",
|
|
621
774
|
"",
|
|
775
|
+
"Default flow (no flags): scans the LAN for camstack nodes via UDP multicast,",
|
|
776
|
+
" prompts you to pick one (arrow-key list), then user + password.",
|
|
777
|
+
"",
|
|
622
778
|
"Options:",
|
|
623
|
-
" -s, --server <url>
|
|
779
|
+
" -s, --server <url> Skip discovery, use this URL directly ($CAMSTACK_SERVER)",
|
|
780
|
+
" -n, --namespace <ns> Skip the interactive picker \u2014 auto-resolves the hub for this namespace ($CAMSTACK_NAMESPACE)",
|
|
624
781
|
" -u, --username <name> Username (skips prompt)",
|
|
625
782
|
" -p, --password <pwd> Password (skips prompt \u2014 prefer letting the CLI prompt)",
|
|
626
|
-
` --token-name <name> Name shown server-side (default: camstack-cli@${
|
|
783
|
+
` --token-name <name> Name shown server-side (default: camstack-cli@${os4.hostname()})`
|
|
784
|
+
].join("\n")
|
|
785
|
+
},
|
|
786
|
+
{
|
|
787
|
+
name: "discover",
|
|
788
|
+
summary: "Probe the LAN for camstack hubs/agents via UDP multicast",
|
|
789
|
+
run: runDiscover,
|
|
790
|
+
help: () => [
|
|
791
|
+
"Usage: camstack discover [options]",
|
|
792
|
+
"",
|
|
793
|
+
"With no flags: lists every camstack node visible on the LAN (any namespace).",
|
|
794
|
+
"",
|
|
795
|
+
"Options:",
|
|
796
|
+
" -n, --namespace <ns> Filter results to one namespace ($CAMSTACK_NAMESPACE)",
|
|
797
|
+
" -t, --timeout <seconds> Listen window (default: 6)",
|
|
798
|
+
" --udp-port <port> UDP discovery port (default: 4445)",
|
|
799
|
+
" --multicast-group <ip> Multicast group (default: 239.0.0.0)",
|
|
800
|
+
" --json Emit JSON instead of human-readable list"
|
|
627
801
|
].join("\n")
|
|
628
802
|
},
|
|
629
803
|
{
|
|
@@ -673,10 +847,10 @@ function buildCommands() {
|
|
|
673
847
|
summary: "Print detailed version and platform info",
|
|
674
848
|
run: () => {
|
|
675
849
|
console.log(`camstack v${pkgVersion}`);
|
|
676
|
-
console.log(`Platform: ${
|
|
850
|
+
console.log(`Platform: ${os4.platform()}-${os4.arch()}`);
|
|
677
851
|
console.log(`Node.js: ${process.version}`);
|
|
678
|
-
console.log(`CPU: ${
|
|
679
|
-
console.log(`Memory: ${Math.round(
|
|
852
|
+
console.log(`CPU: ${os4.cpus()[0]?.model ?? "unknown"} (${os4.cpus().length} cores)`);
|
|
853
|
+
console.log(`Memory: ${Math.round(os4.totalmem() / 1024 / 1024)} MB`);
|
|
680
854
|
},
|
|
681
855
|
help: () => "Usage: camstack info"
|
|
682
856
|
}
|
|
@@ -698,6 +872,7 @@ function parseSubcommandArgs(args, options, allowPositionals) {
|
|
|
698
872
|
async function runLogin(args) {
|
|
699
873
|
const parsed = parseSubcommandArgs(args, {
|
|
700
874
|
server: { type: "string", short: "s" },
|
|
875
|
+
namespace: { type: "string", short: "n" },
|
|
701
876
|
username: { type: "string", short: "u" },
|
|
702
877
|
password: { type: "string", short: "p" },
|
|
703
878
|
"token-name": { type: "string" }
|
|
@@ -706,9 +881,11 @@ async function runLogin(args) {
|
|
|
706
881
|
console.log(commandHelp("login"));
|
|
707
882
|
return;
|
|
708
883
|
}
|
|
709
|
-
const
|
|
884
|
+
const explicitServer = stringOpt(parsed.values, "server") ?? process.env.CAMSTACK_SERVER;
|
|
885
|
+
const namespace = stringOpt(parsed.values, "namespace") ?? process.env.CAMSTACK_NAMESPACE;
|
|
710
886
|
await loginCommand({
|
|
711
|
-
server,
|
|
887
|
+
...explicitServer ? { server: explicitServer } : {},
|
|
888
|
+
...namespace ? { namespace } : {},
|
|
712
889
|
...optionalString(parsed.values, "username"),
|
|
713
890
|
...optionalString(parsed.values, "password"),
|
|
714
891
|
...optionalString(parsed.values, "token-name", "tokenName")
|
|
@@ -785,6 +962,7 @@ function optionalString(values, key, outKey) {
|
|
|
785
962
|
return { [outKey ?? key]: v };
|
|
786
963
|
}
|
|
787
964
|
async function main() {
|
|
965
|
+
maybeNotifyUpdate("camstack", pkgVersion);
|
|
788
966
|
const argv = process.argv.slice(2);
|
|
789
967
|
const commands = buildCommands();
|
|
790
968
|
if (argv.length === 0 || argv.length === 1 && HELP_FLAGS.has(argv[0] ?? "")) {
|
|
@@ -802,8 +980,13 @@ async function main() {
|
|
|
802
980
|
console.error("Run `camstack --help` for the list of commands.");
|
|
803
981
|
process.exit(1);
|
|
804
982
|
}
|
|
983
|
+
const subArgs = argv.slice(1);
|
|
984
|
+
if (subArgs.some((a) => HELP_FLAGS.has(a))) {
|
|
985
|
+
console.log(cmd.help());
|
|
986
|
+
return;
|
|
987
|
+
}
|
|
805
988
|
try {
|
|
806
|
-
await cmd.run(
|
|
989
|
+
await cmd.run(subArgs);
|
|
807
990
|
} catch (err) {
|
|
808
991
|
console.error(`[camstack] ${cmd.name} failed:`, errMsg(err));
|
|
809
992
|
process.exit(1);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "camstack",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
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
|
}
|