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.
@@ -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 os3 from "os";
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 readline from "readline";
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 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);
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 text = await res.text();
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(text);
411
+ const body = parseJson(text2);
424
412
  if (!res.ok) {
425
- const errMsg2 = body !== null && typeof body === "object" && "error" in body ? String(body.error) : text.slice(0, 200);
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 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
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 jwt = login.token;
468
- const user = login.user;
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
- console.warn(`[camstack] Failed to revoke prior token (${prior.tokenId}): ${err instanceof Error ? err.message : String(err)}`);
528
+ clack.log.warn(`Failed to revoke prior token (${prior.tokenId}): ${err instanceof Error ? err.message : String(err)}`);
480
529
  }
481
530
  }
482
- console.log(`[camstack] Creating upload-only scoped token\u2026`);
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: user.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
- 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.`);
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
- console.warn(`[camstack] Server-side revoke failed (${err instanceof Error ? err.message : String(err)}). Removing local cache anyway.`);
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
- console.log(`[camstack] \u2713 Logged out of ${session.server}`);
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> CamStack server URL (default: $CAMSTACK_SERVER or https://localhost:4443)",
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@${os3.hostname()})`
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: ${os3.platform()}-${os3.arch()}`);
850
+ console.log(`Platform: ${os4.platform()}-${os4.arch()}`);
677
851
  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`);
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 server = stringOpt(parsed.values, "server") ?? process.env.CAMSTACK_SERVER ?? "https://localhost:4443";
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(argv.slice(1));
989
+ await cmd.run(subArgs);
807
990
  } catch (err) {
808
991
  console.error(`[camstack] ${cmd.name} failed:`, errMsg(err));
809
992
  process.exit(1);
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ discoverNodes,
4
+ resolveHubFromDiscovered,
5
+ runDiscover
6
+ } from "./chunk-Y4LXGZZ5.js";
7
+ export {
8
+ discoverNodes,
9
+ resolveHubFromDiscovered,
10
+ runDiscover
11
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "camstack",
3
- "version": "0.3.1",
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
  }