@orb44/cli 0.1.4 → 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,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.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
+ orb44 update # after install: replace /usr/lib/orb44-sat from npm
20
+ npx @orb44/cli@0.1.6 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
@@ -9,6 +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, staleSatRoots, applyUpdateTree, fetchLatestMeta, unpackTarball } from "../server/sat-update.mjs";
13
+ import { cabinetRequest, apiFailText } from "../server/sat-http.mjs";
12
14
 
13
15
  const DEVICE_FILE = process.env.ORB44_DEVICE_FILE || path.join(os.homedir(), ".config", "orb44", "device.json");
14
16
  const CLI_FILE = process.env.ORB44_CLI_FILE || path.join(path.dirname(DEVICE_FILE), "cli.json");
@@ -81,16 +83,12 @@ function clearDevice() {
81
83
  }
82
84
  }
83
85
 
84
- async function api(url, pathname, { method = "GET", json, secret } = {}) {
85
- const headers = { "Content-Type": "application/json", Accept: "application/json" };
86
- if (secret) headers.Authorization = `Bearer ${secret}`;
87
- const r = await fetch(`${url.replace(/\/$/, "")}${pathname}`, {
88
- method,
89
- headers,
90
- body: json ? JSON.stringify(json) : undefined,
91
- });
92
- const body = await r.json().catch(() => ({}));
93
- 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);
94
92
  }
95
93
 
96
94
  function dim(s) {
@@ -258,7 +256,7 @@ async function cmdLogin(opts) {
258
256
  }
259
257
  const { out } = await sendPulse(live);
260
258
  if (!out.ok) {
261
- console.error(`⚠️ ${out.body.error || t(lang, "pulse_fail")}`);
259
+ console.error(failLine(out));
262
260
  process.exit(1);
263
261
  }
264
262
  console.log("\n📡 " + t(lang, "pulse_ok"));
@@ -275,7 +273,7 @@ async function cmdLogin(opts) {
275
273
  json: { hostname, name },
276
274
  });
277
275
  if (!begin.ok || !begin.body.pollToken) {
278
- console.error(`⚠️ ${begin.body.error || t(lang, "pair_fail")}`);
276
+ console.error(failLine(begin, "pair_fail"));
279
277
  process.exit(1);
280
278
  }
281
279
  console.log(`🔗 API ${url}`);
