@orb44/cli 0.1.1 → 0.1.5

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/README.md CHANGED
@@ -13,10 +13,13 @@ npx @orb44/cli login --url https://orb44.com
13
13
  Open the printed link in a browser where you are already signed in, confirm the domain, then optionally install the systemd daemon (default no). After a global install the command is `orb44`.
14
14
 
15
15
  ```bash
16
- npx @orb44/cli status
17
- npx @orb44/cli pulse
18
- npx @orb44/cli install # later: background pulse, starts after reboot
19
- npx @orb44/cli logout
16
+ npx @orb44/cli@0.1.5 status
17
+ npx @orb44/cli@0.1.5 pulse
18
+ npx @orb44/cli@0.1.5 install # later: background pulse, starts after reboot
19
+ orb44 update # after install: replace /usr/lib/orb44-sat from npm
20
+ npx @orb44/cli@0.1.5 logout
20
21
  ```
21
22
 
23
+ Do not run bare `npx @orb44/cli` — it can reuse an old cache. Pin the version or use `orb44` after install.
24
+
22
25
  Device key: `~/.config/orb44/device.json` (mode 0600). Not a shell token.
package/bin/orb44.mjs CHANGED
@@ -8,6 +8,8 @@ import { stdin as input, stdout as output } from "node:process";
8
8
  import { collectPulse, formatPulsePreview } from "../server/pulse.mjs";
9
9
  import { LANGS, LANG_LABEL, detectLang, normalizeLang, t } from "../server/sat-i18n.mjs";
10
10
  import { pickFromList } from "../server/cli-menu.mjs";
11
+ import { SYSTEM_DEVICE, parseDeviceJson, hasExistingInstall, pickLiveDevice, deviceSearchPaths, loadFirstDevice, stripDevicePath } from "../server/sat-local.mjs";
12
+ import { CLI_PACKAGE, cmpVer, readCliVersion, pickSatRoots, applyUpdateTree, fetchLatestMeta, unpackTarball } from "../server/sat-update.mjs";
11
13
 
12
14
  const DEVICE_FILE = process.env.ORB44_DEVICE_FILE || path.join(os.homedir(), ".config", "orb44", "device.json");
13
15
  const CLI_FILE = process.env.ORB44_CLI_FILE || path.join(path.dirname(DEVICE_FILE), "cli.json");
