moshcode 0.38.0 → 0.40.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +66 -0
- package/bin/moshcode.mjs +3 -0
- package/package.json +1 -1
- package/prd/0010-cloud-settings-sync.md +132 -0
- package/prd/README.md +1 -0
- package/src/aliases.mjs +160 -0
- package/src/cli-schema.mjs +59 -0
- package/src/dns-system.mjs +176 -6
- package/src/dns.mjs +33 -1
- package/src/help.mjs +14 -1
- package/src/settings-sync.mjs +659 -0
- package/src/tui.mjs +161 -14
package/src/dns-system.mjs
CHANGED
|
@@ -274,7 +274,8 @@ export function requiredPort(platform, preferred = 5354) {
|
|
|
274
274
|
/* ------------------------------------------------------- running the plan */
|
|
275
275
|
|
|
276
276
|
import { spawn } from "node:child_process";
|
|
277
|
-
import
|
|
277
|
+
import dgram from "node:dgram";
|
|
278
|
+
import { mkdir, open, readFile, rm, writeFile } from "node:fs/promises";
|
|
278
279
|
import { existsSync } from "node:fs";
|
|
279
280
|
import { dirname, join } from "node:path";
|
|
280
281
|
import { homedir, tmpdir } from "node:os";
|
|
@@ -367,17 +368,132 @@ export async function daemonStatus(path = pidfilePath()) {
|
|
|
367
368
|
}
|
|
368
369
|
|
|
369
370
|
/**
|
|
370
|
-
*
|
|
371
|
+
* Where a daemon that died on startup left its reason.
|
|
371
372
|
*
|
|
372
|
-
*
|
|
373
|
+
* Next to the pidfile, because the two answer halves of the same question and
|
|
374
|
+
* a person debugging one wants the other in the same directory.
|
|
375
|
+
*/
|
|
376
|
+
export function daemonLogPath(path = pidfilePath()) {
|
|
377
|
+
return join(dirname(path), "moshpit-dns.log");
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* How long to wait for the bridge to answer before reporting it unproven.
|
|
382
|
+
*
|
|
383
|
+
* Generous on purpose, and it costs nothing in the case that matters: a daemon
|
|
384
|
+
* that dies resolves the race on its `exit` event immediately, so this bounds
|
|
385
|
+
* only the "alive but has not answered yet" case. The bridge binds *after* it
|
|
386
|
+
* fetches the ending list, which against the live registry is ~3s on a fast
|
|
387
|
+
* link — a tighter deadline would print a warning about healthy bridges on
|
|
388
|
+
* every slow connection.
|
|
389
|
+
*/
|
|
390
|
+
export const READY_TIMEOUT_MS = 8000;
|
|
391
|
+
const POLL_MS = 150;
|
|
392
|
+
const LOG_TAIL_LINES = 20;
|
|
393
|
+
|
|
394
|
+
const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
395
|
+
|
|
396
|
+
/** A minimal A query. Only the reply matters here, never what it says. */
|
|
397
|
+
function encodeQuery(name, id) {
|
|
398
|
+
const labels = String(name).split(".").filter(Boolean);
|
|
399
|
+
const head = Buffer.alloc(12);
|
|
400
|
+
head.writeUInt16BE(id, 0);
|
|
401
|
+
head.writeUInt16BE(0x0100, 2); // standard query, recursion desired
|
|
402
|
+
head.writeUInt16BE(1, 4); // one question
|
|
403
|
+
const tail = Buffer.alloc(4);
|
|
404
|
+
tail.writeUInt16BE(1, 0); // A
|
|
405
|
+
tail.writeUInt16BE(1, 2); // IN
|
|
406
|
+
return Buffer.concat([
|
|
407
|
+
head,
|
|
408
|
+
...labels.map((label) => {
|
|
409
|
+
const bytes = Buffer.from(label, "ascii");
|
|
410
|
+
return Buffer.concat([Buffer.from([bytes.length]), bytes]);
|
|
411
|
+
}),
|
|
412
|
+
Buffer.from([0]),
|
|
413
|
+
tail,
|
|
414
|
+
]);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Is something serving DNS on this port?
|
|
419
|
+
*
|
|
420
|
+
* Any well-formed reply counts, including NXDOMAIN and SERVFAIL. The question
|
|
421
|
+
* is whether the resolver is up, and a bridge whose upstreams are unreachable
|
|
422
|
+
* is still a bridge that started — conflating the two would turn a bad network
|
|
423
|
+
* into a failed start.
|
|
424
|
+
*/
|
|
425
|
+
export function probeResolver({ host = "127.0.0.1", port, name = "a.eggs", timeoutMs = 500 } = {}) {
|
|
426
|
+
return new Promise((resolve) => {
|
|
427
|
+
const socket = dgram.createSocket("udp4");
|
|
428
|
+
const id = Math.floor(Math.random() * 65536);
|
|
429
|
+
let done = false;
|
|
430
|
+
const finish = (answered) => {
|
|
431
|
+
if (done) return;
|
|
432
|
+
done = true;
|
|
433
|
+
clearTimeout(timer);
|
|
434
|
+
try {
|
|
435
|
+
socket.close();
|
|
436
|
+
} catch {
|
|
437
|
+
// Already closed by the error that brought us here.
|
|
438
|
+
}
|
|
439
|
+
resolve(answered);
|
|
440
|
+
};
|
|
441
|
+
const timer = setTimeout(() => finish(false), timeoutMs);
|
|
442
|
+
socket.once("error", () => finish(false));
|
|
443
|
+
socket.on("message", (msg) => finish(msg.length >= 2 && msg.readUInt16BE(0) === id));
|
|
444
|
+
socket.send(encodeQuery(name, id), port, host, (err) => {
|
|
445
|
+
if (err) finish(false);
|
|
446
|
+
});
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
async function readLogTail(path, lines = LOG_TAIL_LINES) {
|
|
451
|
+
const text = await readFile(path, "utf8").catch(() => "");
|
|
452
|
+
const trimmed = text.trimEnd();
|
|
453
|
+
return trimmed ? trimmed.split("\n").slice(-lines).join("\n") : "";
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* Start the bridge detached, so the shell that launched it can exit — and do
|
|
458
|
+
* not claim it started until it has proved it is there.
|
|
459
|
+
*
|
|
460
|
+
* The old version spawned with `stdio: "ignore"`, wrote the pidfile from
|
|
461
|
+
* `child.pid`, and returned `started: true` in the same tick. Both halves of
|
|
462
|
+
* that were wrong on any machine where the daemon dies on startup. `enable`
|
|
463
|
+
* printed `ok bridge started (pid N)` for a process that was already gone, then
|
|
464
|
+
* installed catch-all routing — `Domains=~.` — pointing every lookup on the box
|
|
465
|
+
* at a port with nothing behind it. The failure took the machine's whole
|
|
466
|
+
* resolver down and left no way to find out why, because the one stream the
|
|
467
|
+
* daemon wrote its reason to had been routed to /dev/null. A node that is not
|
|
468
|
+
* on root's PATH, a port it cannot bind, a half-written install: all of them
|
|
469
|
+
* arrived as the same confident success line.
|
|
470
|
+
*
|
|
471
|
+
* So: stdout and stderr go to a file, an early exit is a failed start that
|
|
472
|
+
* reports what the daemon said, and the pidfile is written only once the
|
|
473
|
+
* process is still there — never for one that is not, which is what made
|
|
474
|
+
* `daemonStatus` report a stale pid as a crash that had never happened.
|
|
475
|
+
*
|
|
476
|
+
* Still not a systemd unit / launchd job / Windows service, which means it does
|
|
373
477
|
* not survive a reboot. `moshcode dns status` says so plainly rather than
|
|
374
478
|
* letting someone discover it when their names stop resolving.
|
|
375
479
|
*/
|
|
376
|
-
export async function startDaemon({
|
|
480
|
+
export async function startDaemon({
|
|
481
|
+
port,
|
|
482
|
+
registryBase,
|
|
483
|
+
path = pidfilePath(),
|
|
484
|
+
entry,
|
|
485
|
+
proxy = null,
|
|
486
|
+
host = "127.0.0.1",
|
|
487
|
+
logPath = null,
|
|
488
|
+
readyTimeoutMs = READY_TIMEOUT_MS,
|
|
489
|
+
probe = probeResolver,
|
|
490
|
+
sleep = defaultSleep,
|
|
491
|
+
}) {
|
|
377
492
|
const existing = await daemonStatus(path);
|
|
378
493
|
if (existing.running) return { started: false, pid: existing.pid, alreadyRunning: true };
|
|
379
494
|
|
|
380
495
|
await mkdir(dirname(path), { recursive: true });
|
|
496
|
+
const log = logPath || daemonLogPath(path);
|
|
381
497
|
const args = [entry, "dns", "start", "--port", String(port)];
|
|
382
498
|
if (registryBase) args.push("--registry", registryBase);
|
|
383
499
|
// Passed at spawn time because it is what the resolver answers with, not
|
|
@@ -385,10 +501,64 @@ export async function startDaemon({ port, registryBase, path = pidfilePath(), en
|
|
|
385
501
|
// short of restarting it, which is why `enable` decides this before starting.
|
|
386
502
|
if (proxy) args.push("--proxy", proxy);
|
|
387
503
|
|
|
388
|
-
|
|
504
|
+
// Truncated rather than appended: the only question this file ever answers is
|
|
505
|
+
// "why did the run I just did fail", and a previous crash above this run's
|
|
506
|
+
// output is how that question gets answered wrong.
|
|
507
|
+
await writeFile(log, "");
|
|
508
|
+
const handle = await open(log, "a");
|
|
509
|
+
let child;
|
|
510
|
+
try {
|
|
511
|
+
child = spawn(process.execPath, args, { detached: true, stdio: ["ignore", handle.fd, handle.fd] });
|
|
512
|
+
} finally {
|
|
513
|
+
// The child holds its own duplicate of the descriptor from spawn onward.
|
|
514
|
+
await handle.close();
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// `error` covers the spawn itself failing — execPath gone, not executable —
|
|
518
|
+
// which never reaches `exit` at all.
|
|
519
|
+
const died = new Promise((resolve) => {
|
|
520
|
+
child.once("error", (error) => resolve({ reason: error.message }));
|
|
521
|
+
child.once("exit", (code, signal) => resolve({
|
|
522
|
+
reason: signal ? `killed by ${signal}` : `exited ${code} before it could serve`,
|
|
523
|
+
code,
|
|
524
|
+
signal,
|
|
525
|
+
}));
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
let gone = null;
|
|
529
|
+
let verified = false;
|
|
530
|
+
const deadline = Date.now() + readyTimeoutMs;
|
|
531
|
+
while (Date.now() < deadline) {
|
|
532
|
+
gone = await Promise.race([died, sleep(POLL_MS).then(() => null)]);
|
|
533
|
+
if (gone) break;
|
|
534
|
+
if (await probe({ host, port })) {
|
|
535
|
+
verified = true;
|
|
536
|
+
break;
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
389
540
|
child.unref();
|
|
541
|
+
|
|
542
|
+
if (gone) {
|
|
543
|
+
// No pidfile for a process that is not there. Writing one anyway is what
|
|
544
|
+
// made the next `enable` believe a bridge was running and skip starting one.
|
|
545
|
+
await rm(path, { force: true });
|
|
546
|
+
return {
|
|
547
|
+
started: false,
|
|
548
|
+
alreadyRunning: false,
|
|
549
|
+
pid: null,
|
|
550
|
+
error: gone.reason,
|
|
551
|
+
log: await readLogTail(log),
|
|
552
|
+
logPath: log,
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
|
|
390
556
|
await writeFile(path, `${child.pid}\n`);
|
|
391
|
-
|
|
557
|
+
// `verified: false` is a process that is alive but had not answered by the
|
|
558
|
+
// deadline — a slow registry fetch on a slow link, most often. Reported as
|
|
559
|
+
// what it is rather than rounded up to success or down to failure: killing a
|
|
560
|
+
// bridge that was merely still waking up would be the worse mistake.
|
|
561
|
+
return { started: true, pid: child.pid, alreadyRunning: false, verified, logPath: log };
|
|
392
562
|
}
|
|
393
563
|
|
|
394
564
|
export async function stopDaemon(path = pidfilePath()) {
|
package/src/dns.mjs
CHANGED
|
@@ -2892,11 +2892,43 @@ export async function dnsCommand(args = [], out = console.log, deps = {}) {
|
|
|
2892
2892
|
// too rather than pinning the answer to one family.
|
|
2893
2893
|
proxy: proxyAddress ? (proxyAddress.v4 || proxyAddress.v6) : null,
|
|
2894
2894
|
});
|
|
2895
|
+
// The routing this is about to install is catch-all — every lookup on the
|
|
2896
|
+
// machine, not just Moshpit ones — so a bridge that did not come up is not
|
|
2897
|
+
// a degraded feature, it is the machine's resolver pointed at nothing.
|
|
2898
|
+
// Refused here, before the drop-in is written, because the alternative was
|
|
2899
|
+
// discovering it from a box that could no longer resolve its own package
|
|
2900
|
+
// mirror. Nothing has been changed at this point except the restore point,
|
|
2901
|
+
// which is removed on the way out.
|
|
2902
|
+
if (!started.reused && !started.alreadyRunning && !started.started) {
|
|
2903
|
+
out(` FAIL bridge did not start on ${DEFAULT_HOST}:${wanted} — ${started.error}`);
|
|
2904
|
+
if (started.log) {
|
|
2905
|
+
out("");
|
|
2906
|
+
for (const line of started.log.split("\n")) out(` ${line}`);
|
|
2907
|
+
}
|
|
2908
|
+
out("");
|
|
2909
|
+
out("Refusing to route this machine's DNS at a bridge that is not running.");
|
|
2910
|
+
out("Nothing has been changed.");
|
|
2911
|
+
if (started.logPath) out(` the daemon's output is at ${started.logPath}`);
|
|
2912
|
+
out(` to watch it start in the foreground: moshcode dns start --port ${wanted}`);
|
|
2913
|
+
if (recorded2.ok) await applyPlan({ steps: [{ kind: "remove", path: manifestFile, why: "the switch never happened" }] });
|
|
2914
|
+
return 1;
|
|
2915
|
+
}
|
|
2895
2916
|
out(started.reused
|
|
2896
2917
|
? ` ok using the bridge already on ${DEFAULT_HOST}:${wanted} (pid ${reusing.pid || "?"}) — not starting a second one`
|
|
2897
2918
|
: started.alreadyRunning
|
|
2898
2919
|
? ` ok bridge already running (pid ${started.pid})`
|
|
2899
|
-
:
|
|
2920
|
+
: started.verified === true
|
|
2921
|
+
? ` ok bridge started on ${DEFAULT_HOST}:${wanted} (pid ${started.pid}) — answering`
|
|
2922
|
+
: ` ok bridge started on ${DEFAULT_HOST}:${wanted} (pid ${started.pid})`);
|
|
2923
|
+
// Alive, but it had not answered a query by the deadline. Said out loud
|
|
2924
|
+
// rather than swallowed: if the routing below fails to verify, this line is
|
|
2925
|
+
// the reason, and it is cheaper to read it here than to derive it later.
|
|
2926
|
+
// Strictly `false`, never merely absent: a starter that does not report on
|
|
2927
|
+
// verification has not failed it, and rounding the two together would print
|
|
2928
|
+
// a warning about every bridge that was started by something else.
|
|
2929
|
+
if (started.started && started.verified === false) {
|
|
2930
|
+
out(` -- it has not answered a query yet — still starting, or it will not serve`);
|
|
2931
|
+
}
|
|
2900
2932
|
|
|
2901
2933
|
const outcome = await applyWith(plan, {
|
|
2902
2934
|
verify: () => verify({ moshpit: moshpitProbe }),
|
package/src/help.mjs
CHANGED
|
@@ -449,8 +449,21 @@ export function renderPitCommand(name) {
|
|
|
449
449
|
}
|
|
450
450
|
}
|
|
451
451
|
const out = [`/${entry.name} — ${entry.description}`];
|
|
452
|
-
|
|
452
|
+
// A pit-only verb may write its own synopsis/examples/note, the same shapes
|
|
453
|
+
// renderCommand reads. Without them the args string is the whole usage, which
|
|
454
|
+
// is enough for `/quit` and not enough for anything with sub-verbs.
|
|
455
|
+
const synopsis = entry.synopsis || (entry.args ? [[`/${entry.name} ${entry.args}`, ""]] : []);
|
|
456
|
+
if (synopsis.length) {
|
|
457
|
+
out.push("", "usage:");
|
|
458
|
+
for (const [line, note] of synopsis) out.push(row(line, note, 44));
|
|
459
|
+
}
|
|
453
460
|
if (entry.aliases?.length) out.push("", `aliases: ${entry.aliases.map((a) => `/${a}`).join(", ")}`);
|
|
461
|
+
const examples = entry.examples || [];
|
|
462
|
+
if (examples.length) {
|
|
463
|
+
out.push("", "examples:");
|
|
464
|
+
for (const [line, note] of examples) out.push(row(line, note ? `# ${note}` : "", 44));
|
|
465
|
+
}
|
|
466
|
+
if (entry.note) out.push("", wrap(entry.note, 0));
|
|
454
467
|
return out.join("\n");
|
|
455
468
|
}
|
|
456
469
|
|