@@ -319,7 +317,7 @@ async function cmdLogin(opts) {
319
317
 
320
318
  const { out } = await sendPulse(device);
321
319
  if (!out.ok) {
322
- console.error(`⚠️ ${out.body.error || t(lang, "pulse_fail")}`);
320
+ console.error(failLine(out));
323
321
  process.exit(1);
324
322
  }
325
323
  console.log("\n📡 " + t(lang, "pulse_ok"));
@@ -349,10 +347,7 @@ async function cmdPulse() {
349
347
  banner();
350
348
  const { out } = await sendPulse(device);
351
349
  if (!out.ok) {
352
- console.error(
353
- "⚠️ " +
354
- (out.body.error === "bad_secret" || out.body.error === "not_found" ? t(lang, "key_revoked") : out.body.error || t(lang, "pulse_fail"))
355
- );
350
+ console.error(failLine(out));
356
351
  process.exit(1);
357
352
  }
358
353
  console.log("\n📡 " + t(lang, "pulse_ok"));
@@ -369,14 +364,17 @@ async function cmdDaemon(opts) {
369
364
  const sec = intervalSec(opts.interval);
370
365
  console.log(`🛰️ ${t(lang, "daemon_run", { sec, host: device.host })}`);
371
366
  const tick = async () => {
372
- const { out, pulse } = await sendPulse(device, { preview: false });
373
367
  const hh = new Date().toISOString().slice(11, 19);
374
- if (!out.ok) {
375
- const gone = out.body.error === "bad_secret" || out.body.error === "not_found";
376
- console.error(`⚠️ ${hh} ${gone ? t(lang, "daemon_revoked") : out.body.error || t(lang, "pulse_fail")}`);
377
- 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)}`);
378
377
  }
379
- console.log(`📡 ${hh} ok load ${pulse.load1} ${pulse.gradeInside || "—"}`);
380
378
  };
381
379
  await tick();
382
380
  const id = setInterval(tick, sec * 1000);
@@ -422,25 +420,19 @@ function linkSystemCli(node, script) {
422
420
  fs.writeFileSync(CLI_LINK, body, { mode: 0o755 });
423
421
  }
424
422
 
425
- function satServerDir() {
426
- return path.join(path.dirname(SCRIPT), "..", "server");
427
- }
428
-
429
423
  function installSystemTree() {
430
424
  const lib = "/usr/lib/orb44-sat";
431
- const serverSrc = satServerDir();
425
+ const srcRoot = path.join(path.dirname(SCRIPT), "..");
432
426
  fs.mkdirSync(path.join(lib, "bin"), { recursive: true, mode: 0o755 });
433
427
  fs.mkdirSync(path.join(lib, "server"), { recursive: true, mode: 0o755 });
434
- const copies = [
435
- [SCRIPT, path.join(lib, "bin", "orb44.mjs")],
436
- [path.join(serverSrc, "pulse.mjs"), path.join(lib, "server", "pulse.mjs")],
437
- [path.join(serverSrc, "sat-i18n.mjs"), path.join(lib, "server", "sat-i18n.mjs")],
438
- [path.join(serverSrc, "cli-menu.mjs"), path.join(lib, "server", "cli-menu.mjs")],
439
- [path.join(serverSrc, "sat-local.mjs"), path.join(lib, "server", "sat-local.mjs")],
440
- ];
441
- for (const [from, to] of copies) {
442
- fs.copyFileSync(from, to);
443
- fs.chmodSync(to, 0o644);
428
+ applyUpdateTree(srcRoot, lib);
429
+ const ver = readCliVersion(SCRIPT);
430
+ if (ver && ver !== "0.0.0") {
431
+ fs.writeFileSync(
432
+ path.join(lib, "package.json"),
433
+ JSON.stringify({ name: CLI_PACKAGE, version: ver, type: "module", private: true }, null, 2) + "\n",
434
+ { mode: 0o644 }
435
+ );
444
436
  }
445
437
  return path.join(lib, "bin", "orb44.mjs");
446
438
  }
@@ -640,6 +632,63 @@ async function cmdUninstall(opts) {
640
632
  console.log(dim(t(lang, "uninstall_key_kept", { file: SYSTEM_DEVICE })));
641
633
  }
642
634
 
635
+ async function cmdUpdate() {
636
+ applyLang(opts);
637
+ const current = readCliVersion(SCRIPT);
638
+ let meta;
639
+ try {
640
+ meta = await fetchLatestMeta();
641
+ } catch (e) {
642
+ console.error("⚠️ " + t(lang, "update_fail", { err: `: ${String(e.message || e).slice(0, 160)}` }));
643
+ process.exit(1);
644
+ }
645
+ const pin = t(lang, "update_npx", { version: meta.version });
646
+ const roots = pickSatRoots(SCRIPT);
647
+ const stale = staleSatRoots(roots, meta.version, { force: Boolean(opts.force) });
648
+ if (!roots.length) {
649
+ console.error("⚠️ " + t(lang, "update_no_tree"));
650
+ console.log(dim(pin));
651
+ process.exit(1);
652
+ }
653
+ if (!stale.length) {
654
+ console.log("✅ " + t(lang, "update_same", { version: current || meta.version }));
655
+ console.log(dim(pin));
656
+ return;
657
+ }
658
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "orb44-upd-"));
659
+ let wrote = [];
660
+ try {
661
+ const pkg = await unpackTarball(meta.tarball, tmp);
662
+ const blocked = [];
663
+ for (const root of stale) {
664
+ try {
665
+ applyUpdateTree(pkg, root);
666
+ wrote.push(root);
667
+ } catch (e) {
668
+ blocked.push({ root, err: String(e.message || e).slice(0, 160) });
669
+ }
670
+ }
671
+ if (!wrote.length) {
672
+ 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}` : "" }));
673
+ process.exit(1);
674
+ }
675
+ } finally {
676
+ try {
677
+ fs.rmSync(tmp, { recursive: true, force: true });
678
+ } catch {
679
+ /* tmp */
680
+ }
681
+ }
682
+ for (const root of wrote) {
683
+ console.log("✅ " + t(lang, "update_wrote", { version: meta.version, path: root }));
684
+ }
685
+ if (wrote.includes("/usr/lib/orb44-sat") && serviceIsActive(true)) {
686
+ spawnSync("systemctl", ["try-restart", "orb44-satellite"], { encoding: "utf8" });
687
+ console.log(dim(t(lang, "update_restarted")));
688
+ }
689
+ console.log(dim(pin));
690
+ }
691
+
643
692
  function cmdStatus() {
644
693
  applyLang(opts);
645
694
  const device = loadDevice();
@@ -662,7 +711,7 @@ async function cmdLogout() {
662
711
  applyLang(opts);
663
712
  const device = loadDevice();
664
713
  if (device?.secret) {
665
- 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(() => {});
666
715
  }
667
716
  clearDevice();
668
717
  console.log("✅ " + t(lang, "logged_out"));
@@ -708,6 +757,7 @@ const run = {
708
757
  install: () => cmdInstall(opts, { enable: true }),
709
758
  uninstall: () => cmdUninstall(opts),
710
759
  status: cmdStatus,
760
+ update: cmdUpdate,
711
761
  logout: cmdLogout,
712
762
  lang: () => cmdLang(opts),
713
763
  help,
@@ -717,4 +767,9 @@ if (!fn) {
717
767
  help();
718
768
  process.exit(1);
719
769
  }
720
- 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.4",
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": {
@@ -11,7 +11,9 @@
11
11
  "server/pulse.mjs",
12
12
  "server/sat-i18n.mjs",
13
13
  "server/cli-menu.mjs",
14
- "server/sat-local.mjs"
14
+ "server/sat-local.mjs",
15
+ "server/sat-http.mjs",
16
+ "server/sat-update.mjs"
15
17
  ],
16
18
  "engines": {
17
19
  "node": ">=18"
package/server/pulse.mjs CHANGED
@@ -174,11 +174,38 @@ 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 world = [];
184
+ const rest = [];
185
+ for (const r of rows) {
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);
194
+ }
195
+ const out = [];
196
+ for (const list of byPort.values()) {
197
+ const addrs = new Set(list.map((x) => x.addr));
198
+ const comm = list.find((x) => x.comm)?.comm || list[0].comm;
199
+ if (addrs.has("*") || (addrs.has("0.0.0.0") && addrs.has("::"))) {
200
+ out.push({ addr: "*", port: list[0].port, comm });
201
+ } else {
202
+ out.push(...list);
203
+ }
204
+ }
205
+ out.push(...rest);
206
+ return out;
207
+ }
208
+
182
209
  export function sanitizePulse(raw = {}) {
183
210
  const top = Array.isArray(raw.top)
184
211
  ? raw.top
@@ -190,16 +217,18 @@ export function sanitizePulse(raw = {}) {
190
217
  }))