@@ -21,6 +23,8 @@ function args() {
21
23
  if (a === "--yes" || a === "-y") out.yes = true;
22
24
  else if (a === "--system") out.system = true;
23
25
  else if (a === "--daemon") out.daemon = true;
26
+ else if (a === "--force") out.force = true;
27
+ else if (a === "--purge") out.purge = true;
24
28
  else if (a === "--url" || a === "--code" || a === "--name" || a === "--interval" || a === "--lang") {
25
29
  out[a.slice(2)] = argv[++i];
26
30
  } else if (!a.startsWith("-")) out._.push(a);
@@ -57,12 +61,12 @@ function storedOrFlag(opts) {
57
61
  return normalizeLang(opts.lang) || storedLang() || detectLang();
58
62
  }
59
63
 
64
+ function loadDeviceRecord() {
65
+ return loadFirstDevice((p) => fs.readFileSync(p, "utf8"), deviceSearchPaths(DEVICE_FILE));
66
+ }
67
+
60
68
  function loadDevice() {
61
- try {
62
- return JSON.parse(fs.readFileSync(DEVICE_FILE, "utf8"));
63
- } catch {
64
- return null;
65
- }
69
+ return stripDevicePath(loadDeviceRecord());
66
70
  }
67
71
 
68
72
  function saveDevice(row) {
@@ -180,14 +184,90 @@ async function sendPulse(device, { preview = true } = {}) {
180
184
  return { out, pulse };
181
185
  }
182
186
 
187
+ async function wantReplace(found, opts) {
188
+ if (opts.force) return true;
189
+ if (opts.yes || !input.isTTY || !output.isTTY) return false;
190
+ const lines = [t(lang, "already_title")];
191
+ for (const d of found.devices) {
192
+ lines.push(t(lang, "already_host", { host: d.host || "—", name: d.name || "—" }));
193
+ lines.push(t(lang, "already_key", { file: d.path }));
194
+ }
195
+ if (found.daemon) lines.push(t(lang, "already_daemon"));
196
+ else if (found.unit) lines.push(t(lang, "already_unit"));
197
+ const idx = await pickFromList({
198
+ title: lines.join("\n"),
199
+ items: [t(lang, "already_keep"), t(lang, "already_replace")],
200
+ index: 0,
201
+ hint: "↑↓ Enter",
202
+ stdin: input,
203
+ stdout: output,
204
+ });
205
+ return idx === 1;
206
+ }
207
+
208
+ function existingOnBox() {
209
+ const paths = [...new Set([DEVICE_FILE, SYSTEM_DEVICE])];
210
+ const devices = [];
211
+ for (const p of paths) {
212
+ try {
213
+ const row = parseDeviceJson(fs.readFileSync(p, "utf8"), p);
214
+ if (row) devices.push(row);
215
+ } catch {
216
+ /* missing or unreadable */
217
+ }
218
+ }
219
+ const unit =
220
+ fs.existsSync("/etc/systemd/system/orb44-satellite.service") ||
221
+ fs.existsSync(path.join(os.homedir(), ".config", "systemd", "user", "orb44-satellite.service"));
222
+ let daemon = false;
223
+ try {
224
+ daemon = serviceIsActive(true) || serviceIsActive(false);
225
+ } catch {
226
+ daemon = false;
227
+ }
228
+ return { devices, daemon, unit };
229
+ }
230
+
231
+ async function revokeLocalDevices(devices, fallbackApi) {
232
+ for (const d of devices) {
233
+ const apiBase = d.api || fallbackApi;
234
+ if (!apiBase || !d.secret) continue;
235
+ await api(apiBase, "/api/satellites/logout", { method: "POST", json: { secret: d.secret } }).catch(() => {});
236
+ }
237
+ clearDevice();
238
+ if (SYSTEM_DEVICE !== DEVICE_FILE) {
239
+ try {
240
+ fs.unlinkSync(SYSTEM_DEVICE);
241
+ } catch {
242
+ /* missing */
243
+ }
244
+ }
245
+ }
246
+
183
247
  async function cmdLogin(opts) {
184
248
  await ensureLang(opts);
185
249
  banner();
186
250
  const url = String(opts.url || process.env.ORB44_API || "http://127.0.0.1:8787").replace(/\/$/, "");
187
- const prev = loadDevice();
188
- if (prev?.secret) {
189
- await api(prev.api, "/api/satellites/logout", { method: "POST", json: { secret: prev.secret } }).catch(() => {});
190
- clearDevice();
251
+ const found = existingOnBox();
252
+ if (hasExistingInstall(found)) {
253
+ const replace = await wantReplace(found, opts);
254
+ if (!replace) {
255
+ const live = pickLiveDevice(found.devices, { daemon: found.daemon });
256
+ if (!live?.secret) {
257
+ console.log(t(lang, "already_no_key"));
258
+ process.exit(0);
259
+ }
260
+ const { out } = await sendPulse(live);
261
+ if (!out.ok) {
262
+ console.error(`⚠️ ${out.body.error || t(lang, "pulse_fail")}`);
263
+ process.exit(1);
264
+ }
265
+ console.log("\n📡 " + t(lang, "pulse_ok"));
266
+ printAdvice(out.body?.notes);
267
+ console.log(dim("\n" + t(lang, "already_kept")));
268
+ process.exit(0);
269
+ }
270
+ await revokeLocalDevices(found.devices, url);
191
271
  }
192
272
  const hostname = os.hostname();
193
273
  const name = opts.name || hostname;
@@ -336,24 +416,26 @@ function systemdUnit({ node, script, deviceFile, interval, user }) {
336
416
  return `${lines.join("\n")}\n`;
337
417
  }
338
418
 
339
- function satServerDir() {
340
- return path.join(path.dirname(SCRIPT), "..", "server");
419
+ const CLI_LINK = "/usr/local/bin/orb44";
420
+
421
+ function linkSystemCli(node, script) {
422
+ const body = `#!/bin/sh\nexec ${quote(node)} ${quote(script)} "$@"\n`;
423
+ fs.writeFileSync(CLI_LINK, body, { mode: 0o755 });
341
424
  }
342
425
 
343
426
  function installSystemTree() {
344
427
  const lib = "/usr/lib/orb44-sat";
345
- const serverSrc = satServerDir();
428
+ const srcRoot = path.join(path.dirname(SCRIPT), "..");
346
429
  fs.mkdirSync(path.join(lib, "bin"), { recursive: true, mode: 0o755 });
347
430
  fs.mkdirSync(path.join(lib, "server"), { recursive: true, mode: 0o755 });
348
- const copies = [
349
- [SCRIPT, path.join(lib, "bin", "orb44.mjs")],
350
- [path.join(serverSrc, "pulse.mjs"), path.join(lib, "server", "pulse.mjs")],
351
- [path.join(serverSrc, "sat-i18n.mjs"), path.join(lib, "server", "sat-i18n.mjs")],
352
- [path.join(serverSrc, "cli-menu.mjs"), path.join(lib, "server", "cli-menu.mjs")],
353
- ];
354
- for (const [from, to] of copies) {
355
- fs.copyFileSync(from, to);
356
- fs.chmodSync(to, 0o644);
431
+ applyUpdateTree(srcRoot, lib);
432
+ const ver = readCliVersion(SCRIPT);
433
+ if (ver && ver !== "0.0.0") {
434
+ fs.writeFileSync(
435
+ path.join(lib, "package.json"),
436
+ JSON.stringify({ name: CLI_PACKAGE, version: ver, type: "module", private: true }, null, 2) + "\n",
437
+ { mode: 0o644 }
438
+ );
357
439
  }
358
440
  return path.join(lib, "bin", "orb44.mjs");
359
441
  }
@@ -377,7 +459,9 @@ function ensureSystemServiceAccount(srcDeviceFile) {
377
459
  }
378
460
  }
379
461
  fs.mkdirSync(home, { recursive: true, mode: 0o750 });
380
- fs.copyFileSync(srcDeviceFile, dest);
462
+ if (path.resolve(srcDeviceFile) !== path.resolve(dest)) {
463
+ fs.copyFileSync(srcDeviceFile, dest);
464
+ }
381
465
  fs.chmodSync(dest, 0o600);
382
466
  const chown = spawnSync("chown", ["-R", "orb44:orb44", home], { encoding: "utf8" });
383
467
  if (chown.status !== 0) {
@@ -429,18 +513,22 @@ function tryEnable(asSystem) {
429
513
 
430
514
  function cmdInstall(opts, { enable = false } = {}) {
431
515
  applyLang(opts);
432
- const device = loadDevice();
433
- if (!device?.secret) {
516
+ const rec = loadDeviceRecord();
517
+ if (!rec?.secret) {
434
518
  console.error("⚠️ " + t(lang, "need_login"));
435
519
  process.exit(1);
436
520
  }
521
+ const srcPath = rec.path || DEVICE_FILE;
522
+ const device = stripDevicePath(rec);
523
+ if (opts.url) device.api = String(opts.url).replace(/\/$/, "");
524
+ if (srcPath !== DEVICE_FILE) saveDevice(device);
437
525
  const asSystem = Boolean(opts.system) || process.getuid?.() === 0;
438
526
  const interval = intervalSec(opts.interval);
439
527
  let deviceFile = DEVICE_FILE;
440
528
  let user = null;
441
529
  let script = SCRIPT;
442
530
  if (asSystem) {
443
- const acct = ensureSystemServiceAccount(DEVICE_FILE);
531
+ const acct = ensureSystemServiceAccount(srcPath);
444
532
  if (acct.ok) {
445
533
  deviceFile = acct.deviceFile;
446
534
  user = acct.user;
@@ -463,6 +551,14 @@ function cmdInstall(opts, { enable = false } = {}) {
463
551
  fs.mkdirSync(path.dirname(unitPath), { recursive: true });
464
552
  fs.writeFileSync(unitPath, unit, { mode: 0o644 });
465
553
  console.log("⚙️ " + t(lang, "unit_written", { path: unitPath }));
554
+ if (asSystem) {
555
+ try {
556
+ linkSystemCli(process.execPath, script);
557
+ console.log(dim(t(lang, "cli_link", { path: CLI_LINK })));
558
+ } catch {
559
+ /* /usr/local/bin missing */
560
+ }
561
+ }
466
562
  if (enable) {
467
563
  const on = tryEnable(asSystem);
468
564
  if (on.ok) {
@@ -482,6 +578,119 @@ function cmdInstall(opts, { enable = false } = {}) {
482
578
  return false;
483
579
  }
484
580
 
581
+ function tryDisable(asSystem) {
582
+ const args = asSystem
583
+ ? [
584
+ ["disable", "--now", "orb44-satellite"],
585
+ ["daemon-reload"],
586
+ ]
587
+ : [
588
+ ["--user", "disable", "--now", "orb44-satellite"],
589
+ ["--user", "daemon-reload"],
590
+ ];
591
+ for (const a of args) {
592
+ spawnSync("systemctl", a, { encoding: "utf8" });
593
+ }
594
+ }
595
+
596
+ function rmTree(p) {
597
+ try {
598
+ fs.rmSync(p, { recursive: true, force: true });
599
+ } catch {
600
+ /* missing */
601
+ }
602
+ }
603
+
604
+ async function cmdUninstall(opts) {
605
+ applyLang(opts);
606
+ const asSystem = Boolean(opts.system) || process.getuid?.() === 0;
607
+ tryDisable(asSystem);
608
+ tryDisable(!asSystem);
609
+ const unitPaths = [
610
+ "/etc/systemd/system/orb44-satellite.service",
611
+ path.join(os.homedir(), ".config", "systemd", "user", "orb44-satellite.service"),
612
+ ];
613
+ for (const p of unitPaths) {
614
+ try {
615
+ fs.unlinkSync(p);
616
+ } catch {
617
+ /* missing */
618
+ }
619
+ }
620
+ rmTree("/usr/lib/orb44-sat");
621
+ try {
622
+ fs.unlinkSync(CLI_LINK);
623
+ } catch {
624
+ /* missing */
625
+ }
626
+ if (opts.purge) {
627
+ const found = existingOnBox();
628
+ await revokeLocalDevices(found.devices, found.devices[0]?.api);
629
+ rmTree("/var/lib/orb44");
630
+ spawnSync("userdel", ["orb44"], { encoding: "utf8" });
631
+ console.log("✅ " + t(lang, "uninstall_purged"));
632
+ return;
633
+ }
634
+ console.log("✅ " + t(lang, "uninstall_ok"));
635
+ console.log(dim(t(lang, "uninstall_key_kept", { file: SYSTEM_DEVICE })));
636
+ }
637
+
638
+ async function cmdUpdate() {
639
+ applyLang(opts);
640
+ const current = readCliVersion(SCRIPT);
641
+ let meta;
642
+ try {
643
+ meta = await fetchLatestMeta();
644
+ } catch (e) {
645
+ console.error("⚠️ " + t(lang, "update_fail", { err: `: ${String(e.message || e).slice(0, 160)}` }));
646
+ process.exit(1);
647
+ }
648
+ const pin = t(lang, "update_npx", { version: meta.version });
649
+ if (cmpVer(current, meta.version) >= 0 && !opts.force) {
650
+ console.log("✅ " + t(lang, "update_same", { version: current }));
651
+ console.log(dim(pin));
652
+ return;
653
+ }
654
+ const roots = pickSatRoots(SCRIPT);
655
+ if (!roots.length) {
656
+ console.error("⚠️ " + t(lang, "update_no_tree"));
657
+ console.log(dim(pin));
658
+ process.exit(1);
659
+ }
660
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "orb44-upd-"));
661
+ let wrote = [];
662
+ try {
663
+ const pkg = await unpackTarball(meta.tarball, tmp);
664
+ const blocked = [];
665
+ for (const root of roots) {
666
+ try {
667
+ applyUpdateTree(pkg, root);
668
+ wrote.push(root);
669
+ } catch (e) {
670
+ blocked.push({ root, err: String(e.message || e).slice(0, 160) });
671
+ }
672
+ }
673
+ if (!wrote.length) {
674
+ console.error("⚠️ " + t(lang, blocked.some((b) => /EACCES|permission/i.test(b.err)) ? "update_need_root" : "update_fail", { err: blocked[0]?.err ? `: ${blocked[0].err}` : "" }));
675
+ process.exit(1);
676
+ }
677
+ } finally {
678
+ try {
679
+ fs.rmSync(tmp, { recursive: true, force: true });
680
+ } catch {
681
+ /* tmp */
682
+ }
683
+ }
684
+ for (const root of wrote) {
685
+ console.log("✅ " + t(lang, "update_wrote", { version: meta.version, path: root }));
686
+ }
687
+ if (wrote.includes("/usr/lib/orb44-sat") && serviceIsActive(true)) {
688
+ spawnSync("systemctl", ["try-restart", "orb44-satellite"], { encoding: "utf8" });
689
+ console.log(dim(t(lang, "update_restarted")));
690
+ }
691
+ console.log(dim(pin));
692
+ }
693
+
485
694
  function cmdStatus() {
486
695
  applyLang(opts);
487
696
  const device = loadDevice();
@@ -548,7 +757,9 @@ const run = {
548
757
  pulse: cmdPulse,
549
758
  daemon: () => cmdDaemon(opts),
550
759
  install: () => cmdInstall(opts, { enable: true }),
760
+ uninstall: () => cmdUninstall(opts),
551
761
  status: cmdStatus,
762
+ update: cmdUpdate,
552
763
  logout: cmdLogout,
553
764
  lang: () => cmdLang(opts),
554
765
  help,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orb44/cli",
3
- "version": "0.1.1",
3
+ "version": "0.1.5",
4
4
  "description": "Orb44: street snapshot of your site. This CLI is the Watch satellite — pulse from the host (load, listeners, hardening). The cabinet does not run commands on the machine.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -10,7 +10,9 @@
10
10
  "bin/orb44.mjs",
11
11
  "server/pulse.mjs",
12
12
  "server/sat-i18n.mjs",
13
- "server/cli-menu.mjs"
13
+ "server/cli-menu.mjs",
14
+ "server/sat-local.mjs",
15
+ "server/sat-update.mjs"
14
16
  ],
15
17
  "engines": {
16
18
  "node": ">=18"
package/server/pulse.mjs CHANGED
@@ -174,11 +174,32 @@ function topTable() {
174
174
 
175
175
  function okListenAddr(a) {
176
176
  const s = String(a || "");
177
- if (s === "0.0.0.0" || s === "::" || s === "::1") return true;
177
+ if (s === "0.0.0.0" || s === "::" || s === "::1" || s === "*") return true;
178
178
  if (/^\d{1,3}(\.\d{1,3}){3}$/.test(s)) return true;
179
179
  return /^[0-9a-fA-F:]+$/.test(s) && s.includes(":");
180
180
  }
181
181
 
182
+ export function collapseListen(rows = []) {
183
+ const groups = new Map();
184
+ for (const r of rows) {
185
+ const key = `${Number(r.port)}|${r.comm || ""}`;
186
+ if (!groups.has(key)) groups.set(key, []);
187
+ groups.get(key).push(r);
188
+ }
189
+ const out = [];
190
+ for (const list of groups.values()) {
191
+ const addrs = new Set(list.map((x) => x.addr));
192
+ const rest = list.filter((x) => x.addr !== "0.0.0.0" && x.addr !== "::" && x.addr !== "*");
193
+ if (addrs.has("*") || (addrs.has("0.0.0.0") && addrs.has("::"))) {
194
+ out.push({ addr: "*", port: list[0].port, comm: list[0].comm });
195
+ out.push(...rest);
196
+ } else {
197
+ out.push(...list);
198
+ }
199
+ }
200
+ return out;
201
+ }
202
+
182
203
  export function sanitizePulse(raw = {}) {
183
204
  const top = Array.isArray(raw.top)
184
205
  ? raw.top
@@ -190,16 +211,18 @@ export function sanitizePulse(raw = {}) {
190
211
  }))
191
212
  .filter((r) => r.comm)
192
213
  : [];
193
- const listen = Array.isArray(raw.listen)
194
- ? raw.listen
195
- .slice(0, 40)
196
- .map((r) => ({
197
- addr: String(r.addr || "").slice(0, 45),
198
- port: Number(r.port) || 0,
199
- comm: sanitizeComm(r.comm),
200
- }))
201
- .filter((r) => r.port > 0 && r.port < 65536 && okListenAddr(r.addr))
202
- : [];
214
+ const listen = collapseListen(
215
+ Array.isArray(raw.listen)
216
+ ? raw.listen
217
+ .slice(0, 40)
218
+ .map((r) => ({
219
+ addr: String(r.addr || "").slice(0, 45),
220
+ port: Number(r.port) || 0,
221
+ comm: sanitizeComm(r.comm),
222
+ }))
223
+ .filter((r) => r.port > 0 && r.port < 65536 && okListenAddr(r.addr))
224
+ : []
225
+ );
203
226
  const originA = Array.isArray(raw.originA)
204
227
  ? raw.originA.map((x) => String(x || "")).filter((x) => /^\d{1,3}(\.\d{1,3}){3}$/.test(x)).slice(0, 8)
205
228
  : [];
@@ -672,33 +695,71 @@ export function collectPulse() {
672
695
  });
673
696
  }
674
697
 
698
+ function clip(s, n) {
699
+ const t = String(s ?? "");
700
+ if (t.length <= n) return t;
701
+ return `${t.slice(0, Math.max(1, n - 1))}…`;
702
+ }
703
+
704
+ function asciiTable(headers, rows) {
705
+ const cells = [headers, ...rows].map((r) => r.map((c) => String(c ?? "")));
706
+ const widths = headers.map((_, i) => Math.max(1, ...cells.map((r) => [...(r[i] || "")].length)));
707
+ const fill = (ch) => widths.map((w) => ch.repeat(w + 2)).join("┼");
708
+ const line = (row) =>
709
+ "│ " +
710
+ row
711
+ .map((c, i) => {
712
+ const s = clip(c, widths[i]);
713
+ return s + " ".repeat(Math.max(0, widths[i] - [...s].length));
714
+ })
715
+ .join(" │ ") +
716
+ " │";
717
+ return [`┌${fill("─").replaceAll("┼", "┬")}┐`, line(headers), `├${fill("─")}┤`, ...rows.map(line), `└${fill("─").replaceAll("┼", "┴")}┘`].join("\n");
718
+ }
719
+
675
720
  export function formatPulsePreview(pulse, lang = "en") {
676
721
  const gb = (n) => (Number(n) / 1024 / 1024 / 1024).toFixed(1);
677
722
  const none = t(lang, "preview_none");
678
- const lines = [
679
- t(lang, "preview_host", { h: pulse.hostname || "—" }),
680
- t(lang, "preview_load", { load: pulse.load1, used: gb(pulse.memUsed), total: gb(pulse.memTotal), disk: pulse.diskUsedPct ?? "—" }),
681
- t(lang, "preview_listen", {
682
- list: (pulse.listen || []).slice(0, 12).map((r) => `${r.addr}:${r.port}${r.comm ? ` ${r.comm}` : ""}`).join(", ") || none,
683
- }),
684
- t(lang, "preview_top", {
685
- list: (pulse.top || []).slice(0, 5).map((r) => `${r.comm} ${r.cpuPct}% ${r.rssMb}M`).join(", ") || none,
686
- }),
687
- ];
688
- const h = pulse.hardening;
689
- if (h) {
690
- const ssh = h.sshPassword ? t(lang, "preview_ssh_pass") : h.sshPassword === false ? t(lang, "preview_ssh_key") : "?";
691
- lines.push(
692
- t(lang, "preview_guard", {
693
- fw: h.firewall || t(lang, "preview_fw_no"),
694
- ban: h.fail2ban || t(lang, "preview_ban_no"),
695
- upd: h.updates ? t(lang, "preview_yes") : t(lang, "preview_no"),
696
- sync: h.timesync || t(lang, "preview_no"),
697
- ssh,
698
- grade: pulse.gradeInside || "—",
699
- })
700
- );
701
- }
702
- if (pulse.limited) lines.push(t(lang, "preview_limited"));
703
- return lines.join("\n");
723
+ const h = pulse.hardening || {};
724
+ const grade = pulse.gradeInside || gradeInside(pulse) || "—";
725
+ const ssh = h.sshPassword ? t(lang, "preview_ssh_pass") : h.sshPassword === false ? t(lang, "preview_ssh_key") : "—";
726
+ const machine = asciiTable(
727
+ [t(lang, "preview_col_check"), t(lang, "preview_col_value")],
728
+ [
729
+ [t(lang, "preview_row_host"), pulse.hostname || "—"],
730
+ [t(lang, "preview_row_load"), Number(pulse.load1).toFixed(2)],
731
+ [t(lang, "preview_row_ram"), `${gb(pulse.memUsed)} / ${gb(pulse.memTotal)}`],
732
+ [t(lang, "preview_row_disk"), `${pulse.diskUsedPct ?? "—"}%`],
733
+ [t(lang, "preview_row_grade"), grade],
734
+ ]
735
+ );
736
+ const listenRows = collapseListen([...(pulse.listen || [])])
737
+ .sort((a, b) => {
738
+ const rank = (addr) =>
739
+ addr === "0.0.0.0" || addr === "::" || addr === "*" ? 0 : String(addr).startsWith("127.") || addr === "::1" ? 2 : 1;
740
+ return rank(a.addr) - rank(b.addr) || Number(a.port) - Number(b.port);
741
+ })
742
+ .slice(0, 16)
743
+ .map((r) => [r.addr || "—", String(r.port ?? ""), r.comm || "—"]);
744
+ const listen = asciiTable(
745
+ [t(lang, "preview_col_bind"), t(lang, "preview_col_port"), t(lang, "preview_col_proc")],
746
+ listenRows.length ? listenRows : [[none, "", ""]]
747
+ );
748
+ const topRows = (pulse.top || []).slice(0, 6).map((r) => [r.comm || "", `${r.cpuPct}%`, `${r.rssMb}M`]);
749
+ const top = asciiTable(
750
+ [t(lang, "preview_col_proc"), t(lang, "preview_col_cpu"), t(lang, "preview_col_rss")],
751
+ topRows.length ? topRows : [[none, "", ""]]
752
+ );
753
+ const guard = asciiTable(
754
+ [t(lang, "preview_col_check"), t(lang, "preview_col_value")],
755
+ [
756
+ [t(lang, "preview_row_fw"), h.firewall || t(lang, "preview_fw_no")],
757
+ [t(lang, "preview_row_ban"), h.fail2ban || t(lang, "preview_ban_no")],
758
+ [t(lang, "preview_row_upd"), h.updates ? t(lang, "preview_yes") : t(lang, "preview_no")],
759
+ [t(lang, "preview_row_sync"), h.timesync || t(lang, "preview_no")],
760
+ [t(lang, "preview_row_ssh"), ssh],
761
+ ]
762
+ );
763
+ const extra = pulse.limited ? `\n${t(lang, "preview_limited")}` : "";
764
+ return `${machine}\n\n${listen}\n\n${top}\n\n${guard}${extra}`;
704
765
  }
@@ -21,7 +21,7 @@ export function detectLang(env = process.env) {
21
21
  const STR = {
22
22
  en: {
23
23
  banner_title: "Orb44 satellite",
24
- banner_sub: "Device key is pulse only, not a shell. Advice comes back on the pulse; you run commands.",
24
+ banner_sub: "Inside analysis of this host: load, listeners, hardening.",
25
25
  lang_pick: "Language · Язык · 언어 · Idioma",
26
26
  lang_saved: "Language saved: {label}",
27
27
  lang_now: "CLI language: {label} (--lang en|ru|ko|es)",
@@ -45,6 +45,7 @@ const STR = {
45
45
  daemon_run: "daemon every {sec}s · {host}",
46
46
  daemon_revoked: "key revoked",
47
47
  unit_written: "unit {path}",
48
+ cli_link: "command {path}",
48
49
  daemon_on: "daemon enabled: background pulse, starts again after reboot.",
49
50
  login_done: "Done. You can close this session — the daemon is already in the background.",
50
51
  daemon_user: "service user {user} · key {file}",
@@ -55,6 +56,25 @@ const STR = {
55
56
  not_paired: "Not paired. orb44 login",
56
57
  now_on_box: "On the machine now",
57
58
  logged_out: "Disconnected. Cabinet dropped the device.",
59
+ uninstall_ok: "Daemon stopped and unit removed. Pairing key kept.",
60
+ uninstall_purged: "Daemon, key, and service user orb44 removed. Cabinet dropped the device.",
61
+ uninstall_key_kept: "Key still at {file}. Later: npx @orb44/cli install",
62
+ already_title: "Already on this machine",
63
+ already_host: "{host} ({name})",
64
+ already_key: "key {file}",
65
+ already_daemon: "Watch daemon is already running",
66
+ already_unit: "systemd unit is already installed",
67
+ already_keep: "Keep — do not pair again",
68
+ already_replace: "Replace — new login, old device is revoked",
69
+ already_kept: "Left as is. Pulse sent. Same device in the cabinet.",
70
+ already_no_key: "Daemon is on, but this user cannot read the key. Try: sudo orb44 status",
71
+ update_same: "Already {version}.",
72
+ update_wrote: "Updated to {version} → {path}",
73
+ update_need_root: "Cannot write the install tree. Run: sudo orb44 update",
74
+ update_no_tree: "No satellite install to overwrite. After install: sudo orb44 update. Until then pin npx.",
75
+ update_fail: "Update failed{err}",
76
+ update_npx: "Do not run bare npx @orb44/cli — pin: npx @orb44/cli@{version}",
77
+ update_restarted: "Restarted orb44-satellite.",
58
78
  advice_title: "What to do on this machine",
59
79
  advice_sub: "The cabinet will not run this — a leaked key must not become a remote shell.",
60
80
  preview_host: "host {h}",
@@ -62,6 +82,23 @@ const STR = {
62
82
  preview_listen: "listening: {list}",
63
83
  preview_top: "top: {list}",
64
84
  preview_none: "not visible",
85
+ preview_col_check: "check",
86
+ preview_col_value: "value",
87
+ preview_col_bind: "bind",
88
+ preview_col_port: "port",
89
+ preview_col_proc: "process",
90
+ preview_col_cpu: "CPU",
91
+ preview_col_rss: "RSS",
92
+ preview_row_host: "host",
93
+ preview_row_load: "load",
94
+ preview_row_ram: "RAM",
95
+ preview_row_disk: "disk",
96
+ preview_row_grade: "grade",
97
+ preview_row_fw: "firewall",
98
+ preview_row_ban: "fail2ban",
99
+ preview_row_upd: "updates",
100
+ preview_row_sync: "time",
101
+ preview_row_ssh: "SSH",
65
102
  preview_guard: "guard: firewall {fw} · {ban} · unattended-upgrades {upd} · time {sync} · SSH {ssh} · grade {grade}",
66
103
  preview_fw_no: "none",
67
104
  preview_ban_no: "no fail2ban",
@@ -72,12 +109,14 @@ const STR = {
72
109
  preview_limited: "limited: cannot see which process owns the ports",
73
110
  help: `Orb44 satellite — admin device.
74
111
 
75
- orb44 login [--url http://127.0.0.1:8787] [--daemon] [--lang en|ru|ko|es]
112
+ orb44 login [--url http://127.0.0.1:8787] [--daemon] [--force] [--lang en|ru|ko|es]
76
113
  orb44 pulse
77
114
  orb44 daemon [--interval 300]
78
115
  orb44 install [--system] [--interval 300]
116
+ orb44 uninstall [--purge]
79
117
  orb44 lang [en|ru|ko|es]
80
118
  orb44 status
119
+ orb44 update
81
120
  orb44 logout
82
121
 
83
122
  Login asks language (saved) then daemon (default no).
@@ -86,7 +125,7 @@ Outgoing pulse. Cabinet does not execute commands; it replies with advice.`,
86
125
  },
87
126
  ru: {
88
127
  banner_title: "Orb44 сателлит",
89
- banner_sub: "Ключ устройства только пульс, не шелл. Советы приходят ответом, команды выполняете вы.",
128
+ banner_sub: "Анализ хоста изнутри: нагрузка, слушатели, hardening.",
90
129
  lang_pick: "Language · Язык · 언어 · Idioma",
91
130
  lang_saved: "Язык сохранён: {label}",
92
131
  lang_now: "Язык консоли: {label} (--lang en|ru|ko|es)",
@@ -110,6 +149,7 @@ Outgoing pulse. Cabinet does not execute commands; it replies with advice.`,
110
149
  daemon_run: "демон каждые {sec}с · {host}",
111
150
  daemon_revoked: "ключ отозван",
112
151
  unit_written: "unit {path}",
152
+ cli_link: "command {path}",
113
153
  daemon_on: "демон включён: пульс в фоне, поднимается после перезагрузки.",
114
154
  login_done: "Готово. Эту сессию можно закрыть — демон уже в фоне.",
115
155
  daemon_user: "пользователь службы {user} · ключ {file}",
@@ -120,6 +160,25 @@ Outgoing pulse. Cabinet does not execute commands; it replies with advice.`,
120
160
  not_paired: "Не связано. orb44 login",
121
161
  now_on_box: "Сейчас на машине",
122
162
  logged_out: "Отключено. В кабинете устройство снято.",
163
+ uninstall_ok: "Демон остановлен, unit снят. Ключ пары оставлен.",
164
+ uninstall_purged: "Демон, ключ и пользователь orb44 сняты. В кабинете устройство отозвано.",
165
+ uninstall_key_kept: "Ключ на месте: {file}. Потом: npx @orb44/cli install",
166
+ already_title: "На этой машине уже стоит",
167
+ already_host: "{host} ({name})",
168
+ already_key: "ключ {file}",
169
+ already_daemon: "Демон Watch уже работает",
170
+ already_unit: "systemd unit уже установлен",
171
+ already_keep: "Оставить — не логиниться заново",
172
+ already_replace: "Заменить — новый login, старое устройство отзовётся",
173
+ already_kept: "Оставили как есть. Пульс ушёл. В кабинете то же устройство.",
174
+ already_no_key: "Демон работает, но этот пользователь не читает ключ. Попробуйте: sudo orb44 status",
175
+ update_same: "Уже {version}.",
176
+ update_wrote: "Обновлено до {version} → {path}",
177
+ update_need_root: "Нельзя записать дерево установки. Запустите: sudo orb44 update",
178
+ update_no_tree: "Нет дерева сателлита для перезаписи. После install: sudo orb44 update. До этого — pin в npx.",
179
+ update_fail: "Обновление не вышло{err}",
180
+ update_npx: "Не вызывайте голый npx @orb44/cli — pin: npx @orb44/cli@{version}",
181
+ update_restarted: "Демон orb44-satellite перезапущен.",
123
182
  advice_title: "Что сделать на этой машине",
124
183
  advice_sub: "Кабинет это не выполнит — иначе утечка ключа была бы удалённым шеллом.",
125
184
  preview_host: "хост {h}",
@@ -127,6 +186,23 @@ Outgoing pulse. Cabinet does not execute commands; it replies with advice.`,
127
186
  preview_listen: "слушает: {list}",
128
187
  preview_top: "топ: {list}",
129
188
  preview_none: "не видно",
189
+ preview_col_check: "проверка",
190
+ preview_col_value: "значение",
191
+ preview_col_bind: "адрес",
192
+ preview_col_port: "порт",
193
+ preview_col_proc: "процесс",
194
+ preview_col_cpu: "CPU",
195
+ preview_col_rss: "RSS",
196
+ preview_row_host: "хост",
197
+ preview_row_load: "load",
198
+ preview_row_ram: "RAM",
199
+ preview_row_disk: "диск",
200
+ preview_row_grade: "грейд",
201
+ preview_row_fw: "файрвол",
202
+ preview_row_ban: "fail2ban",
203
+ preview_row_upd: "обновления",
204
+ preview_row_sync: "время",
205
+ preview_row_ssh: "SSH",
130
206
  preview_guard: "защита: файрвол {fw} · {ban} · автообновления {upd} · время {sync} · SSH {ssh} · грейд {grade}",
131
207
  preview_fw_no: "нет",
132
208
  preview_ban_no: "fail2ban нет",
@@ -137,12 +213,14 @@ Outgoing pulse. Cabinet does not execute commands; it replies with advice.`,
137
213
  preview_limited: "ограничено: не видно, какой процесс слушает порты",
138
214
  help: `Orb44 сателлит — устройство админа.
139
215
 
140
- orb44 login [--url http://127.0.0.1:8787] [--daemon] [--lang en|ru|ko|es]
216
+ orb44 login [--url http://127.0.0.1:8787] [--daemon] [--force] [--lang en|ru|ko|es]
141
217
  orb44 pulse
142
218
  orb44 daemon [--interval 300]
143
219
  orb44 install [--system] [--interval 300]
220
+ orb44 uninstall [--purge]
144
221
  orb44 lang [en|ru|ko|es]
145
222
  orb44 status
223
+ orb44 update
146
224
  orb44 logout
147
225
 
148
226
  После login спрашивает язык (запоминает), затем демон — по умолчанию нет.
@@ -151,7 +229,7 @@ Outgoing pulse. Cabinet does not execute commands; it replies with advice.`,
151
229
  },
152
230
  ko: {
153
231
  banner_title: "Orb44 위성",
154
- banner_sub: "장치 키는 펄스만입니다. 셸이 아닙니다. 조언은 펄스 응답으로 오고, 명령은 직접 실행합니다.",
232
+ banner_sub: " 호스트를 안에서 분석합니다: 부하, 리스너, hardening.",
155
233
  lang_pick: "Language · Язык · 언어 · Idioma",
156
234
  lang_saved: "언어 저장됨: {label}",
157
235
  lang_now: "CLI 언어: {label} (--lang en|ru|ko|es)",
@@ -175,6 +253,7 @@ Outgoing pulse. Cabinet does not execute commands; it replies with advice.`,
175
253
  daemon_run: "데몬 {sec}초마다 · {host}",
176
254
  daemon_revoked: "키 취소됨",
177
255
  unit_written: "unit {path}",
256
+ cli_link: "command {path}",
178
257
  daemon_on: "데몬이 켜졌습니다: 백그라운드 펄스, 재부팅 후에도 올라옵니다.",
179
258
  login_done: "끝났습니다. 이 세션을 닫아도 됩니다 — 데몬은 이미 백그라운드에 있습니다.",
180
259
  daemon_user: "서비스 사용자 {user} · 키 {file}",
@@ -185,6 +264,25 @@ Outgoing pulse. Cabinet does not execute commands; it replies with advice.`,
185
264
  not_paired: "연결되지 않음. orb44 login",
186
265
  now_on_box: "지금 이 기기",
187
266
  logged_out: "해제됨. 콘솔에서 장치가 제거되었습니다.",
267
+ uninstall_ok: "데몬을 멈추고 unit을 제거했습니다. 페어링 키는 남겼습니다.",
268
+ uninstall_purged: "데몬, 키, 사용자 orb44를 제거했습니다. 콘솔에서 장치를 취소했습니다.",
269
+ uninstall_key_kept: "키는 그대로입니다: {file}. 나중에: npx @orb44/cli install",
270
+ already_title: "이 기기에 이미 있습니다",
271
+ already_host: "{host} ({name})",
272
+ already_key: "키 {file}",
273
+ already_daemon: "Watch 데몬이 이미 실행 중입니다",
274
+ already_unit: "systemd unit이 이미 설치되어 있습니다",
275
+ already_keep: "유지 — 다시 페어링하지 않음",
276
+ already_replace: "교체 — 새 login, 이전 장치는 취소됨",
277
+ already_kept: "그대로 두었습니다. 펄스를 보냈습니다. 콘솔의 장치는 같습니다.",
278
+ already_no_key: "데몬은 켜져 있지만 이 사용자는 키를 읽지 못합니다. sudo orb44 status",
279
+ update_same: "이미 {version}.",
280
+ update_wrote: "{version}(으)로 갱신 → {path}",
281
+ update_need_root: "설치 트리를 쓸 수 없습니다. sudo orb44 update",
282
+ update_no_tree: "덮어쓸 위성 설치가 없습니다. install 후 sudo orb44 update. 그 전에는 npx를 pin 하세요.",
283
+ update_fail: "업데이트 실패{err}",
284
+ update_npx: "버전 없는 npx @orb44/cli 는 쓰지 마세요 — pin: npx @orb44/cli@{version}",
285
+ update_restarted: "orb44-satellite 를 재시작했습니다.",
188
286
  advice_title: "이 기기에서 할 일",
189
287
  advice_sub: "콘솔은 실행하지 않습니다. 키 유출이 원격 셸이 되면 안 됩니다.",
190
288
  preview_host: "호스트 {h}",
@@ -192,6 +290,23 @@ Outgoing pulse. Cabinet does not execute commands; it replies with advice.`,
192
290
  preview_listen: "수신: {list}",
193
291
  preview_top: "top: {list}",
194
292
  preview_none: "없음",
293
+ preview_col_check: "항목",
294
+ preview_col_value: "값",
295
+ preview_col_bind: "주소",
296
+ preview_col_port: "포트",
297
+ preview_col_proc: "프로세스",
298
+ preview_col_cpu: "CPU",
299
+ preview_col_rss: "RSS",
300
+ preview_row_host: "호스트",
301
+ preview_row_load: "load",
302
+ preview_row_ram: "RAM",
303
+ preview_row_disk: "디스크",
304
+ preview_row_grade: "등급",
305
+ preview_row_fw: "방화벽",
306
+ preview_row_ban: "fail2ban",
307
+ preview_row_upd: "업데이트",
308
+ preview_row_sync: "시간",
309
+ preview_row_ssh: "SSH",
195
310
  preview_guard: "보호: 방화벽 {fw} · {ban} · 자동업데이트 {upd} · 시간 {sync} · SSH {ssh} · 등급 {grade}",
196
311
  preview_fw_no: "없음",
197
312
  preview_ban_no: "fail2ban 없음",
@@ -202,12 +317,14 @@ Outgoing pulse. Cabinet does not execute commands; it replies with advice.`,
202
317
  preview_limited: "제한: 포트를 연 프로세스를 볼 수 없음",
203
318
  help: `Orb44 위성 — 관리자 장치.
204
319
 
205
- orb44 login [--url http://127.0.0.1:8787] [--daemon] [--lang en|ru|ko|es]
320
+ orb44 login [--url http://127.0.0.1:8787] [--daemon] [--force] [--lang en|ru|ko|es]
206
321
  orb44 pulse
207
322
  orb44 daemon [--interval 300]
208
323
  orb44 install [--system] [--interval 300]
324
+ orb44 uninstall [--purge]
209
325
  orb44 lang [en|ru|ko|es]
210
326
  orb44 status
327
+ orb44 update
211
328
  orb44 logout
212
329
 
213
330
  login에서 언어를 묻고 저장한 뒤, 데몬은 기본값 아니오입니다.
@@ -216,7 +333,7 @@ login에서 언어를 묻고 저장한 뒤, 데몬은 기본값 아니오입니
216
333
  },
217
334
  es: {
218
335
  banner_title: "Satélite Orb44",
219
- banner_sub: "La clave del dispositivo es solo pulso, no un shell. Los consejos vuelven en la respuesta; las órdenes las ejecuta usted.",
336
+ banner_sub: "Análisis del host desde dentro: carga, listeners, hardening.",
220
337
  lang_pick: "Language · Язык · 언어 · Idioma",
221
338
  lang_saved: "Idioma guardado: {label}",
222
339
  lang_now: "Idioma de la consola: {label} (--lang en|ru|ko|es)",
@@ -240,6 +357,7 @@ login에서 언어를 묻고 저장한 뒤, 데몬은 기본값 아니오입니
240
357
  daemon_run: "demonio cada {sec}s · {host}",
241
358
  daemon_revoked: "clave revocada",
242
359
  unit_written: "unit {path}",
360
+ cli_link: "command {path}",
243
361
  daemon_on: "demonio activado: pulso en segundo plano, arranca de nuevo tras el reinicio.",
244
362
  login_done: "Listo. Puede cerrar esta sesión: el demonio ya está en segundo plano.",
245
363
  daemon_user: "usuario del servicio {user} · clave {file}",
@@ -250,6 +368,25 @@ login에서 언어를 묻고 저장한 뒤, 데몬은 기본값 아니오입니
250
368
  not_paired: "Sin vincular. orb44 login",
251
369
  now_on_box: "Ahora en la máquina",
252
370
  logged_out: "Desconectado. El gabinete quitó el dispositivo.",
371
+ uninstall_ok: "Demonio parado y unit quitado. Se guarda la clave de emparejamiento.",
372
+ uninstall_purged: "Demonio, clave y usuario orb44 eliminados. El gabinete revocó el dispositivo.",
373
+ uninstall_key_kept: "La clave sigue en {file}. Luego: npx @orb44/cli install",
374
+ already_title: "Ya está en esta máquina",
375
+ already_host: "{host} ({name})",
376
+ already_key: "clave {file}",
377
+ already_daemon: "El demonio Watch ya está en marcha",
378
+ already_unit: "El unit systemd ya está instalado",
379
+ already_keep: "Dejar — no volver a emparejar",
380
+ already_replace: "Sustituir — login nuevo, se revoca el dispositivo anterior",
381
+ already_kept: "Se dejó igual. Pulso enviado. El mismo dispositivo en el gabinete.",
382
+ already_no_key: "El demonio está activo, pero este usuario no lee la clave. Pruebe: sudo orb44 status",
383
+ update_same: "Ya está {version}.",
384
+ update_wrote: "Actualizado a {version} → {path}",
385
+ update_need_root: "No se puede escribir el árbol. Ejecute: sudo orb44 update",
386
+ update_no_tree: "No hay instalación del satélite que sobrescribir. Tras install: sudo orb44 update. Mientras tanto, fije npx.",
387
+ update_fail: "La actualización falló{err}",
388
+ update_npx: "No use npx @orb44/cli sin versión — fije: npx @orb44/cli@{version}",
389
+ update_restarted: "orb44-satellite reiniciado.",
253
390
  advice_title: "Qué hacer en esta máquina",
254
391
  advice_sub: "El gabinete no lo ejecutará: una clave filtrada no debe ser un shell remoto.",
255
392
  preview_host: "host {h}",
@@ -257,6 +394,23 @@ login에서 언어를 묻고 저장한 뒤, 데몬은 기본값 아니오입니
257
394
  preview_listen: "escucha: {list}",
258
395
  preview_top: "top: {list}",
259
396
  preview_none: "no se ve",
397
+ preview_col_check: "dato",
398
+ preview_col_value: "valor",
399
+ preview_col_bind: "bind",
400
+ preview_col_port: "puerto",
401
+ preview_col_proc: "proceso",
402
+ preview_col_cpu: "CPU",
403
+ preview_col_rss: "RSS",
404
+ preview_row_host: "host",
405
+ preview_row_load: "load",
406
+ preview_row_ram: "RAM",
407
+ preview_row_disk: "disco",
408
+ preview_row_grade: "grado",
409
+ preview_row_fw: "cortafuegos",
410
+ preview_row_ban: "fail2ban",
411
+ preview_row_upd: "updates",
412
+ preview_row_sync: "hora",
413
+ preview_row_ssh: "SSH",
260
414
  preview_guard: "defensa: cortafuegos {fw} · {ban} · actualizaciones {upd} · hora {sync} · SSH {ssh} · grado {grade}",
261
415
  preview_fw_no: "no",
262
416
  preview_ban_no: "sin fail2ban",
@@ -267,12 +421,14 @@ login에서 언어를 묻고 저장한 뒤, 데몬은 기본값 아니오입니
267
421
  preview_limited: "limitado: no se ve qué proceso tiene los puertos",
268
422
  help: `Satélite Orb44 — dispositivo de admin.
269
423
 
270
- orb44 login [--url http://127.0.0.1:8787] [--daemon] [--lang en|ru|ko|es]
424
+ orb44 login [--url http://127.0.0.1:8787] [--daemon] [--force] [--lang en|ru|ko|es]
271
425
  orb44 pulse
272
426
  orb44 daemon [--interval 300]
273
427
  orb44 install [--system] [--interval 300]
428
+ orb44 uninstall [--purge]
274
429
  orb44 lang [en|ru|ko|es]
275
430
  orb44 status
431
+ orb44 update
276
432
  orb44 logout
277
433
 
278
434
  Login pregunta idioma (lo guarda) y luego demonio (por defecto no).
@@ -0,0 +1,46 @@
1
+ export const SYSTEM_DEVICE = "/var/lib/orb44/device.json";
2
+
3
+ export function parseDeviceJson(raw, filePath) {
4
+ try {
5
+ const row = JSON.parse(raw);
6
+ if (!row?.secret) return null;
7
+ return { ...row, path: filePath };
8
+ } catch {
9
+ return null;
10
+ }
11
+ }
12
+
13
+ export function deviceSearchPaths(homeFile, systemdPath = SYSTEM_DEVICE) {
14
+ return [...new Set([homeFile, systemdPath].filter(Boolean))];
15
+ }
16
+
17
+ export function loadFirstDevice(readFile, paths) {
18
+ for (const p of paths) {
19
+ try {
20
+ const row = parseDeviceJson(readFile(p), p);
21
+ if (row) return row;
22
+ } catch {
23
+ /* missing or unreadable */
24
+ }
25
+ }
26
+ return null;
27
+ }
28
+
29
+ export function stripDevicePath(row) {
30
+ if (!row) return null;
31
+ const { path: _p, ...rest } = row;
32
+ return rest;
33
+ }
34
+
35
+ export function hasExistingInstall({ devices = [], daemon = false, unit = false } = {}) {
36
+ return Boolean((devices && devices.length) || daemon || unit);
37
+ }
38
+
39
+ export function pickLiveDevice(devices, { daemon = false, systemdPath = SYSTEM_DEVICE } = {}) {
40
+ const list = Array.isArray(devices) ? devices.filter((d) => d?.secret) : [];
41
+ if (daemon) {
42
+ const sys = list.find((d) => d.path === systemdPath);
43
+ if (sys) return sys;
44
+ }
45
+ return list[0] || null;
46
+ }
@@ -0,0 +1,104 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { spawnSync } from "node:child_process";
4
+
5
+ export const CLI_PACKAGE = "@orb44/cli";
6
+ export const SAT_TREE_FILES = [
7
+ "bin/orb44.mjs",
8
+ "server/pulse.mjs",
9
+ "server/sat-i18n.mjs",
10
+ "server/cli-menu.mjs",
11
+ "server/sat-local.mjs",
12
+ "server/sat-update.mjs",
13
+ "package.json",
14
+ ];
15
+
16
+ const REGISTRY_LATEST = "https://registry.npmjs.org/@orb44%2fcli/latest";
17
+
18
+ export function cmpVer(a, b) {
19
+ const pa = String(a || "0").split(".").map((n) => Number(n) || 0);
20
+ const pb = String(b || "0").split(".").map((n) => Number(n) || 0);
21
+ const n = Math.max(pa.length, pb.length);
22
+ for (let i = 0; i < n; i++) {
23
+ const d = (pa[i] || 0) - (pb[i] || 0);
24
+ if (d) return d < 0 ? -1 : 1;
25
+ }
26
+ return 0;
27
+ }
28
+
29
+ export function looksLikeSat(root) {
30
+ const r = path.resolve(root);
31
+ if (!fs.existsSync(path.join(r, "bin", "orb44.mjs"))) return false;
32
+ if (!fs.existsSync(path.join(r, "server", "pulse.mjs"))) return false;
33
+ if (fs.existsSync(path.join(r, "server", "index.mjs"))) return false;
34
+ return true;
35
+ }
36
+
37
+ export function readCliVersion(scriptFile) {
38
+ const binDir = path.dirname(path.resolve(scriptFile));
39
+ const root = path.join(binDir, "..");
40
+ const candidates = [
41
+ path.join(root, "package.json"),
42
+ path.join(root, "packages", "orb44", "package.json"),
43
+ ];
44
+ for (const p of candidates) {
45
+ try {
46
+ const j = JSON.parse(fs.readFileSync(p, "utf8"));
47
+ if (j.name === CLI_PACKAGE && j.version) return String(j.version);
48
+ } catch {
49
+ /* next */
50
+ }
51
+ }
52
+ return "0.0.0";
53
+ }
54
+
55
+ export function pickSatRoots(scriptFile, { systemLib = "/usr/lib/orb44-sat" } = {}) {
56
+ const here = path.resolve(path.dirname(scriptFile), "..");
57
+ const out = [];
58
+ if (looksLikeSat(here)) out.push(here);
59
+ const lib = systemLib ? path.resolve(systemLib) : "";
60
+ if (lib && looksLikeSat(lib) && !out.includes(lib)) out.push(lib);
61
+ return out;
62
+ }
63
+
64
+ export function applyUpdateTree(srcPackageDir, destRoot) {
65
+ const src = path.resolve(srcPackageDir);
66
+ const dest = path.resolve(destRoot);
67
+ for (const rel of SAT_TREE_FILES) {
68
+ const from = path.join(src, rel);
69
+ if (!fs.existsSync(from)) {
70
+ if (rel === "package.json") continue;
71
+ throw new Error(`missing ${rel}`);
72
+ }
73
+ const to = path.join(dest, rel);
74
+ fs.mkdirSync(path.dirname(to), { recursive: true });
75
+ fs.copyFileSync(from, to);
76
+ fs.chmodSync(to, rel.endsWith("orb44.mjs") ? 0o755 : 0o644);
77
+ }
78
+ return dest;
79
+ }
80
+
81
+ export async function fetchLatestMeta(fetchFn = fetch) {
82
+ const r = await fetchFn(REGISTRY_LATEST, { headers: { Accept: "application/json" } });
83
+ if (!r?.ok) throw new Error(`registry ${r?.status || "fail"}`);
84
+ const j = await r.json();
85
+ const version = j?.version;
86
+ const tarball = j?.dist?.tarball;
87
+ if (!version || !tarball) throw new Error("registry meta");
88
+ return { version: String(version), tarball: String(tarball) };
89
+ }
90
+
91
+ export async function unpackTarball(url, tmp, fetchFn = fetch) {
92
+ const r = await fetchFn(url);
93
+ if (!r?.ok) throw new Error(`tarball ${r?.status || "fail"}`);
94
+ const buf = Buffer.from(await r.arrayBuffer());
95
+ const tgz = path.join(tmp, "pkg.tgz");
96
+ fs.writeFileSync(tgz, buf);
97
+ const unpack = path.join(tmp, "unpack");
98
+ fs.mkdirSync(unpack, { recursive: true });
99
+ const tar = spawnSync("tar", ["-xzf", tgz, "-C", unpack], { encoding: "utf8" });
100
+ if (tar.status !== 0) throw new Error((tar.stderr || tar.stdout || "tar").trim().slice(0, 200));
101
+ const pkg = path.join(unpack, "package");
102
+ if (!fs.existsSync(path.join(pkg, "bin", "orb44.mjs"))) throw new Error("bad tarball");
103
+ return pkg;
104
+ }