@orb44/cli 0.1.5 → 0.1.6

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,11 +13,11 @@ 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@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
16
+ npx @orb44/cli@0.1.6 status
17
+ npx @orb44/cli@0.1.6 pulse
18
+ npx @orb44/cli@0.1.6 install # later: background pulse, starts after reboot
19
19
  orb44 update # after install: replace /usr/lib/orb44-sat from npm
20
- npx @orb44/cli@0.1.5 logout
20
+ npx @orb44/cli@0.1.6 logout
21
21
  ```
22
22
 
23
23
  Do not run bare `npx @orb44/cli` — it can reuse an old cache. Pin the version or use `orb44` after install.
package/bin/orb44.mjs CHANGED
@@ -9,7 +9,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
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";
12
+ import { CLI_PACKAGE, cmpVer, readCliVersion, pickSatRoots, staleSatRoots, applyUpdateTree, fetchLatestMeta, unpackTarball } from "../server/sat-update.mjs";
13
+ import { cabinetRequest, apiFailText } from "../server/sat-http.mjs";
13
14
 
14
15
  const DEVICE_FILE = process.env.ORB44_DEVICE_FILE || path.join(os.homedir(), ".config", "orb44", "device.json");
15
16
  const CLI_FILE = process.env.ORB44_CLI_FILE || path.join(path.dirname(DEVICE_FILE), "cli.json");
@@ -82,16 +83,12 @@ function clearDevice() {
82
83
  }
83
84
  }
84
85
 
85
- async function api(url, pathname, { method = "GET", json, secret } = {}) {
86
- const headers = { "Content-Type": "application/json", Accept: "application/json" };
87
- if (secret) headers.Authorization = `Bearer ${secret}`;
88
- const r = await fetch(`${url.replace(/\/$/, "")}${pathname}`, {
89
- method,
90
- headers,
91
- body: json ? JSON.stringify(json) : undefined,
92
- });
93
- const body = await r.json().catch(() => ({}));
94
- return { ok: r.ok, status: r.status, body };
86
+ function api(url, pathname, opts) {
87
+ return cabinetRequest(url, pathname, opts);
88
+ }
89
+
90
+ function failLine(out, fallbackKey = "pulse_fail") {
91
+ return "⚠️ " + apiFailText(lang, out, t, fallbackKey);
95
92
  }
96
93
 
97
94
  function dim(s) {
@@ -259,7 +256,7 @@ async function cmdLogin(opts) {
259
256
  }
260
257
  const { out } = await sendPulse(live);
261
258
  if (!out.ok) {
262
- console.error(`⚠️ ${out.body.error || t(lang, "pulse_fail")}`);
259
+ console.error(failLine(out));
263
260
  process.exit(1);
264
261
  }
265
262
  console.log("\n📡 " + t(lang, "pulse_ok"));
@@ -276,7 +273,7 @@ async function cmdLogin(opts) {
276
273
  json: { hostname, name },
277
274
  });
278
275
  if (!begin.ok || !begin.body.pollToken) {
279
- console.error(`⚠️ ${begin.body.error || t(lang, "pair_fail")}`);
276
+ console.error(failLine(begin, "pair_fail"));
280
277
  process.exit(1);
281
278
  }
282
279
  console.log(`🔗 API ${url}`);
@@ -320,7 +317,7 @@ async function cmdLogin(opts) {
320
317
 
321
318
  const { out } = await sendPulse(device);
322
319
  if (!out.ok) {
323
- console.error(`⚠️ ${out.body.error || t(lang, "pulse_fail")}`);
320
+ console.error(failLine(out));
324
321
  process.exit(1);
325
322
  }
326
323
  console.log("\n📡 " + t(lang, "pulse_ok"));
@@ -350,10 +347,7 @@ async function cmdPulse() {
350
347
  banner();
351
348
  const { out } = await sendPulse(device);
352
349
  if (!out.ok) {
353
- console.error(
354
- "⚠️ " +
355
- (out.body.error === "bad_secret" || out.body.error === "not_found" ? t(lang, "key_revoked") : out.body.error || t(lang, "pulse_fail"))
356
- );
350
+ console.error(failLine(out));
357
351
  process.exit(1);
358
352
  }
359
353
  console.log("\n📡 " + t(lang, "pulse_ok"));
@@ -370,14 +364,17 @@ async function cmdDaemon(opts) {
370
364
  const sec = intervalSec(opts.interval);
371
365
  console.log(`🛰️ ${t(lang, "daemon_run", { sec, host: device.host })}`);
372
366
  const tick = async () => {
373
- const { out, pulse } = await sendPulse(device, { preview: false });
374
367
  const hh = new Date().toISOString().slice(11, 19);
375
- if (!out.ok) {
376
- const gone = out.body.error === "bad_secret" || out.body.error === "not_found";
377
- console.error(`⚠️ ${hh} ${gone ? t(lang, "daemon_revoked") : out.body.error || t(lang, "pulse_fail")}`);
378
- return;
368
+ try {
369
+ const { out, pulse } = await sendPulse(device, { preview: false });
370
+ if (!out.ok) {
371
+ console.error(`⚠️ ${hh} ${apiFailText(lang, out, t)}`);
372
+ return;
373
+ }
374
+ console.log(`📡 ${hh} ok load ${pulse.load1} ${pulse.gradeInside || "—"}`);
375
+ } catch (e) {
376
+ console.error(`⚠️ ${hh} ${String(e?.message || e).split("\n")[0].slice(0, 200)}`);
379
377
  }
380
- console.log(`📡 ${hh} ok load ${pulse.load1} ${pulse.gradeInside || "—"}`);
381
378
  };
382
379
  await tick();
383
380
  const id = setInterval(tick, sec * 1000);
@@ -646,23 +643,24 @@ async function cmdUpdate() {
646
643
  process.exit(1);
647
644
  }
648
645
  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
646
  const roots = pickSatRoots(SCRIPT);
647
+ const stale = staleSatRoots(roots, meta.version, { force: Boolean(opts.force) });
655
648
  if (!roots.length) {
656
649
  console.error("⚠️ " + t(lang, "update_no_tree"));
657
650
  console.log(dim(pin));
658
651
  process.exit(1);
659
652
  }
653
+ if (!stale.length) {
654
+ console.log("✅ " + t(lang, "update_same", { version: current || meta.version }));
655
+ console.log(dim(pin));
656
+ return;
657
+ }
660
658
  const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "orb44-upd-"));
661
659
  let wrote = [];
662
660
  try {
663
661
  const pkg = await unpackTarball(meta.tarball, tmp);
664
662
  const blocked = [];
665
- for (const root of roots) {
663
+ for (const root of stale) {
666
664
  try {
667
665
  applyUpdateTree(pkg, root);
668
666
  wrote.push(root);
@@ -713,7 +711,7 @@ async function cmdLogout() {
713
711
  applyLang(opts);
714
712
  const device = loadDevice();
715
713
  if (device?.secret) {
716
- await api(device.api, "/api/satellites/logout", { method: "POST", json: { secret: device.secret } });
714
+ await api(device.api, "/api/satellites/logout", { method: "POST", json: { secret: device.secret } }).catch(() => {});
717
715
  }
718
716
  clearDevice();
719
717
  console.log("✅ " + t(lang, "logged_out"));
@@ -769,4 +767,9 @@ if (!fn) {
769
767
  help();
770
768
  process.exit(1);
771
769
  }
772
- await fn();
770
+ try {
771
+ await fn();
772
+ } catch (e) {
773
+ console.error("⚠️ " + String(e?.message || e).split("\n")[0].slice(0, 200));
774
+ process.exit(1);
775
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orb44/cli",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
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": {
@@ -12,6 +12,7 @@
12
12
  "server/sat-i18n.mjs",
13
13
  "server/cli-menu.mjs",
14
14
  "server/sat-local.mjs",
15
+ "server/sat-http.mjs",
15
16
  "server/sat-update.mjs"
16
17
  ],
17
18
  "engines": {
package/server/pulse.mjs CHANGED
@@ -180,23 +180,29 @@ function okListenAddr(a) {
180
180
  }
181
181
 
182
182
  export function collapseListen(rows = []) {
183
- const groups = new Map();
183
+ const world = [];
184
+ const rest = [];
184
185
  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);
186
+ if (r.addr === "0.0.0.0" || r.addr === "::" || r.addr === "*") world.push(r);
187
+ else rest.push(r);
188
+ }
189
+ const byPort = new Map();
190
+ for (const r of world) {
191
+ const port = Number(r.port);
192
+ if (!byPort.has(port)) byPort.set(port, []);
193
+ byPort.get(port).push(r);
188
194
  }
189
195
  const out = [];
190
- for (const list of groups.values()) {
196
+ for (const list of byPort.values()) {
191
197
  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 !== "*");
198
+ const comm = list.find((x) => x.comm)?.comm || list[0].comm;
193
199
  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);
200
+ out.push({ addr: "*", port: list[0].port, comm });
196
201
  } else {
197
202
  out.push(...list);
198
203
  }
199
204
  }
205
+ out.push(...rest);
200
206
  return out;
201
207
  }
202
208
 
@@ -0,0 +1,65 @@
1
+ export const FETCH_MS = 15_000;
2
+
3
+ const NET_CODES = new Set([
4
+ "ECONNREFUSED",
5
+ "ECONNRESET",
6
+ "ECONNABORTED",
7
+ "EHOSTUNREACH",
8
+ "ENETUNREACH",
9
+ "EPIPE",
10
+ "EAI_AGAIN",
11
+ "ENOTFOUND",
12
+ ]);
13
+
14
+ export function classifyNetError(err) {
15
+ const cause = err?.cause && typeof err.cause === "object" ? err.cause : err;
16
+ const code = String(cause?.code || err?.code || "");
17
+ const name = String(err?.name || cause?.name || "");
18
+ const msg = `${cause?.message || ""} ${err?.message || ""}`;
19
+ if (name === "TimeoutError" || name === "AbortError" || code === "ABORT_ERR" || /timeout|aborted/i.test(msg)) {
20
+ return "timeout";
21
+ }
22
+ if (/CERT|UNABLE_TO_VERIFY|ERR_TLS|ERR_SSL/i.test(`${code} ${msg}`)) return "tls";
23
+ if (code === "ENOTFOUND" || code === "EAI_AGAIN" || /getaddrinfo/i.test(msg)) return "dns";
24
+ if (NET_CODES.has(code) || /fetch failed|socket hang up/i.test(msg)) return "unreachable";
25
+ return "unreachable";
26
+ }
27
+
28
+ export function cabinetFail(url, err) {
29
+ return {
30
+ ok: false,
31
+ status: 0,
32
+ body: { error: classifyNetError(err), url: String(url || "").replace(/\/$/, "") },
33
+ };
34
+ }
35
+
36
+ export async function cabinetRequest(url, pathname, { method = "GET", json, secret, fetchImpl = fetch, timeoutMs = FETCH_MS } = {}) {
37
+ const base = String(url || "").replace(/\/$/, "");
38
+ const headers = { "Content-Type": "application/json", Accept: "application/json" };
39
+ if (secret) headers.Authorization = `Bearer ${secret}`;
40
+ const signal = timeoutMs > 0 && typeof AbortSignal !== "undefined" && AbortSignal.timeout ? AbortSignal.timeout(timeoutMs) : undefined;
41
+ try {
42
+ const r = await fetchImpl(`${base}${pathname}`, {
43
+ method,
44
+ headers,
45
+ body: json ? JSON.stringify(json) : undefined,
46
+ signal,
47
+ });
48
+ const body = await r.json().catch(() => ({}));
49
+ return { ok: Boolean(r.ok), status: r.status, body };
50
+ } catch (err) {
51
+ return cabinetFail(base, err);
52
+ }
53
+ }
54
+
55
+ export function apiFailText(lang, out, t, fallbackKey = "pulse_fail") {
56
+ const err = out?.body?.error;
57
+ const url = out?.body?.url || "";
58
+ if (err === "unreachable") return t(lang, "cabinet_down", { url });
59
+ if (err === "timeout") return t(lang, "cabinet_timeout", { url });
60
+ if (err === "tls") return t(lang, "cabinet_tls", { url });
61
+ if (err === "dns") return t(lang, "cabinet_dns", { url });
62
+ if (err === "bad_secret" || err === "not_found") return t(lang, "key_revoked");
63
+ if (typeof err === "string" && /^[a-z][a-z0-9_]{0,40}$/.test(err)) return err;
64
+ return t(lang, fallbackKey);
65
+ }
@@ -35,6 +35,10 @@ const STR = {
35
35
  payload: "What goes to the cabinet",
36
36
  pulse_ok: "Pulse is in the cabinet.",
37
37
  pulse_fail: "pulse not accepted",
38
+ cabinet_down: "Cabinet is not reachable ({url}). Start the cabinet or the tunnel to it.",
39
+ cabinet_timeout: "Cabinet did not answer ({url}). Try again later.",
40
+ cabinet_tls: "TLS to the cabinet failed ({url}).",
41
+ cabinet_dns: "Cannot resolve the cabinet host ({url}).",
38
42
  pair_fail: "could not start pairing",
39
43
  ask_daemon: "Install as a Watch daemon?\nRuns in the background and starts again after reboot. Pulse every 5 min.",
40
44
  daemon_no: "No",
@@ -139,6 +143,10 @@ Outgoing pulse. Cabinet does not execute commands; it replies with advice.`,
139
143
  payload: "Что уходит в кабинет",