191
218
  .filter((r) => r.comm)
192
219
  : [];
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
- : [];
220
+ const listen = collapseListen(
221
+ Array.isArray(raw.listen)
222
+ ? raw.listen
223
+ .slice(0, 40)
224
+ .map((r) => ({
225
+ addr: String(r.addr || "").slice(0, 45),
226
+ port: Number(r.port) || 0,
227
+ comm: sanitizeComm(r.comm),
228
+ }))
229
+ .filter((r) => r.port > 0 && r.port < 65536 && okListenAddr(r.addr))
230
+ : []
231
+ );
203
232
  const originA = Array.isArray(raw.originA)
204
233
  ? raw.originA.map((x) => String(x || "")).filter((x) => /^\d{1,3}(\.\d{1,3}){3}$/.test(x)).slice(0, 8)
205
234
  : [];
@@ -710,9 +739,10 @@ export function formatPulsePreview(pulse, lang = "en") {
710
739
  [t(lang, "preview_row_grade"), grade],
711
740
  ]
712
741
  );
713
- const listenRows = [...(pulse.listen || [])]
742
+ const listenRows = collapseListen([...(pulse.listen || [])])
714
743
  .sort((a, b) => {
715
- const rank = (addr) => (addr === "0.0.0.0" || addr === "::" ? 0 : String(addr).startsWith("127.") || addr === "::1" ? 2 : 1);
744
+ const rank = (addr) =>
745
+ addr === "0.0.0.0" || addr === "::" || addr === "*" ? 0 : String(addr).startsWith("127.") || addr === "::1" ? 2 : 1;
716
746
  return rank(a.addr) - rank(b.addr) || Number(a.port) - Number(b.port);
717
747
  })
718
748
  .slice(0, 16)
