@ghostlytools/cli 1.2.2 → 1.2.3

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/dist/ghostly.mjs CHANGED
@@ -1,18 +1,18 @@
1
1
  #!/usr/bin/env node
2
2
  import { t as __exportAll } from "./assets/rolldown-runtime-8H4AJuhK.js";
3
- import { $ as groupName, $a as APP_PREFIXES, $c as sanitizeAvatar, $n as readGroupWantNo, At as checkDidSignature, Ba as RUN_RESULTS, Bo as signalHasVideo, Bt as parseDid, Ci as recordsFromWellKnown, Cn as pubkyProofFile, Ct as AtprotoProofError, Dn as requestLnurlInvoice, Dr as ChatFiles, Ei as wellKnownUrl, En as pubkyHomeserverOf, Et as DID_FILE_MAX_BYTES, Fa as replySnippet, Fo as buildSdpFromSignal, Ft as didWebFile, Ga as checkStatusCard, Gc as openRelayPayload, Gt as strictBase64Url, Ha as buttonLabelClash, Hc as RELAY_PAYLOAD_MAX_BYTES, Hn as engineText, Ht as parseDidSignaturePaste, Ic as setLinkTraceSink, Ii as boundVerifiedIdentity, Io as callRtcConfig, It as didWebFileNames, Jn as groupFileFallback, Kn as GROUP_WANT_NO_FRAME, La as ITEM_STATES, Lo as callSignalHeardAt, Lt as didWebFileUrl, Mo as CALL_SIGNAL_MAX_AGE_MS, Mr as TYPING_REFRESH_MS, Mt as didJwsAlg, No as RTC_CONFIG, Nt as didSignedMessages, On as resolveLightningDestination, Ot as DidError, Pi as IdentityCheckUnavailable, Po as answersOffer, Pr as sanitizeTypingStatus, Pt as didWebDocumentUrl, Qc as MAX_AVATAR_LENGTH, Qn as readGroupWant, Ra as PR_CHECKS, Ro as extractParamsFromSdp, Rt as didWebHost, Si as recordsFromTxt, Sn as pubkyProofCapability, St as shortAtprotoDid, Ti as txtValue, Tn as homeserverWebEndpoint, To as GhostlyHttpError, Tt as DID_DOCUMENT_MAX_BYTES$1, Ua as cardLine, Un as GROUP_FILE_LIMITS, Ut as signingKeys, Va as STATUS_CARD_LIMITS, Vn as engineError, Vt as parseDidDocument, Wa as cardUrl, Wn as GROUP_HAVE_FRAME, Wt as staticDidDocument, Xi as irohRelayUrl, Xr as checkPushEndpoint, Xt as resolveDidDht, Yi as irohDescriptor, Yn as isGroupMessageId, Zn as readGroupHave, _ as checkAppManifest, _a as RateWindow, _i as domainWellKnownFile, _n as PUBKY_PROOF_MAX_BYTES, _t as atprotoProofRkey, ac as inviteLink, al as utf8Decode, at as sameSshKey, bi as nip05RootKey, bn as newPubkyProofFolder, bt as normalizeAtprotoHandle, c as readAppStore, ca as PLAYABLE_AUDIO, co as signAppObject, ct as verifyBitcoinMessage, d as APP_BUNDLE_LIMITS, da as fileMessageText, dn as walletNetworkOf, do as canonicalJsonBytes, dt as ATPROTO_DECLARED_SCOPE, eo as APP_STATEMENT_LIMITS, fa as parseVoiceMeta, ft as ATPROTO_FULL_SCOPE, g as assembleAppBundle, gc as seedSigner, gi as domainTxtRecord, gt as atprotoProofRecord, hi as dnsProofName, ht as atprotoDidMethod, il as toZ32, it as parseSshSignature, jr as TYPING_KINDS, jt as didGhostlyServiceEntry, kn as decodeBolt11, ko as LIMITS, kr as transferEnded, kt as checkDidJws, la as VOICE_LIMITS, lo as signAppRevocation, lt as BitcoinAddressError, mi as decodeDnsResponse, mt as ATPROTO_PROOF_SCOPE, n as appStoreDecision, nl as randomBytes$1, no as appRef, nt as SshSigError, oa as sanitizeFileName, ol as utf8Encode, on as WALLET_NETWORKS, ot as sshSignCommand, pa as voicePeaksOf, pi as DOMAIN_FILE_MAX_BYTES, pt as ATPROTO_PROOF_COLLECTION, qn as fetchesByItself, ra as readImageMeta, rl as toBase64Url, ro as checkAppRevokeStatement, rt as parseSshPublicKey, s as checkAppStoreIndex, sa as sanitizeMime, so as readAppRevocations, st as verifySshSignature, ti as BUTTONS_CAPABILITY, tl as fromBase64Url$1, to as appFingerprint, tt as SSHSIG_NAMESPACE, u as signAppStore, ua as baseMime, uo as canonicalJson, ut as decodeAnyBitcoinAddress, v as isAppUrl, vi as encodeDnsQuery, vn as isPubkyKey, vt as checkAtprotoProofRecord, wi as recordsName, wn as pubkyProofPath, wt as verifyAtprotoRecordProof, xi as normalizeDomain, xn as normalizePubkyKey, xt as parseAtprotoDidDocument, y as readAppBundle, yi as isPublicAddress, yn as isPubkyProofFolder, yt as isAtprotoDid, za as PR_STATES, zi as identityStatement, zo as parseCallSignal, zt as normalizeDid } from "./assets/src-XzcHzneo.js";
3
+ import { $n as readGroupHave, An as resolveLightningDestination, At as DidError, Ba as PR_CHECKS, Bo as extractParamsFromSdp, Bt as didWebHost, Ci as normalizeDomain, Cn as normalizePubkyKey, Ct as parseAtprotoDidDocument, Di as txtValue, Dn as homeserverWebEndpoint, Do as GhostlyHttpError, Dt as DID_DOCUMENT_MAX_BYTES$1, Ei as recordsName, En as pubkyProofPath, Et as verifyAtprotoRecordProof, Fo as RTC_CONFIG, Ft as didSignedMessages, Ga as cardLine, Gn as GROUP_FILE_LIMITS, Gt as signingKeys, Ha as RUN_RESULTS, Ho as signalHasVideo, Ht as parseDid, Ii as IdentityCheckUnavailable, Io as answersOffer, Ir as sanitizeTypingStatus, It as didWebDocumentUrl, Jn as GROUP_WANT_NO_FRAME, Ka as cardUrl, Kn as GROUP_HAVE_FRAME, Kt as staticDidDocument, La as replySnippet, Lo as buildSdpFromSignal, Lt as didWebFile, Mt as checkDidSignature, Nr as TYPING_KINDS, Nt as didGhostlyServiceEntry, Oi as wellKnownUrl, On as pubkyHomeserverOf, Ot as DID_FILE_MAX_BYTES, Po as CALL_SIGNAL_MAX_AGE_MS, Pr as TYPING_REFRESH_MS, Pt as didJwsAlg, Qi as irohRelayUrl, Qr as checkPushEndpoint, Qt as resolveDidDht, Ri as boundVerifiedIdentity, Ro as callRtcConfig, Rt as didWebFileNames, Si as nip05RootKey, Sn as newPubkyProofFolder, St as normalizeAtprotoHandle, Ti as recordsFromWellKnown, Tn as pubkyProofFile, Tt as AtprotoProofError, Ua as STATUS_CARD_LIMITS, Un as engineError, Ut as parseDidDocument, Va as PR_STATES, Vc as setLinkTraceSink, Vi as identityStatement, Vo as parseCallSignal, Vt as normalizeDid, Wa as buttonLabelClash, Wn as engineText, Wt as parseDidSignaturePaste, Xc as openRelayPayload, Xn as groupFileFallback, Yn as fetchesByItself, Zi as irohDescriptor, Zn as isGroupMessageId, _ as checkAppManifest, _i as dnsProofName, _t as atprotoDidMethod, aa as readImageMeta, ao as checkAppRevokeStatement, at as parseSshPublicKey, bi as encodeDnsQuery, bn as isPubkyKey, bt as checkAtprotoProofRecord, c as readAppStore, ca as sanitizeFileName, cl as toBase64Url, cn as WALLET_NETWORKS, ct as sshSignCommand, d as APP_BUNDLE_LIMITS, da as VOICE_LIMITS, dl as utf8Encode, do as signAppRevocation, dt as BitcoinAddressError, er as readGroupWant, fa as baseMime, fo as canonicalJson, ft as decodeAnyBitcoinAddress, g as assembleAppBundle, gi as decodeDnsResponse, gt as ATPROTO_PROOF_SCOPE, ha as voicePeaksOf, hi as DOMAIN_FILE_MAX_BYTES, ht as ATPROTO_PROOF_COLLECTION, il as sanitizeAvatar, io as appRef, it as SshSigError, jn as decodeBolt11, jo as LIMITS, jr as transferEnded, jt as checkDidJws, kn as requestLnurlInvoice, kr as ChatFiles, la as sanitizeMime, ll as toZ32, lo as readAppRevocations, lt as verifySshSignature, ma as parseVoiceMeta, mt as ATPROTO_FULL_SCOPE, n as appStoreDecision, no as APP_STATEMENT_LIMITS, ol as fromBase64Url$1, ot as parseSshSignature, pa as fileMessageText, pn as walletNetworkOf, po as canonicalJsonBytes, pt as ATPROTO_DECLARED_SCOPE, qa as checkStatusCard, qc as RELAY_PAYLOAD_MAX_BYTES, qt as strictBase64Url, ri as BUTTONS_CAPABILITY, rl as MAX_AVATAR_LENGTH, ro as appFingerprint, rt as SSHSIG_NAMESPACE, s as checkAppStoreIndex, sc as inviteLink, sl as randomBytes$1, st as sameSshKey, to as APP_PREFIXES, tr as readGroupWantNo, tt as groupName, u as signAppStore, ua as PLAYABLE_AUDIO, ul as utf8Decode, uo as signAppObject, ut as verifyBitcoinMessage, v as isAppUrl, vc as seedSigner, vi as domainTxtRecord, vt as atprotoProofRecord, wi as recordsFromTxt, wn as pubkyProofCapability, wt as shortAtprotoDid, xi as isPublicAddress, xn as isPubkyProofFolder, xt as isAtprotoDid, y as readAppBundle, ya as RateWindow, yi as domainWellKnownFile, yn as PUBKY_PROOF_MAX_BYTES, yt as atprotoProofRkey, za as ITEM_STATES, zo as callSignalHeardAt, zt as didWebFileUrl } from "./assets/src-_FzJkeAJ.js";
4
4
  import { c as fileStore, f as store$1, i as STORES, m as wrap, o as databaseName, p as transact, u as openDb } from "./assets/idb-CTF6LF5B.js";