140
144
  pulse_ok: "Пульс в кабинете.",
141
145
  pulse_fail: "пульс не принят",
146
+ cabinet_down: "Кабинет недоступен ({url}). Поднимите кабинет или туннель к нему.",
147
+ cabinet_timeout: "Кабинет не ответил ({url}). Повторите позже.",
148
+ cabinet_tls: "TLS к кабинету не сошёлся ({url}).",
149
+ cabinet_dns: "Не резолвится хост кабинета ({url}).",
142
150
  pair_fail: "не удалось начать пару",
143
151
  ask_daemon: "Поставить Watch-демоном?\nБудет работать в фоне и подниматься после перезагрузки. Пульс каждые 5 мин.",
144
152
  daemon_no: "Нет",
@@ -243,6 +251,10 @@ Outgoing pulse. Cabinet does not execute commands; it replies with advice.`,
243
251
  payload: "콘솔로 보내는 내용",
244
252
  pulse_ok: "펄스가 콘솔에 있습니다.",
245
253
  pulse_fail: "펄스 거부",
254
+ cabinet_down: "콘솔에 연결할 수 없습니다 ({url}). 콘솔 또는 터널을 올리세요.",
255
+ cabinet_timeout: "콘솔이 응답하지 않습니다 ({url}). 나중에 다시 시도하세요.",
256
+ cabinet_tls: "콘솔 TLS 실패 ({url}).",
257
+ cabinet_dns: "콘솔 호스트를 찾지 못했습니다 ({url}).",
246
258
  pair_fail: "페어링을 시작하지 못했습니다",
247
259
  ask_daemon: "Watch 데몬으로 설치할까요?\n백그라운드에서 돌고 재부팅 후에도 올라옵니다. 5분마다 펄스.",
248
260
  daemon_no: "아니요",
@@ -347,6 +359,10 @@ login에서 언어를 묻고 저장한 뒤, 데몬은 기본값 아니오입니
347
359
  payload: "Qué se envía al gabinete",
348
360
  pulse_ok: "Pulso en el gabinete.",
349
361
  pulse_fail: "pulso rechazado",
362
+ cabinet_down: "El gabinete no responde ({url}). Levante el gabinete o el túnel.",
363
+ cabinet_timeout: "El gabinete no contestó ({url}). Pruebe más tarde.",
364
+ cabinet_tls: "Falló TLS al gabinete ({url}).",
365
+ cabinet_dns: "No se resuelve el host del gabinete ({url}).",
350
366
  pair_fail: "no se pudo iniciar el emparejamiento",
351
367
  ask_daemon: "¿Instalar como demonio Watch?\nCorre en segundo plano y arranca de nuevo tras el reinicio. Pulso cada 5 min.",
352
368
  daemon_no: "No",
@@ -9,6 +9,7 @@ export const SAT_TREE_FILES = [
9
9
  "server/sat-i18n.mjs",
10
10
  "server/cli-menu.mjs",
11
11
  "server/sat-local.mjs",
12
+ "server/sat-http.mjs",
12
13
  "server/sat-update.mjs",
13
14
  "package.json",
14
15
  ];
@@ -52,6 +53,24 @@ export function readCliVersion(scriptFile) {
52
53
  return "0.0.0";
53
54
  }
54
55
 
56
+ export function treeNeedsRefresh(root, latest, { force = false } = {}) {
57
+ if (force) return true;
58
+ const ver = readCliVersion(path.join(root, "bin", "orb44.mjs"));
59
+ if (cmpVer(ver, latest) < 0) return true;
60
+ try {
61
+ const pulse = fs.readFileSync(path.join(root, "server", "pulse.mjs"), "utf8");
62
+ if (!pulse.includes("function collapseListen")) return true;
63
+ if (!fs.existsSync(path.join(root, "server", "sat-http.mjs"))) return true;
64
+ } catch {
65
+ return true;
66
+ }
67
+ return false;
68
+ }
69
+
70
+ export function staleSatRoots(roots, latest, opts = {}) {
71
+ return (roots || []).filter((r) => treeNeedsRefresh(r, latest, opts));
72
+ }
73
+
55
74
  export function pickSatRoots(scriptFile, { systemLib = "/usr/lib/orb44-sat" } = {}) {
56
75
  const here = path.resolve(path.dirname(scriptFile), "..");
57
76
  const out = [];