@@ -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",
@@ -68,6 +72,13 @@ const STR = {
68
72
  already_replace: "Replace — new login, old device is revoked",
69
73
  already_kept: "Left as is. Pulse sent. Same device in the cabinet.",
70
74
  already_no_key: "Daemon is on, but this user cannot read the key. Try: sudo orb44 status",
75
+ update_same: "Already {version}.",
76
+ update_wrote: "Updated to {version} → {path}",
77
+ update_need_root: "Cannot write the install tree. Run: sudo orb44 update",
78
+ update_no_tree: "No satellite install to overwrite. After install: sudo orb44 update. Until then pin npx.",
79
+ update_fail: "Update failed{err}",
80
+ update_npx: "Do not run bare npx @orb44/cli — pin: npx @orb44/cli@{version}",
81
+ update_restarted: "Restarted orb44-satellite.",
71
82
  advice_title: "What to do on this machine",
72
83
  advice_sub: "The cabinet will not run this — a leaked key must not become a remote shell.",
73
84
  preview_host: "host {h}",
@@ -109,6 +120,7 @@ const STR = {
109
120
  orb44 uninstall [--purge]
110
121
  orb44 lang [en|ru|ko|es]
111
122
  orb44 status
123
+ orb44 update
112
124
  orb44 logout
113
125
 
114
126
  Login asks language (saved) then daemon (default no).
@@ -131,6 +143,10 @@ Outgoing pulse. Cabinet does not execute commands; it replies with advice.`,
131
143
  payload: "Что уходит в кабинет",
132
144
  pulse_ok: "Пульс в кабинете.",
133
145
  pulse_fail: "пульс не принят",
146
+ cabinet_down: "Кабинет недоступен ({url}). Поднимите кабинет или туннель к нему.",
147
+ cabinet_timeout: "Кабинет не ответил ({url}). Повторите позже.",
148
+ cabinet_tls: "TLS к кабинету не сошёлся ({url}).",
149
+ cabinet_dns: "Не резолвится хост кабинета ({url}).",
134
150
  pair_fail: "не удалось начать пару",
135
151
  ask_daemon: "Поставить Watch-демоном?\nБудет работать в фоне и подниматься после перезагрузки. Пульс каждые 5 мин.",
136
152
  daemon_no: "Нет",
@@ -164,6 +180,13 @@ Outgoing pulse. Cabinet does not execute commands; it replies with advice.`,
164
180
  already_replace: "Заменить — новый login, старое устройство отзовётся",
165
181
  already_kept: "Оставили как есть. Пульс ушёл. В кабинете то же устройство.",
166
182
  already_no_key: "Демон работает, но этот пользователь не читает ключ. Попробуйте: sudo orb44 status",
183
+ update_same: "Уже {version}.",
184
+ update_wrote: "Обновлено до {version} → {path}",
185
+ update_need_root: "Нельзя записать дерево установки. Запустите: sudo orb44 update",
186
+ update_no_tree: "Нет дерева сателлита для перезаписи. После install: sudo orb44 update. До этого — pin в npx.",
187
+ update_fail: "Обновление не вышло{err}",
188
+ update_npx: "Не вызывайте голый npx @orb44/cli — pin: npx @orb44/cli@{version}",
189
+ update_restarted: "Демон orb44-satellite перезапущен.",
167
190
  advice_title: "Что сделать на этой машине",
168
191
  advice_sub: "Кабинет это не выполнит — иначе утечка ключа была бы удалённым шеллом.",
169
192
  preview_host: "хост {h}",
@@ -205,6 +228,7 @@ Outgoing pulse. Cabinet does not execute commands; it replies with advice.`,
205
228
  orb44 uninstall [--purge]
206
229
  orb44 lang [en|ru|ko|es]
207
230
  orb44 status
231
+ orb44 update
208
232
  orb44 logout
209
233
 
210
234
  После login спрашивает язык (запоминает), затем демон — по умолчанию нет.
@@ -227,6 +251,10 @@ Outgoing pulse. Cabinet does not execute commands; it replies with advice.`,
227
251
  payload: "콘솔로 보내는 내용",
228
252
  pulse_ok: "펄스가 콘솔에 있습니다.",
229
253
  pulse_fail: "펄스 거부",
254
+ cabinet_down: "콘솔에 연결할 수 없습니다 ({url}). 콘솔 또는 터널을 올리세요.",
255
+ cabinet_timeout: "콘솔이 응답하지 않습니다 ({url}). 나중에 다시 시도하세요.",
256
+ cabinet_tls: "콘솔 TLS 실패 ({url}).",
257
+ cabinet_dns: "콘솔 호스트를 찾지 못했습니다 ({url}).",
230
258
  pair_fail: "페어링을 시작하지 못했습니다",
231
259
  ask_daemon: "Watch 데몬으로 설치할까요?\n백그라운드에서 돌고 재부팅 후에도 올라옵니다. 5분마다 펄스.",
232
260
  daemon_no: "아니요",
@@ -260,6 +288,13 @@ Outgoing pulse. Cabinet does not execute commands; it replies with advice.`,
260
288
  already_replace: "교체 — 새 login, 이전 장치는 취소됨",
261
289
  already_kept: "그대로 두었습니다. 펄스를 보냈습니다. 콘솔의 장치는 같습니다.",
262
290
  already_no_key: "데몬은 켜져 있지만 이 사용자는 키를 읽지 못합니다. sudo orb44 status",
291
+ update_same: "이미 {version}.",
292
+ update_wrote: "{version}(으)로 갱신 → {path}",
293
+ update_need_root: "설치 트리를 쓸 수 없습니다. sudo orb44 update",
294
+ update_no_tree: "덮어쓸 위성 설치가 없습니다. install 후 sudo orb44 update. 그 전에는 npx를 pin 하세요.",
295
+ update_fail: "업데이트 실패{err}",
296
+ update_npx: "버전 없는 npx @orb44/cli 는 쓰지 마세요 — pin: npx @orb44/cli@{version}",
297
+ update_restarted: "orb44-satellite 를 재시작했습니다.",
263
298
  advice_title: "이 기기에서 할 일",
264
299
  advice_sub: "콘솔은 실행하지 않습니다. 키 유출이 원격 셸이 되면 안 됩니다.",
265
300
  preview_host: "호스트 {h}",
@@ -301,6 +336,7 @@ Outgoing pulse. Cabinet does not execute commands; it replies with advice.`,
301
336
  orb44 uninstall [--purge]
302
337
  orb44 lang [en|ru|ko|es]
303
338
  orb44 status
339
+ orb44 update
304
340
  orb44 logout
305
341
 
306
342
  login에서 언어를 묻고 저장한 뒤, 데몬은 기본값 아니오입니다.
@@ -323,6 +359,10 @@ login에서 언어를 묻고 저장한 뒤, 데몬은 기본값 아니오입니
323
359
  payload: "Qué se envía al gabinete",
324
360
  pulse_ok: "Pulso en el gabinete.",
325
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}).",
326
366
  pair_fail: "no se pudo iniciar el emparejamiento",