5
5
  import { a as fileBytesOf, i as fileBytes, l as FILE_BYTES_STEP } from "./assets/fileBytes-BcYZhNgF.js";
6
6
  import { createRequire } from "node:module";
7
7
  import { spawn } from "node:child_process";
8
8
  import { connect, createServer, isIP } from "node:net";
9
- import { appendFileSync, chmodSync, closeSync, existsSync, fsyncSync, lstatSync, mkdirSync, openAsBlob, openSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync, writeSync } from "node:fs";
9
+ import { accessSync, appendFileSync, chmodSync, closeSync, constants, existsSync, fsyncSync, lstatSync, mkdirSync, openAsBlob, openSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync, writeSync } from "node:fs";
10
10
  import { ed25519 } from "@noble/curves/ed25519.js";
11
11
  import { sha256 } from "@noble/hashes/sha2.js";
12
12
  import { createBase58check } from "@scure/base";
13
13
  import { schnorr } from "@noble/curves/secp256k1.js";
14
14
  import { wordlist } from "@scure/bip39/wordlists/english.js";
15
- import { access, constants, link, mkdir, open, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
15
+ import { access, constants as constants$1, link, mkdir, open, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
16
16
  import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
17
17
  import { createHash, createPrivateKey, generateKeyPairSync, randomBytes, timingSafeEqual } from "node:crypto";
18
18
  import { decode, npubEncode } from "nostr-tools/nip19";
@@ -35,7 +35,7 @@ import { IDBFactory, IDBKeyRange as IDBKeyRange$1, IDBTransaction } from "fake-i
35
35
  import { format } from "node:util";
36
36
  var package_default = {
37
37
  name: "@ghostlytools/cli",
38
- version: "1.2.2",
38
+ version: "1.2.3",
39
39
  type: "module",
40
40
  description: "Ghostly for bots and terminals: the app's own engine, headless on Node, behind a daemon and a `ghostly` command with JSON output.",
41
41
  license: "MIT",
@@ -1323,7 +1323,7 @@ var GroupFileDesk = class {
1323
1323
  }
1324
1324
  /**
1325
1325
  * An error when this group took as many announcements of mine as it takes in a minute, or as its members take from
1326
- * one member in `announceWindowMs` (past that they would get the text, not the file); null when one may go now.
1326
+ * one member in `announceWindowMs` (past that they would not fetch the file by themselves); null when one may go now.
1327
1327
  */
1328
1328
  mayAnnounce(groupId) {
1329
1329
  const wait = Math.max(...this.sentPaces(groupId).map((pace) => pace.wait()));
@@ -1340,9 +1340,9 @@ var GroupFileDesk = class {
1340
1340
  this.own.delete(`${groupId}\n${meta.d}`);
1341
1341
  }
1342
1342
  /**
1343
- * A message of the group announces a file (mine, or a member's): the file its row shows, or none when the member
1344
- * announced more than the group takes (its text shows). A member's is kept here unfetched, and fetched now when it is
1345
- * one this device takes by itself. The same message again (a catch-up, a whole copy) is the same file.
1343
+ * A message of the group announces a file (mine, or a member's): the file its row shows. A member's is kept here
1344
+ * unfetched, and fetched now when it is one this device takes by itself and the member announced no more than the
1345
+ * group fetches that way. The same message again (a catch-up, a whole copy) is the same file.
1346
1346
  */
1347
1347
  async announced(groupId, m, me) {
1348
1348
  const known = this.byMessage.get(`${groupId}\n${m.id}`);
@@ -1384,9 +1384,9 @@ var GroupFileDesk = class {
1384
1384
  this.queueHave(kept);
1385
1385
  return messageFile(kept);
1386
1386
  }
1387
- if (!this.pace(this.takenPace, `${groupId}\n${m.sender}`, GROUP_FILE_LIMITS.announceTaken, GROUP_FILE_LIMITS.announceWindowMs).take()) return void 0;
1387
+ const paced = this.pace(this.takenPace, `${groupId}\n${m.sender}`, GROUP_FILE_LIMITS.announceTaken, GROUP_FILE_LIMITS.announceWindowMs).take();
1388
1388
  const id = groupFileId(groupId, "in");
1389
- const auto = this.deps.settings().autoDownloads && fetchesByItself(m.file, this.keptAutomatically(groupId));
1389
+ const auto = paced && this.deps.settings().autoDownloads && fetchesByItself(m.file, this.keptAutomatically(groupId));
1390
1390
  const holders = this.ahead.get(`${groupId}\n${m.id}`) ?? [];
1391
1391
  this.ahead.delete(`${groupId}\n${m.id}`);
1392
1392
  const kept = {
@@ -2700,7 +2700,7 @@ var FILE_METHODS = {
2700
2700
  throw new CliError("not_found", `No file ${path}`);
2701
2701
  }
2702
2702
  if (!info.isFile()) throw new CliError("bad_request", `${path} is not a file`);
2703
- await access(path, constants.R_OK);
2703
+ await access(path, constants$1.R_OK);
2704
2704
  if ("group" in target) return sendToGroup(ctx, params, target.group, path, info.size);
2705
2705
  const { link } = target;
2706
2706
  if (link.profile && link.dataLink === "open" && !link.capabilities?.files) throw new CliError("unavailable", "The contact's app takes no files");
@@ -6172,7 +6172,8 @@ async function verifyIdToken(token, options) {
6172
6172
  }
6173
6173
  //#endregion
6174
6174
  //#region ../browser/src/proofs/oidc/flow.ts
6175
- var STATE = /^(?:d\.\d{1,5}\.)?[A-Za-z0-9_-]{43}$/;
6175
+ /** Optional platform prefix (desktop `d.<port>.`, Android deep link `a.`), then 32 random bytes. */
6176
+ var STATE = /^(?:d\.\d{1,5}\.|a\.)?[A-Za-z0-9_-]{43}$/;
6176
6177
  function scopeFor$1(provider, reveal) {
6177
6178
  const scopes = provider.id === "apple" ? [] : ["openid"];
6178
6179
  const claims = [];
@@ -8727,13 +8728,15 @@ var IDENTITY_METHODS = {
8727
8728
  },
8728
8729
  async "identity.cancel"(ctx, params) {
8729
8730
  const draftId = str(params, "draft", true);
8730
- drafts.delete(draftId);
8731
+ if (!drafts.delete(draftId)) throw new CliError("not_found", "No such draft here: it is kept by the daemon that made it, for an hour");
8731
8732
  await node(ctx).cancelIdentityProof({ draftId });
8732
8733
  return { cancelled: draftId };
8733
8734
  },
8734
8735
  async "identity.remove"(ctx, params) {
8735
- await node(ctx).removeIdentityProof({ id: str(params, "id", true) });
8736
- return { removed: str(params, "id", true) };
8736
+ const id = str(params, "id", true);
8737
+ if (!state(ctx).identityProofs.some((p) => p.id === id)) throw new CliError("not_found", `No identity proof ${JSON.stringify(id)} (ghostly identity list)`);
8738
+ await node(ctx).removeIdentityProof({ id });
8739
+ return { removed: id };
8737
8740
  },
8738
8741
  async "identity.share"(ctx, params) {
8739
8742
  const link = chatOf(ctx, params);
@@ -8748,13 +8751,15 @@ var IDENTITY_METHODS = {
8748
8751
  },
8749
8752
  async "identity.withdraw"(ctx, params) {
8750
8753
  const link = chatOf(ctx, params);
8754
+ const id = str(params, "id", true);
8755
+ if (!link.identities?.shared.some((s) => s.id === id)) throw new CliError("not_found", `No identity proof ${JSON.stringify(id)} shared in this chat (ghostly identity contact ${link.id})`);
8751
8756
  await node(ctx).withdrawIdentityProof({
8752
8757
  linkId: link.id,
8753
- id: str(params, "id", true)
8758
+ id
8754
8759
  });
8755
8760
  return {
8756
8761
  chat: link.id,
8757
- withdrawn: str(params, "id", true)
8762
+ withdrawn: id
8758
8763
  };
8759
8764
  },
8760
8765
  /** What a contact shared in a chat, as this device checked it, and what this side shares there. */
@@ -8990,6 +8995,15 @@ async function proxy(request, response, chunks, send, cookies) {
8990
8995
  plain(response, gone ? 503 : 502, gone ? "This service is not reachable. Services exist while their ghost is online." : `The request failed: ${error instanceof Error ? error.message : String(error)}`);
8991
8996
  }
8992
8997
  }
8998
+ /**
8999
+ * The service a command names, by its id: one of this profile's. The engine changes nothing for an id it does not
9000
+ * know and says nothing, so a mistyped id read as done ("shared": true, with a contact).
9001
+ */
9002
+ function serviceOf(ctx, params) {
9003
+ const serviceId = str(params, "service", true);
9004
+ if (!state(ctx).services.some((s) => s.id === serviceId)) throw new CliError("not_found", `No service ${JSON.stringify(serviceId)} (ghostly service list)`);
9005
+ return serviceId;
9006
+ }
8993
9007
  var SERVICE_METHODS = {
8994
9008
  async "service.list"(ctx) {
8995
9009
  return { services: state(ctx).services.map((s) => ({
@@ -9016,29 +9030,32 @@ var SERVICE_METHODS = {
9016
9030
  });
9017
9031
  },
9018
9032
  async "service.remove"(ctx, params) {
9019
- await node(ctx).removeService({ serviceId: str(params, "service", true) });
9020
- return { removed: str(params, "service", true) };
9033
+ const serviceId = serviceOf(ctx, params);
9034
+ await node(ctx).removeService({ serviceId });
9035
+ return { removed: serviceId };
9021
9036
  },
9022
9037
  async "service.enable"(ctx, params) {
9038
+ const serviceId = serviceOf(ctx, params);
9023
9039
  await node(ctx).setServiceEnabled({
9024
- serviceId: str(params, "service", true),
9040
+ serviceId,
9025
9041
  enabled: !bool(params, "off")
9026
9042
  });
9027
9043
  return {
9028
- service: str(params, "service", true),
9044
+ service: serviceId,
9029
9045
  enabled: !bool(params, "off")
9030
9046
  };
9031
9047
  },
9032
9048
  async "service.share"(ctx, params) {
9033
9049
  const link = chatOf(ctx, params);
9034
9050
  const shared = !bool(params, "off");
9051
+ const serviceId = serviceOf(ctx, params);
9035
9052
  await node(ctx).setServiceShared({
9036
- serviceId: str(params, "service", true),
9053
+ serviceId,
9037
9054
  peerPubKeyZ32: link.peerPubKeyZ32,
9038
9055
  shared
9039
9056
  });
9040
9057
  return {
9041
- service: str(params, "service", true),
9058
+ service: serviceId,
9042
9059
  chat: link.id,
9043
9060
  shared
9044
9061
  };
@@ -13082,13 +13099,15 @@ var METHODS = {
13082
13099
  },
13083
13100
  async "chat.retry"(ctx, params) {
13084
13101
  const link = chatOf(ctx, params);
13102
+ const messageId = str(params, "message", true);
13103
+ if (!await node(ctx).getMessage(link.id, messageId)) throw new CliError("not_found", `No message ${messageId} in this chat`);
13085
13104
  await node(ctx).retryMessage({
13086
13105
  linkId: link.id,
13087
- messageId: str(params, "message", true)
13106
+ messageId
13088
13107
  });
13089
13108
  return {
13090
13109
  chat: link.id,
13091
- messageId: params.message
13110
+ messageId
13092
13111
  };
13093
13112
  },
13094
13113
  async "chat.delete"(ctx, params) {
@@ -17350,6 +17369,14 @@ var refuseAnswers = Number(process.env.GHOSTLY_CALL_REFUSE_ANSWERS) || 0;
17350
17369
  var GATHER_MS = 5e3;
17351
17370
  var SETTLE_MS = 400;
17352
17371
  /**
17372
+ * With a candidate in (a host one comes in milliseconds), the server reflexive one is waited for this long at most,
17373
+ * not the whole of `GATHER_MS`: a STUN server that answers does in tens of milliseconds, and one that never does (UDP
17374
+ * to it blocked, as on many office, school and hotel networks) made every call ring 5 s late and connect 5 s after
17375
+ * the answer. The apps' calls and the chats' data link bound theirs the same way (`HOST_GATHER_MS`,
17376
+ * `OFFER_HOST_GATHER_MS`).
17377
+ */
17378
+ var HOST_GATHER_MS = 2e3;
17379
+ /**
17353
17380
  * One call's peer connection. Its SDP never travels: the compact signal does (`local`), and each side rebuilds the
17354
17381
  * other's SDP from it (`buildSdpFromSignal`), so the payload types, SSRC and mids here are the ones that rebuilt SDP
17355
17382
  * uses: audio is mid 0, Opus is 111 unless the contact's offer says otherwise.
@@ -17421,7 +17448,7 @@ var CallMedia = class CallMedia {
17421
17448
  const track = pc.addTrack(audioSection);
17422
17449
  let videoTrack = null;
17423
17450
  if (offer?.m?.includes("v")) {
17424
- const video = new ndc.Video(String(offer.m.indexOf("v")), "RecvOnly");
17451
+ const video = new ndc.Video(String(offer.m.indexOf("v")), "Inactive");
17425
17452
  video.addVP8Codec(offer.vp ?? 96);
17426
17453
  videoTrack = pc.addTrack(video);
17427
17454
  videoTrack.onMessage(() => {});
@@ -17440,6 +17467,7 @@ var CallMedia = class CallMedia {
17440
17467
  ss: [ssrc]
17441
17468
  };
17442
17469
  if (offer?.m) local.m = [...offer.m];
17470
+ if (videoTrack) local.vr = 0;
17443
17471
  const listeners = [];
17444
17472
  track.onMessage((packet) => {
17445
17473
  for (const listener of listeners) listener(packet);
@@ -17520,20 +17548,26 @@ function mdnsNote(signal, log) {
17520
17548
  lookup(host, { all: true }).then((found) => log(`${host} resolves here to ${found.map((a) => a.address).join(", ")}`), () => log(`${host} does not resolve here: the call connects only if the contact's checks reach this side`));
17521
17549
  }
17522
17550
  }
17523
- /** Resolves once the local description has its candidates (see GATHER_MS). */
17551
+ /** Resolves once the local description has its candidates (see GATHER_MS and HOST_GATHER_MS). */
17524
17552
  function gathering(pc) {
17525
17553
  return new Promise((resolve) => {
17526
17554
  let settle = null;
17555
+ let any = false;
17527
17556
  const done = () => {
17528
17557
  clearTimeout(cap);
17558
+ clearTimeout(bounded);
17529
17559
  if (settle) clearTimeout(settle);
17530
17560
  resolve();
17531
17561
  };
17532
17562
  const cap = setTimeout(done, GATHER_MS);
17563
+ const bounded = setTimeout(() => {
17564
+ if (any) done();
17565
+ }, HOST_GATHER_MS);
17533
17566
  pc.onGatheringStateChange((state) => {
17534
17567
  if (state === "complete") done();
17535
17568
  });
17536
17569
  pc.onLocalCandidate((candidate) => {
17570
+ any = true;
17537
17571
  if (!settle && / typ srflx/.test(candidate)) settle = setTimeout(done, SETTLE_MS);
17538
17572
  });
17539
17573
  });
@@ -18189,23 +18223,15 @@ var EventHub = class {
18189
18223
  request.onsuccess = () => resolve(request.result);
18190
18224
  request.onerror = () => reject(request.error);
18191
18225
  });
18192
- const rows = await new Promise((resolve, reject) => {
18193
- const out = [];
18194
- const request = this.db.transaction(SEEN).objectStore(SEEN).openCursor();
18195
- request.onsuccess = () => {
18196
- const cursor = request.result;
18197
- if (!cursor) return resolve(out);
18198
- out.push({
18199
- key: cursor.key,
18200
- value: cursor.value
18201
- });
18202
- cursor.continue();
18203
- };
18226
+ const store = this.db.transaction(SEEN).objectStore(SEEN);
18227
+ const all = (request) => new Promise((resolve, reject) => {
18228
+ request.onsuccess = () => resolve(request.result);
18204
18229
  request.onerror = () => reject(request.error);
18205
18230
  });
18206
- for (const { key, value } of rows) {
18207
- const [chat, id] = key;
18208
- this.seenOf(chat).set(id, value);
18231
+ const [keys, values] = await Promise.all([all(store.getAllKeys()), all(store.getAll())]);
18232
+ for (let i = 0; i < keys.length; i++) {
18233
+ const [chat, id] = keys[i];
18234
+ this.seenOf(chat).set(id, values[i]);
18209
18235
  }
18210
18236
  this.mentionsNoted = this.firstRun || this.seen.get(FORMAT)?.get(MENTIONS_NOTED) === "yes";
18211
18237
  }
@@ -18691,8 +18717,13 @@ var EventHub = class {
18691
18717
  }
18692
18718
  if (changes.length) this.persist(key, changes);
18693
18719
  }
18720
+ /**
18721
+ * Relaxed: nobody waits for it, and a note lost to a power cut is made again from the history at the next start
18722
+ * (its event is then reported a second time, as after a crash between a message's row and its note). A disk sync
18723
+ * for each one held the whole daemon for milliseconds, three times per message sent.
18724
+ */
18694
18725
  persist(chat, changes) {
18695
- const store = this.db.transaction(SEEN, "readwrite").objectStore(SEEN);
18726
+ const store = this.db.transaction(SEEN, "readwrite", { durability: "relaxed" }).objectStore(SEEN);
18696
18727
  for (const [id, state] of changes) if (state === null) store.delete([chat, id]);
18697
18728
  else store.put(state, [chat, id]);
18698
18729
  }
@@ -19067,7 +19098,7 @@ function federationInfo(federationId, config) {
19067
19098
  };
19068
19099
  }
19069
19100
  /** The first value a subscription gives that is final, or the last one within `waitMs`. */
19070
- function watch(subscribe, final, waitMs) {
19101
+ function watch$1(subscribe, final, waitMs) {
19071
19102
  return new Promise((resolve) => {
19072
19103
  let last, done = false, cancel = () => {};
19073
19104
  const finish = () => {
@@ -19138,13 +19169,13 @@ var RealClient = class {
19138
19169
  return this.wallet.mint.tryCancelSpendNotes(operationId);
19139
19170
  }
19140
19171
  spendState(operationId, waitMs) {
19141
- return watch((ok, fail) => this.wallet.mint.subscribeSpendNotes(operationId, (s) => ok(SPEND[s] ?? "created"), fail), spendFinal, waitMs);
19172
+ return watch$1((ok, fail) => this.wallet.mint.subscribeSpendNotes(operationId, (s) => ok(SPEND[s] ?? "created"), fail), spendFinal, waitMs);
19142
19173
  }
19143
19174
  redeem(notes, ghostly) {
19144
19175
  return this.wallet.mint.reissueExternalNotes(notes, { ghostly });
19145
19176
  }
19146
19177
  redeemState(operationId, waitMs) {
19147
- return watch((ok, fail) => this.wallet.mint.subscribeReissueExternalNotes(operationId, (s) => ok(typeof s === "string" ? REDEEM[s] ?? "created" : s && typeof s === "object" && "Failed" in s ? "failed" : "created"), (error) => {
19178
+ return watch$1((ok, fail) => this.wallet.mint.subscribeReissueExternalNotes(operationId, (s) => ok(typeof s === "string" ? REDEEM[s] ?? "created" : s && typeof s === "object" && "Failed" in s ? "failed" : "created"), (error) => {
19148
19179
  ok("failed");
19149
19180
  fail(error);
19150
19181
  }), (s) => s === "done" || s === "failed", waitMs);
@@ -19157,7 +19188,7 @@ var RealClient = class {
19157
19188
  };
19158
19189
  }
19159
19190
  receiveState(operationId, waitMs) {
19160
- return watch((ok, fail) => this.wallet.lightning.subscribeLnReceive(operationId, (s) => ok(receiveOf(s)), fail), (s) => s === "claimed" || s === "canceled", waitMs);
19191
+ return watch$1((ok, fail) => this.wallet.lightning.subscribeLnReceive(operationId, (s) => ok(receiveOf(s)), fail), (s) => s === "claimed" || s === "canceled", waitMs);
19161
19192
  }
19162
19193
  async gatewayFee(amountMsats) {
19163
19194
  await this.wallet.lightning.updateGatewayCache().catch(() => {});
@@ -19175,7 +19206,7 @@ var RealClient = class {
19175
19206
  };
19176
19207
  }
19177
19208
  async payState(operationId, internal, waitMs) {
19178
- const state = await watch((ok, fail) => internal ? this.wallet.lightning.subscribeInternalPayment(operationId, ok, fail) : this.wallet.lightning.subscribeLnPay(operationId, ok, fail), (s) => typeof s === "object" && !!s && ("success" in s || "preimage" in s || "refunded" in s || "refund_success" in s || "unexpected_error" in s || "funding_failed" in s) || s === "canceled", waitMs);
19209
+ const state = await watch$1((ok, fail) => internal ? this.wallet.lightning.subscribeInternalPayment(operationId, ok, fail) : this.wallet.lightning.subscribeLnPay(operationId, ok, fail), (s) => typeof s === "object" && !!s && ("success" in s || "preimage" in s || "refunded" in s || "refund_success" in s || "unexpected_error" in s || "funding_failed" in s) || s === "canceled", waitMs);
19179
19210
  if (state && typeof state === "object") {
19180
19211
  const s = state;
19181
19212
  if (s.success?.preimage || s.preimage) return {
@@ -19510,6 +19541,10 @@ var SNAPSHOT = "snapshot.bin";
19510
19541
  var JOURNAL = "journal.bin";
19511
19542
  /** A journal past this is folded into the snapshot at the next quiet moment. */
19512
19543
  var COMPACT_BYTES = 33554432;
19544
+ /** How long `close` waits for the transactions still running: one left open for good does not keep the process. */
19545
+ var CLOSE_WAIT_MS = 5e3;
19546
+ /** How long a relaxed transaction's entry may wait for a sync when no other transaction brings one. */
19547
+ var RELAXED_SYNC_MS = 1e3;
19513
19548
  var BLOB = "__ghostlyBlob";
19514
19549
  var RANGE = "__ghostlyRange";
19515
19550
  var PROBE = "__ghostly_storage_probe__";
@@ -19532,12 +19567,14 @@ async function openPersistentIndexedDb(dir) {
19532
19567
  let queue = Promise.resolve();
19533
19568
  let queued = 0;
19534
19569
  let schemaDirty = false;
19570
+ /** Set while the journal holds relaxed entries no sync has covered yet. */
19571
+ let relaxed = null;
19535
19572
  const pending = /* @__PURE__ */ new WeakMap();
19536
19573
  const snapshotPath = join(dir, SNAPSHOT);
19537
19574
  const journalPath = join(dir, JOURNAL);
19538
19575
  const snapshotFound = existsSync(snapshotPath), journalFound = existsSync(journalPath);
19539
- if (snapshotFound) restore(classes, raw._databases, deserialize(readFileSync(snapshotPath)), (a, b) => factory.cmp(a, b));
19540
- if (journalFound) replay(raw._databases, readFileSync(journalPath));
19576
+ if (snapshotFound) restore(classes, raw._databases, deserialize(readFileSync(snapshotPath)), keyOrder((a, b) => factory.cmp(a, b)));
19577
+ if (journalFound) replay(raw._databases, readFileSync(journalPath), keyOrder((a, b) => factory.cmp(a, b)));
19541
19578
  restoring = false;
19542
19579
  if (journalFound || !snapshotFound) await writeSnapshot();
19543
19580
  const storeProto = classes.ObjectStore.prototype;
@@ -19584,7 +19621,7 @@ async function openPersistentIndexedDb(dir) {
19584
19621
  if (this.mode === "versionchange") schemaDirty = true;
19585
19622
  else {
19586
19623
  const ops = pending.get(this._rollbackLog);
19587
- if (ops?.length) append(ops);
19624
+ if (ops?.length) append(ops, this.durability === "relaxed");
19588
19625
  }
19589
19626
  pending.delete(this._rollbackLog);
19590
19627
  if (schemaDirty) enqueue(writeSnapshot);
@@ -19606,34 +19643,54 @@ async function openPersistentIndexedDb(dir) {
19606
19643
  });
19607
19644
  return request;
19608
19645
  };
19609
- function append(ops) {
19646
+ function append(ops, lazy) {
19610
19647
  if (queued === 0 && !ops.some((op) => op.kind === "put" && hasBlob(op.value))) {
19611
- writeEntry(serialize(ops));
19648
+ writeEntry(serialize(ops), lazy);
19612
19649
  return;
19613
19650
  }
19614
19651
  enqueue(async () => writeEntry(serialize(await Promise.all(ops.map(async (op) => op.kind === "put" ? {
19615
19652
  ...op,
19616
19653
  value: await blobsToDisk(op.value)
19617
- } : op)))));
19654
+ } : op))), lazy));
19618
19655
  }
19619
19656
  function enqueue(work) {
19620
19657
  queued++;
19621
- queue = queue.then(work).catch((error) => {
19622
- process.stderr.write(`ghostly: storage write failed: ${error instanceof Error ? error.message : String(error)}\n`);
19623
- }).finally(() => {
19658
+ queue = queue.then(work).catch(failed).finally(() => {
19624
19659
  queued--;
19625
19660
  });
19626
19661
  return queue;
19627
19662
  }
19628
- function writeEntry(entry) {
19663
+ function failed(error) {
19664
+ process.stderr.write(`ghostly: storage write failed: ${error instanceof Error ? error.message : String(error)}\n`);
19665
+ }
19666
+ /**
19667
+ * `lazy`: a relaxed transaction's entry. A sync blocks the whole process for milliseconds, and what such a
19668
+ * transaction wrote can be made again (the event set's note of a message it already reported): written, it outlives
19669
+ * the process; a power cut may take its last second.
19670
+ */
19671
+ function writeEntry(entry, lazy = false) {
19629
19672
  if (!writing) return;
19630
19673
  journalFd ??= openSync(journalPath, "a", 384);
19631
19674
  const header = Buffer.alloc(4);
19632
19675
  header.writeUInt32BE(entry.length);
19633
19676
  writeSync(journalFd, Buffer.concat([header, entry]));
19634
- fsyncSync(journalFd);
19677
+ if (lazy) relaxed ??= setTimeout(() => {
19678
+ try {
19679
+ sync();
19680
+ } catch (error) {
19681
+ failed(error);
19682
+ }
19683
+ }, RELAXED_SYNC_MS).unref();
19684
+ else sync();
19635
19685
  if (statSync(journalPath).size > COMPACT_BYTES) enqueue(writeSnapshot);
19636
19686
  }
19687
+ function sync() {
19688
+ if (relaxed) {
19689
+ clearTimeout(relaxed);
19690
+ relaxed = null;
19691
+ }
19692
+ if (journalFd !== null) fsyncSync(journalFd);
19693
+ }
19637
19694
  async function writeSnapshot() {
19638
19695
  schemaDirty = false;
19639
19696
  const snapshot = {
@@ -19676,12 +19733,29 @@ async function openPersistentIndexedDb(dir) {
19676
19733
  closeSync(fd);
19677
19734
  }
19678
19735
  renameSync(temporary, snapshotPath);
19736
+ if (relaxed) {
19737
+ clearTimeout(relaxed);
19738
+ relaxed = null;
19739
+ }
19679
19740
  if (journalFd !== null) {
19680
19741
  closeSync(journalFd);
19681
19742
  journalFd = null;
19682
19743
  }
19683
19744
  rmSync(journalPath, { force: true });
19684
19745
  }
19746
+ /**
19747
+ * Resolves once no transaction is left to run. The engine does not wait for every write (`void db.deleteLink(…)`),
19748
+ * and fake-indexeddb runs a transaction on a later turn of the event loop: a command that closed in the turn it wrote
19749
+ * in (no chat to say goodbye to) took its last snapshot before the write, and answered done for what was not on disk.
19750
+ * Two quiet turns in a row: a write that begins when another ends (a chat's messages after its row) is seen too.
19751
+ */
19752
+ async function settled() {
19753
+ const until = Date.now() + CLOSE_WAIT_MS;
19754
+ for (let quiet = 0; quiet < 2 && Date.now() < until;) {
19755
+ await new Promise((resolve) => setImmediate(resolve));
19756
+ quiet = [...raw._databases.values()].some((db) => db.transactions.some((transaction) => transaction._state !== "finished")) ? 0 : quiet + 1;
19757
+ }
19758
+ }
19685
19759
  const installed = globalThis;
19686
19760
  installed.indexedDB = factory;
19687
19761
  installed.IDBKeyRange = IDBKeyRange$1;
@@ -19690,6 +19764,7 @@ async function openPersistentIndexedDb(dir) {
19690
19764
  flush: () => queue,
19691
19765
  compact: () => enqueue(writeSnapshot),
19692
19766
  async close() {
19767
+ await settled();
19693
19768
  await enqueue(writeSnapshot);
19694
19769
  writing = false;
19695
19770
  if (journalFd !== null) {
@@ -19781,17 +19856,49 @@ function dropIndexEntriesByRecord(classes, cmp) {
19781
19856
  for (const candidate of gone.indexes.values()) if (candidate.records === this) index = candidate;
19782
19857
  }
19783
19858
  if (!gone || !index) return deleteByValue.call(this, key);
19784
- const dropped = [];
19785
- for (const entry of indexEntries(index, gone.records, cmp)) {
19786
- if (this.keysAreUnique) {
19787
- const held = this.get(entry.key);
19788
- if (!held || cmp(held.value, entry.value) !== 0) continue;
19859
+ return dropEntries(index, gone.records, cmp);
19860
+ };
19861
+ }
19862
+ /** Drops from an index the entries of records that left its store, and gives back the ones it dropped. */
19863
+ function dropEntries(index, records, cmp) {
19864
+ const dropped = [];
19865
+ for (const entry of indexEntries(index, records, cmp)) {
19866
+ if (index.records.keysAreUnique) {
19867
+ const held = index.records.get(entry.key);
19868
+ if (!held || cmp(held.value, entry.value) !== 0) continue;
19869
+ }
19870
+ index.records.records.delete(entry);
19871
+ dropped.push(entry);
19872
+ }
19873
+ return dropped;
19874
+ }
19875
+ /**
19876
+ * `cmp` for keys that are stored already (a record's, an index entry's): numbers, strings and arrays of them are
19877
+ * ordered here, as IndexedDB orders them (a number before a string before an array; strings by code unit; arrays item
19878
+ * by item, then the shorter first). Anything else (a date, bytes, a value that is no key) is left to `cmp`, which
19879
+ * checks and copies both keys at every call: on the way back from a snapshot that was a third of a large store's time.
19880
+ */
19881
+ function keyOrder(cmp) {
19882
+ const order = (a, b) => {
19883
+ const first = typeof a, second = typeof b;
19884
+ if (first === "number" && second === "number") return a === b ? 0 : a > b ? 1 : a < b ? -1 : cmp(a, b);
19885
+ if (first === "string" && second === "string") return a === b ? 0 : a > b ? 1 : -1;
19886
+ if (first === "number" && second === "string") return -1;
19887
+ if (first === "string" && second === "number") return 1;
19888
+ if (Array.isArray(a)) {
19889
+ if (second === "number" || second === "string") return 1;
19890
+ if (!Array.isArray(b)) return cmp(a, b);
19891
+ const length = Math.min(a.length, b.length);
19892
+ for (let at = 0; at < length; at++) {
19893
+ const item = order(a[at], b[at]);
19894
+ if (item !== 0) return item;
19789
19895
  }
19790
- this.records.delete(entry);
19791
- dropped.push(entry);
19896
+ return a.length === b.length ? 0 : a.length > b.length ? 1 : -1;
19792
19897
  }
19793
- return dropped;
19898
+ if (Array.isArray(b) && (first === "number" || first === "string")) return -1;
19899
+ return cmp(a, b);
19794
19900
  };
19901
+ return order;
19795
19902
  }
19796
19903
  /**
19797
19904
  * The snapshot's records come in key order (as `writeSnapshot` walks them), so each store's tree and each index's are
@@ -19888,21 +19995,27 @@ function fill(store, sorted) {
19888
19995
  /**
19889
19996
  * Replays whole entries; a torn last entry (the process died mid-write) was never acknowledged and is dropped.
19890
19997
  *
19891
- * A store's indexes are set aside while its ops replay and rebuilt once at the end: fake-indexeddb drops an
19998
+ * A store's indexes are set aside while its ops replay and brought in step once at the end: fake-indexeddb drops an
19892
19999
  * overwritten or deleted record from an index by walking every record of it, so a crash after a busy chat took
19893
- * tens of seconds to minutes to start, one write at a time against the whole profile.
20000
+ * tens of seconds to minutes to start, one write at a time against the whole profile. Only the rows the journal
20001
+ * touched change in an index (what each was before goes, what it is now comes in): a journal of one message costs one
20002
+ * message, not the profile's history once per index. A store the journal cleared has its indexes built again whole.
19894
20003
  */
19895
- function replay(databases, journal) {
20004
+ function replay(databases, journal, cmp) {
19896
20005
  const touched = /* @__PURE__ */ new Map();
19897
20006
  try {
19898
20007
  replayEntries(databases, journal, touched);
19899
20008
  } finally {
19900
- for (const [store, indexes] of touched) {
20009
+ for (const [store, { indexes, was, now }] of touched) {
20010
+ const records = store.records;
20011
+ delete records.put;
20012
+ delete records.delete;
19901
20013
  store.rawIndexes = indexes;
19902
20014
  for (const index of indexes.values()) {
19903
- index.records.clear();
20015
+ if (!was) index.records.clear();
19904
20016
  if (!index.initialized) continue;
19905
- for (const record of store.records.values()) index.storeRecord(record);
20017
+ if (was) dropEntries(index, [...was], cmp);
20018
+ for (const record of was ? now : store.records.values()) if (!absent(index.keyPath, record.value)) index.storeRecord(record);
19906
20019
  }
19907
20020
  }
19908
20021
  }
@@ -19922,19 +20035,50 @@ function replayEntries(databases, journal, touched) {
19922
20035
  for (const op of ops) {
19923
20036
  const store = databases.get(op.db)?.rawObjectStores.get(op.store);
19924
20037
  if (!store) continue;
19925
- if (!touched.has(store)) {
19926
- touched.set(store, store.rawIndexes);
19927
- store.rawIndexes = /* @__PURE__ */ new Map();
19928
- }
20038
+ let change = touched.get(store);
20039
+ if (!change) touched.set(store, change = watch(store));
19929
20040
  if (op.kind === "put") store.storeRecord({
19930
20041
  key: op.key,
19931
20042
  value: blobsFromDisk(op.value)
19932
20043
  }, false);
19933
20044
  else if (op.kind === "delete") store.deleteRecord(keyFromDisk(op.key));
19934
- else store.clear();
20045
+ else {
20046
+ change.was = null;
20047
+ store.clear();
20048
+ }
19935
20049
  }
19936
20050
  }
19937
20051
  }
20052
+ /**
20053
+ * Sets a store's indexes aside and notes, as its records are written over and removed, which ones left and which
20054
+ * ones came: the record store hands back what a put replaced and what a delete removed, so a row costs no lookup.
20055
+ * A record that came and left again was never in an index.
20056
+ */
20057
+ function watch(store) {
20058
+ const change = {
20059
+ indexes: store.rawIndexes,
20060
+ was: store.rawIndexes.size ? /* @__PURE__ */ new Set() : null,
20061
+ now: /* @__PURE__ */ new Set()
20062
+ };
20063
+ store.rawIndexes = /* @__PURE__ */ new Map();
20064
+ if (!change.was) return change;
20065
+ const { put, delete: remove } = store.records;
20066
+ const left = (record) => {
20067
+ if (!change.now.delete(record)) change.was?.add(record);
20068
+ };
20069
+ store.records.put = (next, keep) => {
20070
+ const replaced = put.call(store.records, next, keep);
20071
+ if (replaced) left(replaced);
20072
+ change.now.add(next);
20073
+ return replaced;
20074
+ };
20075
+ store.records.delete = (key) => {
20076
+ const removed = remove.call(store.records, key);
20077
+ removed.forEach(left);
20078
+ return removed;
20079
+ };
20080
+ return change;
20081
+ }
19938
20082
  function keyToDisk(key) {
19939
20083
  if (key instanceof IDBKeyRange$1) return { [RANGE]: {
19940
20084
  lower: key.lower,
@@ -20188,14 +20332,14 @@ async function startRuntime(paths, options = {}) {
20188
20332
  (await import("./assets/ghostly_iroh_web-Dvzh3Y6E.js")).initSync({ module: irohWasmBytes() });
20189
20333
  const [{ registerFileBytes }, { NodeFileBytes }] = await Promise.all([import("./assets/fileBytes-BcYZhNgF.js").then((n) => n.o), import("./assets/fileBytes-Dbifu5I5.js")]);
20190
20334
  registerFileBytes("native", async () => new NodeFileBytes(paths.files), true);
20191
- const [{ EngineServer }, { createHyperEndpoint }] = await Promise.all([import("./assets/server-Cy4ox7oD.js").then((n) => n.t), import("./assets/endpoint-pgFkR2AS.js")]);
20335
+ const [{ EngineServer }, { createHyperEndpoint }] = await Promise.all([import("./assets/server-DHMXMwNn.js").then((n) => n.t), import("./assets/endpoint-pgFkR2AS.js")]);
20192
20336
  const network = hyperdhtNetwork();
20193
20337
  const dht = mainlineNetwork();
20194
- const mainline = dht.off ? null : new (await (import("./assets/mainline-1v8OTYvp.js"))).Mainline({
20338
+ const mainline = dht.off ? null : new (await (import("./assets/mainline-DriRu_hb.js"))).Mainline({
20195
20339
  bootstrap: dht.bootstrap,
20196
20340
  host: dht.host
20197
20341
  });
20198
- const transport = mainline || pinned ? new (await (import("./assets/mainline-1v8OTYvp.js"))).RelaysAndDht(mainline, pinned ? { relays: pinned } : {}, !!pinned) : null;
20342
+ const transport = mainline || pinned ? new (await (import("./assets/mainline-DriRu_hb.js"))).RelaysAndDht(mainline, pinned ? { relays: pinned } : {}, !!pinned) : null;
20199
20343
  const server = new EngineServer({
20200
20344
  ...transport ? { transport } : {},
20201
20345
  irohWeb: true,
@@ -20340,10 +20484,14 @@ var guarded = false;
20340
20484
  /**
20341
20485
  * A daemon says an error nobody caught (with its stack, on stderr: the daemon's log when detached) and keeps serving:
20342
20486
  * one request gone wrong must not take the profile offline. One-shot commands do not call this; they still fail loudly.
20487
+ *
20488
+ * `outputs`: what it writes to. A write there that fails (a pipe whose reader is gone: the process that started the
20489
+ * daemon died) is dropped, not an error nobody caught: saying it would be one more failed write, and the next, with
20490
+ * no turn left for anything else (a full core, and no stop on SIGTERM).
20343
20491
  */
20344
20492
  function keepServing(proc = process, write = (text) => {
20345
20493
  process.stderr.write(text);
20346
- }) {
20494
+ }, outputs = proc === process ? [process.stdout, process.stderr] : []) {
20347
20495
  if (proc === process) {
20348
20496
  if (guarded) return;
20349
20497
  guarded = true;
@@ -20353,6 +20501,7 @@ function keepServing(proc = process, write = (text) => {
20353
20501
  write(`ghostly: ${what}, the daemon keeps serving: ${error instanceof Error ? error.stack ?? error.message : String(error)}\n`);
20354
20502
  } catch {}
20355
20503
  };
20504
+ for (const output of outputs) output.on("error", () => {});
20356
20505
  proc.on("unhandledRejection", log("unhandled rejection"));
20357
20506
  proc.on("uncaughtException", log("uncaught exception"));
20358
20507
  }
@@ -20556,6 +20705,18 @@ function readCursor(path) {
20556
20705
  const value = Number(readFileSync(path, "utf8").trim());
20557
20706
  return Number.isInteger(value) && value >= 0 ? value : void 0;
20558
20707
  }
20708
+ /**
20709
+ * Whether a cursor can be kept at `path`, asked before anything starts: its folder is there and takes a file. Before, a
20710
+ * folder that was not there was found at the first event: the listener ran, said "event N failed: ENOENT" for each one
20711
+ * and never wrote the cursor, so the next start began at the journal's end and skipped what came while it was down.
20712
+ * Throws what the file system says (`asCliError` names the path and the code: not_found, refused).
20713
+ */
20714
+ function checkCursor(path) {
20715
+ if (!path) return;
20716
+ const folder = dirname(resolve(path));
20717
+ if (!statSync(folder).isDirectory()) throw new CliError("not_found", `No such file or folder: ${folder}`);
20718
+ accessSync(folder, constants.W_OK | constants.X_OK);
20719
+ }
20559
20720
  function writeCursor(path, seq) {
20560
20721
  rmSync(path + ".tmp", { force: true });
20561
20722
  writeFileSync(path + ".tmp", String(seq) + "\n", {
@@ -21361,6 +21522,7 @@ async function listenCommand(argv) {
21361
21522
  description: ""
21362
21523
  }
21363
21524
  });
21525
+ noMoreThan(0, parsed.positionals, SPECIAL.find(([usage]) => usage.startsWith("listen "))[0]);
21364
21526
  const g = globals(parsed);
21365
21527
  pretty = false;
21366
21528
  if (parsed.options.webhook) checkWebhook(String(parsed.options.webhook));
@@ -21369,6 +21531,7 @@ async function listenCommand(argv) {
21369
21531
  if (turns && types.length) throw new CliError("usage", "--turns takes the place of --type: a turn is a message received, or a group message that mentions you");
21370
21532
  const from = parsed.options.from ?? [], groups = parsed.options.group ?? [];
21371
21533
  const cursor = parsed.options.cursor;
21534
+ checkCursor(cursor);
21372
21535
  let since = parsed.options.since ?? readCursor(cursor);
21373
21536
  let handle = null;
21374
21537
  const start = async (call, peerOf) => {