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.
@@ -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,90 @@ 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 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
- console.warn(`[camstack] Failed to revoke prior token (${prior.tokenId}): ${err instanceof Error ? err.message : String(err)}`);
538
+ clack.log.warn(`Failed to revoke prior token (${prior.tokenId}): ${err instanceof Error ? err.message : String(err)}`);
480
539
  }
481
540
  }
482
- console.log(`[camstack] Creating upload-only scoped token\u2026`);
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: user.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
- 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.`);
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
- console.warn(`[camstack] Server-side revoke failed (${err instanceof Error ? err.message : String(err)}). Removing local cache anyway.`);
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
- console.log(`[camstack] \u2713 Logged out of ${session.server}`);
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> CamStack server URL (default: $CAMSTACK_SERVER or https://localhost:4443)",
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@${os3.hostname()})`
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: ${os3.platform()}-${os3.arch()}`);
860
+ console.log(`Platform: ${os4.platform()}-${os4.arch()}`);
677
861
  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`);
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 server = stringOpt(parsed.values, "server") ?? process.env.CAMSTACK_SERVER ?? "https://localhost:4443";
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(argv.slice(1));
999
+ await cmd.run(subArgs);
807
1000
  } catch (err) {
808
1001
  console.error(`[camstack] ${cmd.name} failed:`, errMsg(err));
809
1002
  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.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
  }