327
367
  ask_daemon: "¿Instalar como demonio Watch?\nCorre en segundo plano y arranca de nuevo tras el reinicio. Pulso cada 5 min.",
328
368
  daemon_no: "No",
@@ -356,6 +396,13 @@ login에서 언어를 묻고 저장한 뒤, 데몬은 기본값 아니오입니
356
396
  already_replace: "Sustituir — login nuevo, se revoca el dispositivo anterior",
357
397
  already_kept: "Se dejó igual. Pulso enviado. El mismo dispositivo en el gabinete.",
358
398
  already_no_key: "El demonio está activo, pero este usuario no lee la clave. Pruebe: sudo orb44 status",
399
+ update_same: "Ya está {version}.",
400
+ update_wrote: "Actualizado a {version} → {path}",
401
+ update_need_root: "No se puede escribir el árbol. Ejecute: sudo orb44 update",
402
+ update_no_tree: "No hay instalación del satélite que sobrescribir. Tras install: sudo orb44 update. Mientras tanto, fije npx.",
403
+ update_fail: "La actualización falló{err}",
404
+ update_npx: "No use npx @orb44/cli sin versión — fije: npx @orb44/cli@{version}",
405
+ update_restarted: "orb44-satellite reiniciado.",
359
406
  advice_title: "Qué hacer en esta máquina",
360
407
  advice_sub: "El gabinete no lo ejecutará: una clave filtrada no debe ser un shell remoto.",
361
408
  preview_host: "host {h}",
@@ -397,6 +444,7 @@ login에서 언어를 묻고 저장한 뒤, 데몬은 기본값 아니오입니
397
444
  orb44 uninstall [--purge]
398
445
  orb44 lang [en|ru|ko|es]
399
446
  orb44 status
447
+ orb44 update
400
448
  orb44 logout
401
449
 
402
450
  Login pregunta idioma (lo guarda) y luego demonio (por defecto no).
@@ -0,0 +1,123 @@
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-http.mjs",
13
+ "server/sat-update.mjs",
14
+ "package.json",
15
+ ];
16
+
17
+ const REGISTRY_LATEST = "https://registry.npmjs.org/@orb44%2fcli/latest";
18
+
19
+ export function cmpVer(a, b) {
20
+ const pa = String(a || "0").split(".").map((n) => Number(n) || 0);
21
+ const pb = String(b || "0").split(".").map((n) => Number(n) || 0);
22
+ const n = Math.max(pa.length, pb.length);
23
+ for (let i = 0; i < n; i++) {
24
+ const d = (pa[i] || 0) - (pb[i] || 0);
25
+ if (d) return d < 0 ? -1 : 1;
26
+ }
27
+ return 0;
28
+ }
29
+
30
+ export function looksLikeSat(root) {
31
+ const r = path.resolve(root);
32
+ if (!fs.existsSync(path.join(r, "bin", "orb44.mjs"))) return false;
33
+ if (!fs.existsSync(path.join(r, "server", "pulse.mjs"))) return false;
34
+ if (fs.existsSync(path.join(r, "server", "index.mjs"))) return false;
35
+ return true;
36
+ }
37
+
38
+ export function readCliVersion(scriptFile) {
39
+ const binDir = path.dirname(path.resolve(scriptFile));
40
+ const root = path.join(binDir, "..");
41
+ const candidates = [
42
+ path.join(root, "package.json"),
43
+ path.join(root, "packages", "orb44", "package.json"),
44
+ ];
45
+ for (const p of candidates) {
46
+ try {
47
+ const j = JSON.parse(fs.readFileSync(p, "utf8"));
48
+ if (j.name === CLI_PACKAGE && j.version) return String(j.version);
49
+ } catch {
50
+ /* next */
51
+ }
52
+ }
53
+ return "0.0.0";
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
+
74
+ export function pickSatRoots(scriptFile, { systemLib = "/usr/lib/orb44-sat" } = {}) {
75
+ const here = path.resolve(path.dirname(scriptFile), "..");
76
+ const out = [];
77
+ if (looksLikeSat(here)) out.push(here);
78
+ const lib = systemLib ? path.resolve(systemLib) : "";
79
+ if (lib && looksLikeSat(lib) && !out.includes(lib)) out.push(lib);
80
+ return out;
81
+ }
82
+
83
+ export function applyUpdateTree(srcPackageDir, destRoot) {
84
+ const src = path.resolve(srcPackageDir);
85
+ const dest = path.resolve(destRoot);
86
+ for (const rel of SAT_TREE_FILES) {
87
+ const from = path.join(src, rel);
88
+ if (!fs.existsSync(from)) {
89
+ if (rel === "package.json") continue;
90
+ throw new Error(`missing ${rel}`);
91
+ }
92
+ const to = path.join(dest, rel);
93
+ fs.mkdirSync(path.dirname(to), { recursive: true });
94
+ fs.copyFileSync(from, to);
95
+ fs.chmodSync(to, rel.endsWith("orb44.mjs") ? 0o755 : 0o644);
96
+ }
97
+ return dest;
98
+ }
99
+
100
+ export async function fetchLatestMeta(fetchFn = fetch) {
101
+ const r = await fetchFn(REGISTRY_LATEST, { headers: { Accept: "application/json" } });
102
+ if (!r?.ok) throw new Error(`registry ${r?.status || "fail"}`);
103
+ const j = await r.json();
104
+ const version = j?.version;
105
+ const tarball = j?.dist?.tarball;
106
+ if (!version || !tarball) throw new Error("registry meta");
107
+ return { version: String(version), tarball: String(tarball) };
108
+ }
109
+
110
+ export async function unpackTarball(url, tmp, fetchFn = fetch) {
111
+ const r = await fetchFn(url);
112
+ if (!r?.ok) throw new Error(`tarball ${r?.status || "fail"}`);
113
+ const buf = Buffer.from(await r.arrayBuffer());
114
+ const tgz = path.join(tmp, "pkg.tgz");
115
+ fs.writeFileSync(tgz, buf);
116
+ const unpack = path.join(tmp, "unpack");
117
+ fs.mkdirSync(unpack, { recursive: true });
118
+ const tar = spawnSync("tar", ["-xzf", tgz, "-C", unpack], { encoding: "utf8" });
119
+ if (tar.status !== 0) throw new Error((tar.stderr || tar.stdout || "tar").trim().slice(0, 200));
120
+ const pkg = path.join(unpack, "package");
121
+ if (!fs.existsSync(path.join(pkg, "bin", "orb44.mjs"))) throw new Error("bad tarball");
122
+ return pkg;
123
+ }