@ghostlytools/cli 1.2.1 → 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 SSHSIG_NAMESPACE, $a as appRef, $c as randomBytes$1, $n as engineError, An as encodeDnsQuery, Ao as RTC_CONFIG, At as didJwsAlg, Ba as cardLine, Bn as resolveLightningDestination, Bt as parseDidSignaturePaste, Cn as homeserverWebEndpoint, Ct as DID_DOCUMENT_MAX_BYTES$1, Dn as dnsProofName, Dt as checkDidJws, En as decodeDnsResponse, Eo as LIMITS, Et as DidError, Fa as PR_CHECKS, Fn as recordsFromWellKnown, Fo as extractParamsFromSdp, Ft as didWebFileUrl, Ha as checkStatusCard, Hc as openRelayPayload, Ht as staticDidDocument, Ia as PR_STATES, Ii as identityStatement, In as recordsName, Io as parseCallSignal, It as didWebHost, Jt as resolveDidDht, Ki as irohDescriptor, Kr as sanitizeTypingStatus, La as RUN_RESULTS, Ln as txtValue, Lo as signalHasVideo, Lt as normalizeDid, Ma as replySnippet, Mn as nip05RootKey, Mo as buildSdpFromSignal, Mt as didWebDocumentUrl, Nc as setLinkTraceSink, Ni as boundVerifiedIdentity, Nn as normalizeDomain, No as callRtcConfig, Nt as didWebFile, On as domainTxtRecord, Ot as checkDidSignature, Pa as ITEM_STATES, Pn as recordsFromTxt, Po as callSignalHeardAt, Pt as didWebFileNames, Qa as appFingerprint, Qc as fromBase64Url$1, Ra as STATUS_CARD_LIMITS, Rn as wellKnownUrl, Rt as parseDid, Sn as pubkyProofPath, So as GhostlyHttpError, St as verifyAtprotoRecordProof, Tn as DOMAIN_FILE_MAX_BYTES, Ur as TYPING_KINDS, Ut as strictBase64Url, Va as cardUrl, Vn as decodeBolt11, Vr as transferEnded, Vt as signingKeys, Wr as TYPING_REFRESH_MS, Xa as APP_PREFIXES, Xc as sanitizeAvatar, Yc as MAX_AVATAR_LENGTH, Z as groupName, Za as APP_STATEMENT_LIMITS, _ as readAppBundle, _n as isPubkyProofFolder, _t as isAtprotoDid, aa as PLAYABLE_AUDIO, ao as signAppObject, ar as fetchesByItself, at as verifySshSignature, bn as pubkyProofCapability, bt as shortAtprotoDid, c as signAppStore, ca as fileMessageText, ci as checkPushEndpoint, co as canonicalJsonBytes, ct as decodeAnyBitcoinAddress, dr as readGroupWantNo, dt as ATPROTO_PROOF_COLLECTION, ea as readImageMeta, el as toBase64Url, eo as checkAppRevokeStatement, er as engineText, et as SshSigError, ft as ATPROTO_PROOF_SCOPE, g as isAppUrl, gn as isPubkyKey, gt as checkAtprotoProofRecord, h as checkAppManifest, hn as PUBKY_PROOF_MAX_BYTES, ht as atprotoProofRkey, ia as sanitizeMime, in as WALLET_NETWORKS, io as readAppRevocations, ir as GROUP_WANT_NO_FRAME, it as sshSignCommand, ji as IdentityCheckUnavailable, jn as isPublicAddress, jo as answersOffer, jt as didSignedMessages, kn as domainWellKnownFile, ko as CALL_SIGNAL_MAX_AGE_MS, kt as didGhostlyServiceEntry, l as APP_BUNDLE_LIMITS, la as parseVoiceMeta, ln as walletNetworkOf, lr as readGroupHave, lt as ATPROTO_DECLARED_SCOPE, m as assembleAppBundle, ma as RateWindow, mt as atprotoProofRecord, n as appStoreDecision, nc as inviteLink, nl as utf8Decode, nr as GROUP_HAVE_FRAME, nt as parseSshSignature, o as checkAppStoreIndex, oa as VOICE_LIMITS, oo as signAppRevocation, or as groupFileFallback, ot as verifyBitcoinMessage, pc as seedSigner, pi as BUTTONS_CAPABILITY, pt as atprotoDidMethod, qi as irohRelayUrl, ra as sanitizeFileName, rl as utf8Encode, rt as sameSshKey, s as readAppStore, sa as baseMime, so as canonicalJson, sr as isGroupMessageId, st as BitcoinAddressError, tl as toZ32, tr as GROUP_FILE_LIMITS, tt as parseSshPublicKey, ua as voicePeaksOf, ur as readGroupWant, ut as ATPROTO_FULL_SCOPE, vn as newPubkyProofFolder, vt as normalizeAtprotoHandle, wn as pubkyHomeserverOf, wt as DID_FILE_MAX_BYTES, xn as pubkyProofFile, xt as AtprotoProofError, yn as normalizePubkyKey, yt as parseAtprotoDidDocument, za as buttonLabelClash, zc as RELAY_PAYLOAD_MAX_BYTES, zn as requestLnurlInvoice, zr as ChatFiles, zt as parseDidDocument } from "./assets/src-aKGjb_yz.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.1",
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",
@@ -285,15 +285,21 @@ function parseArgs(argv, spec, idSlot = () => false) {
285
285
  }
286
286
  /** The global option a word names (`--profile`, `--profile=bot`, `-p`), else undefined. */
287
287
  function globalOption(arg) {
288
+ const name = globalName(arg);
289
+ return name ? GLOBAL_OPTIONS[name] : void 0;
290
+ }
291
+ /** The name of the global option a word names (`profile` for `-p`), else undefined. */
292
+ function globalName(arg) {
288
293
  const eq = arg.indexOf("=");
289
294
  const name = arg.startsWith("--") ? arg.slice(2, eq === -1 ? void 0 : eq) : arg.length === 2 && arg.startsWith("-") ? Object.entries(GLOBAL_OPTIONS).find(([, o]) => o.short === arg.slice(1))?.[0] : void 0;
290
- return name && Object.hasOwn(GLOBAL_OPTIONS, name) ? GLOBAL_OPTIONS[name] : void 0;
295
+ return name && Object.hasOwn(GLOBAL_OPTIONS, name) ? name : void 0;
291
296
  }
292
297
  /**
293
298
  * Global options given before the command (`ghostly --profile bot send …`) move after it, where every command's
294
299
  * parser reads them. Stops at `--` and at the first word that is not a global option. They go before the first word
295
300
  * after the command's that starts with a dash (its first option, or the `--` that ends them), else at the end: never
296
- * between an option and its value, which may itself be `--` (`--pretty profile set --name --`).
301
+ * between an option and its value, which may itself be `--` (`--pretty profile set --name --`). One that takes a value
302
+ * and is the last word has none: a usage error.
297
303
  */
298
304
  function liftGlobals(argv) {
299
305
  const lifted = [];
@@ -304,6 +310,7 @@ function liftGlobals(argv) {
304
310
  const option = globalOption(arg);
305
311
  if (!option || arg === "--") break;
306
312
  const takesValue = option.type !== "boolean" && eq === -1;
313
+ if (takesValue && i + 1 >= argv.length) throw new CliError("usage", `--${globalName(arg)} needs a value`);
307
314
  lifted.push(...argv.slice(i, i + (takesValue ? 2 : 1)));
308
315
  i += takesValue ? 2 : 1;
309
316
  }
@@ -335,14 +342,26 @@ var SECRET_CONTEXT = /priv(?:ate)?[\s_-]*key|secret|seed|mnemonic|nsec|xprv|\bwi
335
342
  /** How far from a 64-hex string a word like "seed" still counts as saying what it is. */
336
343
  var CONTEXT_REACH = 48;
337
344
  var CASHU_TOKEN = /\bcashu([AB])([A-Za-z0-9_\-+/]{20,}={0,2})/g;
345
+ /**
346
+ * Characters that take no room on screen: format characters (zero-width space, soft hyphen, word joiner, the joiners,
347
+ * direction marks), control characters that are not white space, variation selectors, the grapheme joiner and the
348
+ * Hangul fillers. A page, a PDF or a terminal puts some of them into long strings as places to break a line.
349
+ */
350
+ var INVISIBLE = /(?![\t\n\v\f\r\x85])[\p{Cc}\p{Cf}]|\u034f|[\u115f\u1160\u3164\uffa0]|[\ufe00-\ufe0f]|[\u{e0100}-\u{e01ef}]/gu;
338
351
  var base58check = createBase58check(sha256);
339
- /** The first secret in `text`, keys and seeds before ecash; null for anything else. */
352
+ /**
353
+ * The first secret in `text`, keys and seeds before ecash; null for anything else. The text is read as it is and as
354
+ * it reads without its invisible characters: a key with one inside is whole again once the reader takes it out.
355
+ */
340
356
  function findSecret(text) {
341
- if (hasMnemonic(text)) return { kind: "mnemonic" };
342
- if (NSEC.test(text)) return { kind: "nsec" };
343
- if (EXTENDED_PRIVATE.test(text) || hasWif(text)) return { kind: "bitcoin-key" };
344
- if (hasHexSecret(text)) return { kind: "hex-key" };
345
- return findCashu(text);
357
+ const visible = text.replace(INVISIBLE, "");
358
+ const readings = visible === text ? [text] : [text, visible];
359
+ if (readings.some(hasMnemonic)) return { kind: "mnemonic" };
360
+ if (readings.some((reading) => NSEC.test(reading))) return { kind: "nsec" };
361
+ if (readings.some((reading) => EXTENDED_PRIVATE.test(reading) || hasWif(reading))) return { kind: "bitcoin-key" };
362
+ if (readings.some(hasHexSecret)) return { kind: "hex-key" };
363
+ const tokens = readings.map(findCashu);
364
+ return tokens.find((found) => found?.kind === "cashu" && found.amount !== null) ?? tokens.find((found) => found) ?? null;
346
365
  }
347
366
  /**
348
367
  * 12 to 24 words in a row from the English BIP39 list whose checksum holds, anywhere in the text; or a message that
@@ -875,7 +894,8 @@ function findGroup(groups, ref) {
875
894
  * A chat or a group, where a command takes either: `group:<id>` is a group; anything else a chat first (`findChat`),
876
895
  * then a group. A chat found only by the name its contact gave itself never takes what also names a group: a group's
877
896
  * own id is that group, and a prefix or the name of a group this profile is in is refused, with both ids. With neither,
878
- * the error says what `findChat` would.
897
+ * one `not_found` names both and where to look (a contact's key: no chat with it); a name that matches more than one
898
+ * chat or group stays that error.
879
899
  */
880
900
  function findChatOrGroup(links, groups, ref) {
881
901
  if (ref.startsWith("group:")) return { group: findGroup(groups, ref.slice(6)) };
@@ -887,7 +907,9 @@ function findChatOrGroup(links, groups, ref) {
887
907
  try {
888
908
  return { group: findGroup(groups, ref) };
889
909
  } catch (inner) {
890
- throw inner instanceof CliError && inner.code !== "not_found" ? inner : error;
910
+ if (!(inner instanceof CliError) || inner.code !== "not_found") throw inner;
911
+ if (CONTACT_KEY.test(ref)) throw error;
912
+ throw new CliError("not_found", `No chat or group ${JSON.stringify(ref)} (ghostly chat list, ghostly group list)`);
891
913
  }
892
914
  }
893
915
  if (!chat.theirName) return { link: chat.link };
@@ -916,6 +938,16 @@ function chatOrGroup(ctx, ref) {
916
938
  };
917
939
  }
918
940
  var groupOf = (ctx, params) => findGroup(state(ctx).groups, str(params, "group", true));
941
+ /**
942
+ * A message of a chat or a group (`group:<id>`) named by its id here, read as one row; only a name that is no such
943
+ * row's key (the id both sides know, `wireId`) reads the whole history. `keep`: only a message it accepts (mine, or
944
+ * someone else's). The history is synchronous work on the daemon's one thread, about 120 ms at 10,000 messages.
945
+ */
946
+ async function messageOf(ctx, linkId, ref, keep = () => true) {
947
+ const found = await node(ctx).getMessage(linkId, ref);
948
+ if (found && keep(found)) return found;
949
+ return (linkId.startsWith("group:") ? await node(ctx).groupMessages({ groupId: linkId.slice(6) }) : await node(ctx).getMessages(linkId)).find((m) => keep(m) && m.wireId === ref);
950
+ }
919
951
  /** Resolves with `check`'s first defined answer: now, or on a later state; `timeout` error after `ms`. */
920
952
  function waitForState(ctx, check, ms, what) {
921
953
  const now = check(state(ctx));
@@ -1131,6 +1163,12 @@ var storedGroupFiles = {
1131
1163
  read: (file, offset, length) => readStored(file, offset, length),
1132
1164
  room: async () => (await fileBytes()).room()
1133
1165
  };
1166
+ /**
1167
+ * Ended transfers an edge keeps the record of. A late frame for one of them is still answered as files/3 says (an abort
1168
+ * to an asker going on with a transfer cancelled here); older ones are forgotten, so an edge's records stay few
1169
+ * however many transfers it has seen.
1170
+ */
1171
+ var ENDED_KEPT = 8;
1134
1172
  /** Why a want waits, as the person reads it (the app shows its own words for each `wait`). */
1135
1173
  var WAITING_NOTES = {
1136
1174
  nobody: "Nobody you are connected to has this file yet",
@@ -1164,7 +1202,10 @@ var GroupFileDesk = class {
1164
1202
  constructor(deps) {
1165
1203
  this.deps = deps;
1166
1204
  }
1167
- /** At start: each group's files as they were kept. One fetched by itself, or asked for, and not here yet is asked for again. */
1205
+ /**
1206
+ * At start: each group's files as they were kept. One fetched by itself, or asked for, and not here yet is asked for
1207
+ * again; with automatic downloads off, one chosen by itself before waits for a Download instead.
1208
+ */
1168
1209
  async load(groupIds) {
1169
1210
  for (const groupId of groupIds) for (const file of await this.deps.store.list(groupId).catch(() => [])) {
1170
1211
  if (!file.group || !file.metadata || !file.digest || this.kept.has(file.id)) continue;
@@ -1205,7 +1246,8 @@ var GroupFileDesk = class {
1205
1246
  retry: true,
1206
1247
  error: file.transfer.error ?? DAMAGED$1
1207
1248
  });
1208
- else if (kept.auto || kept.asked) this.wants.set(kept.id, this.newWant());
1249
+ else if (kept.asked || kept.auto && this.deps.settings().autoDownloads) this.wants.set(kept.id, this.newWant());
1250
+ else if (kept.auto) await this.stop(kept, false);
1209
1251
  else this.show(kept, {
1210
1252
  state: "transferring",
1211
1253
  stage: "asking",
@@ -1230,8 +1272,58 @@ var GroupFileDesk = class {
1230
1272
  }
1231
1273
  }
1232
1274
  /**
1275
+ * The person stops a file that is coming, or that this device would fetch (Cancel, Decline): nobody is asked for it
1276
+ * any more, the transfer under way ends, what came of it is deleted, and it waits for a Download. Paused (`keep`),
1277
+ * what came stays, and Resume (`download`) goes on from there.
1278
+ */
1279
+ async halt(fileId, keep = false) {
1280
+ const kept = this.kept.get(fileId);
1281
+ if (!kept) throw new Error("No such file");
1282
+ if (keep && !this.wants.has(fileId)) return;
1283
+ await this.stop(kept, keep);
1284
+ }
1285
+ /** Automatic downloads were turned off: files chosen by themselves and not here yet stop coming, and wait for a Download. */
1286
+ async autoDownloadsOff() {
1287
+ for (const kept of [...this.kept.values()]) if (kept.auto && !kept.asked && this.wants.has(kept.id)) await this.stop(kept, false);
1288
+ }
1289
+ async stop(kept, keep) {
1290
+ if (kept.held || kept.mine) return;
1291
+ const want = this.wants.get(kept.id);
1292
+ this.wants.delete(kept.id);
1293
+ const edge = want?.wire && want.linkId ? this.edgesById.get(want.linkId) : void 0;
1294
+ const record = edge?.files.get("in", want.wire);
1295
+ const { size } = kept.meta;
1296
+ if (!keep) {
1297
+ kept.stored = 0;
1298
+ kept.fresh = true;
1299
+ }
1300
+ if (edge && record && !transferEnded(record)) {
1301
+ if (!keep) kept.dropped = record.id;
1302
+ edge.files.cancel("in", record.id);
1303
+ edge.taking.delete(record.id);
1304
+ } else if (!keep) kept.idle = (kept.idle ?? Promise.resolve()).then(() => this.deps.store.write(kept.id)).then((writer) => writer.discard()).catch(() => {});
1305
+ this.show(kept, keep ? {
1306
+ state: "transferring",
1307
+ stage: "paused",
1308
+ pausedBy: "me",
1309
+ transferred: kept.stored ?? 0,
1310
+ size,
1311
+ direction: "in"
1312
+ } : {
1313
+ state: "transferring",
1314
+ stage: "asking",
1315
+ transferred: 0,
1316
+ size,
1317
+ direction: "in"
1318
+ });
1319
+ if (kept.auto || kept.asked) {
1320
+ kept.auto = kept.asked = false;
1321
+ await this.deps.store.patch(kept.id, { group: this.groupField(kept) }).catch(() => {});
1322
+ }
1323
+ }
1324
+ /**
1233
1325
  * An error when this group took as many announcements of mine as it takes in a minute, or as its members take from
1234
- * 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.
1235
1327
  */
1236
1328
  mayAnnounce(groupId) {
1237
1329
  const wait = Math.max(...this.sentPaces(groupId).map((pace) => pace.wait()));
@@ -1248,9 +1340,9 @@ var GroupFileDesk = class {
1248
1340
  this.own.delete(`${groupId}\n${meta.d}`);
1249
1341
  }
1250
1342
  /**
1251
- * A message of the group announces a file (mine, or a member's): the file its row shows, or none when the member
1252
- * announced more than the group takes (its text shows). A member's is kept here unfetched, and fetched now when it is
1253
- * 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.
1254
1346
  */
1255
1347
  async announced(groupId, m, me) {
1256
1348
  const known = this.byMessage.get(`${groupId}\n${m.id}`);
@@ -1292,9 +1384,9 @@ var GroupFileDesk = class {
1292
1384
  this.queueHave(kept);
1293
1385
  return messageFile(kept);
1294
1386
  }
1295
- 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();
1296
1388
  const id = groupFileId(groupId, "in");
1297
- 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));
1298
1390
  const holders = this.ahead.get(`${groupId}\n${m.id}`) ?? [];
1299
1391
  this.ahead.delete(`${groupId}\n${m.id}`);
1300
1392
  const kept = {
@@ -1696,20 +1788,35 @@ var GroupFileDesk = class {
1696
1788
  const reserved = active.reduce((sum, a) => sum + a.size, 0);
1697
1789
  return this.served.bytes + reserved + kept.meta.size > GROUP_FILE_LIMITS.serveBytesPerDay ? "busy" : null;
1698
1790
  }
1699
- /** Transfers served now, all groups together: to whom, their size, and whether bytes are still to go. */
1791
+ /**
1792
+ * Transfers served now, all groups together: to whom, what each takes of the day (its size, or what it sent when that
1793
+ * is more), and whether bytes are still to go.
1794
+ */
1700
1795
  servingNow() {
1701
1796
  const active = [];
1702
1797
  for (const edge of this.edgesById.values()) for (const wire of edge.serving.keys()) {
1703
1798
  const record = edge.files.get("out", wire);
1704
1799
  if (record && !transferEnded(record)) active.push({
1705
1800
  peer: edge.peer,
1706
- size: record.file.size,
1801
+ size: Math.max(record.file.size, edge.files.sent(wire)),
1707
1802
  sending: record.confirmed < record.file.size
1708
1803
  });
1709
1804
  }
1710
1805
  return active;
1711
1806
  }
1712
1807
  /**
1808
+ * An asker has a served transfer send parts again (WISP 503 § Serving limits): every byte sent counts toward the day,
1809
+ * so it goes on only while the day has room for it.
1810
+ */
1811
+ mayResend() {
1812
+ const today = Math.floor(this.deps.now() / 864e5);
1813
+ if (this.served.day !== today) this.served = {
1814
+ day: today,
1815
+ bytes: 0
1816
+ };
1817
+ return this.served.bytes + this.servingNow().reduce((sum, a) => sum + a.size, 0) < GROUP_FILE_LIMITS.serveBytesPerDay;
1818
+ }
1819
+ /**
1713
1820
  * Served transfers that no longer count end, their places free for others (WISP 503 § Serving limits): the asker's
1714
1821
  * session closed, it is not in the roster any more, or its bytes have not moved for `wantWaitMs` (the asker has moved
1715
1822
  * on by then).
@@ -1762,6 +1869,7 @@ var GroupFileDesk = class {
1762
1869
  serving: /* @__PURE__ */ new Map(),
1763
1870
  servedMoved: /* @__PURE__ */ new Map(),
1764
1871
  taking: /* @__PURE__ */ new Map(),
1872
+ ended: [],
1765
1873
  have: /* @__PURE__ */ new Set(),
1766
1874
  haveAt: 0,
1767
1875
  busy: /* @__PURE__ */ new Set()
@@ -1779,7 +1887,8 @@ var GroupFileDesk = class {
1779
1887
  openSource: (record) => this.openSource(edge, record),
1780
1888
  changed: (record, transferred) => this.transferChanged(edge, record, transferred),
1781
1889
  room: () => this.deps.store.room(),
1782
- writable: () => this.deps.writable?.(linkId)
1890
+ writable: () => this.deps.writable?.(linkId),
1891
+ resend: (record) => !edge.serving.has(record.id) || this.mayResend()
1783
1892
  });
1784
1893
  this.edgesById.set(linkId, edge);
1785
1894
  return edge;
@@ -1818,7 +1927,7 @@ var GroupFileDesk = class {
1818
1927
  if (resumed) kept.fresh = kept.unclear = true;
1819
1928
  else kept.damaged.add(edge.peer);
1820
1929
  }
1821
- return kept.idle = (damaged ? writer.discard() : writer.close()).catch(() => {});
1930
+ return kept.idle = (damaged || kept.dropped === record.id ? writer.discard() : writer.close()).catch(() => {});
1822
1931
  }
1823
1932
  };
1824
1933
  }
@@ -1831,7 +1940,14 @@ var GroupFileDesk = class {
1831
1940
  digest: async () => kept.meta.d
1832
1941
  };
1833
1942
  }
1943
+ /** A transfer on the edge ended: its record stays among the last `ENDED_KEPT`, and the oldest past that is forgotten. */
1944
+ transferOver(edge, record) {
1945
+ if (edge.ended.some(([direction, id]) => direction === record.direction && id === record.id)) return;
1946
+ edge.ended.push([record.direction, record.id]);
1947
+ while (edge.ended.length > ENDED_KEPT) edge.files.forget(...edge.ended.shift());
1948
+ }
1834
1949
  transferChanged(edge, record, transferred) {
1950
+ if (transferEnded(record)) this.transferOver(edge, record);
1835
1951
  if (record.direction === "out") {
1836
1952
  if (transferEnded(record) && edge.serving.has(record.id)) {
1837
1953
  const today = Math.floor(this.deps.now() / 864e5);
@@ -1839,7 +1955,7 @@ var GroupFileDesk = class {
1839
1955
  day: today,
1840
1956
  bytes: 0
1841
1957
  };
1842
- this.served.bytes += record.confirmed;
1958
+ this.served.bytes += edge.files.sent(record.id);
1843
1959
  edge.serving.delete(record.id);
1844
1960
  edge.servedMoved.delete(record.id);
1845
1961
  const serving = this.deps.settings().serveFiles;
@@ -2584,7 +2700,7 @@ var FILE_METHODS = {
2584
2700
  throw new CliError("not_found", `No file ${path}`);
2585
2701
  }
2586
2702
  if (!info.isFile()) throw new CliError("bad_request", `${path} is not a file`);
2587
- await access(path, constants.R_OK);
2703
+ await access(path, constants$1.R_OK);
2588
2704
  if ("group" in target) return sendToGroup(ctx, params, target.group, path, info.size);
2589
2705
  const { link } = target;
2590
2706
  if (link.profile && link.dataLink === "open" && !link.capabilities?.files) throw new CliError("unavailable", "The contact's app takes no files");
@@ -2671,15 +2787,16 @@ var FILE_METHODS = {
2671
2787
  /**
2672
2788
  * files/3: answer an offer, or pause, resume or cancel a transfer, either way; `resend` a file sent from here that
2673
2789
  * stopped moving (or failed), `request` again one that stopped arriving. Without a chat, the file's id names it.
2674
- * A group's file (WISP 503) takes `accept` (download one this device did not fetch by itself) and `request`.
2790
+ * A group's file (WISP 503) takes `accept` (download one this device did not fetch by itself), `request`, and `cancel`,
2791
+ * `decline`, `pause` and `resume` for one that is coming.
2675
2792
  */
2676
2793
  async "file.action"(ctx, params) {
2677
2794
  const fileId = str(params, "file", true);
2678
2795
  const { linkId, where } = fileChat(ctx, params, fileId);
2679
2796
  const action = oneOf(params, "action", ACTIONS, "accept");
2680
2797
  if ("group" in where) {
2681
- if (action !== "accept" && action !== "request") throw new CliError("bad_request", "A group's file is downloaded (file accept) or asked for again (file request)");
2682
- if (state(ctx).transfers[fileId]?.state === "done") throw new CliError("refused", "Nothing to ask for: the file is all here");
2798
+ if (action === "resend") throw new CliError("bad_request", "A group's file is downloaded (file accept) or asked for again (file request)");
2799
+ if (state(ctx).transfers[fileId]?.state === "done") throw new CliError("refused", action === "accept" || action === "request" || action === "resume" ? "Nothing to ask for: the file is all here" : "Nothing to stop: the file is all here");
2683
2800
  try {
2684
2801
  await node(ctx).fileAction({
2685
2802
  linkId,
@@ -3346,8 +3463,7 @@ function pressable(link) {
3346
3463
  return peer ? peer.includes(BUTTONS_CAPABILITY) : null;
3347
3464
  }
3348
3465
  /** The message of mine with buttons that `button update` names, by its id here (or the id both sides know). */
3349
- function buttonsMessage(messages, ref, group) {
3350
- const message = messages.find((m) => m.sender === "me" && m.id === ref) ?? messages.find((m) => m.sender === "me" && m.wireId === ref);
3466
+ function buttonsMessage(message, ref, group) {
3351
3467
  if (!message) throw new CliError("not_found", `No message ${JSON.stringify(ref)} of yours in this ${group ? "group" : "chat"}: the messageId send gave`);
3352
3468
  if (message.card?.kind !== "buttons") throw new CliError("bad_request", "That message has no buttons: send one with send --button id:Label");
3353
3469
  return message;
@@ -3367,7 +3483,7 @@ async function updateButtons(ctx, params) {
3367
3483
  const secret = findSecret(newText);
3368
3484
  if (secret) throw new CliError("confirm", `The text looks like ${secret.kind === "cashu" ? "a Cashu token (money anyone who reads it can take)" : "a secret (a seed or a private key)"}; update with --force if you mean to`, { kind: secret.kind });
3369
3485
  }
3370
- const message = buttonsMessage(target.group ? await node(ctx).groupMessages({ groupId: target.id }) : await node(ctx).getMessages(target.linkId), ref, target.group);
3486
+ const message = buttonsMessage(await messageOf(ctx, target.linkId, ref, (m) => m.sender === "me"), ref, target.group);
3371
3487
  if ((message.edit?.seq ?? 0) >= STATUS_CARD_LIMITS.edits) throw new CliError("refused", `This message took ${STATUS_CARD_LIMITS.edits} updates, the most one takes: ask again with a new send`, {
3372
3488
  messageId: message.id,
3373
3489
  edits: message.edit.seq
@@ -3392,7 +3508,7 @@ async function updateButtons(ctx, params) {
3392
3508
  card
3393
3509
  });
3394
3510
  if (result.error) throw new CliError(result.refused ? "refused" : "unavailable", result.error);
3395
- let edited = target.group ? (await node(ctx).groupMessages({ groupId: target.id })).find((m) => m.id === message.id) : await node(ctx).getMessage(target.linkId, message.id);
3511
+ let edited = await node(ctx).getMessage(target.linkId, message.id);
3396
3512
  if (wait === "confirmed" && edited?.edit?.pending) edited = await waitForEdit(ctx, target.linkId, message.id, ms);
3397
3513
  const edges = wait === "sent" && edited?.edit ? await waitForGroupFrame(ctx, target.id, message.id, edited.edit.seq, ms) : void 0;
3398
3514
  return {
@@ -3415,8 +3531,7 @@ async function pressButton(ctx, params) {
3415
3531
  const ref = str(params, "message", true);
3416
3532
  const buttonId = str(params, "button", true);
3417
3533
  const where = target.group ? "group" : "chat";
3418
- const theirs = (target.group ? await node(ctx).groupMessages({ groupId: target.id }) : await node(ctx).getMessages(target.linkId)).filter((m) => m.sender !== "me");
3419
- const message = theirs.find((m) => m.id === ref) ?? theirs.find((m) => m.wireId === ref);
3534
+ const message = await messageOf(ctx, target.linkId, ref, (m) => m.sender !== "me");
3420
3535
  if (!message) throw new CliError("not_found", `No message ${JSON.stringify(ref)} from someone else in this ${where}: the id its event or history gave`);
3421
3536
  if (message.card?.kind !== "buttons") throw new CliError("bad_request", "That message has no buttons");
3422
3537
  const button = message.card.buttons.find((b) => b.id === buttonId);
@@ -3575,7 +3690,7 @@ function isLive(link) {
3575
3690
  if (link.textDelivery !== "stream" || link.dataLink !== "open") return false;
3576
3691
  return !link.profile || link.pairing?.status === "ready" && !!link.pairing.transport;
3577
3692
  }
3578
- function chatJson(link) {
3693
+ function chatJson(link, showSecret) {
3579
3694
  const label = link.label?.trim() || null;
3580
3695
  const peerName = link.peerNick?.trim() || null;
3581
3696
  const live = isLive(link);
@@ -3593,15 +3708,15 @@ function chatJson(link) {
3593
3708
  pairing: link.pairing?.status ?? null,
3594
3709
  stage: link.pairingProgress?.stage ?? null,
3595
3710
  verified: !!link.peerVerified,
3596
- invite: link.pairing?.status === "ready" ? null : link.inviteCode ?? null,
3711
+ invite: link.pairing?.status === "ready" || !link.inviteCode ? null : showSecret === true ? link.inviteCode : "<hidden>",
3597
3712
  compatibility: !link.profile,
3598
3713
  peerPicture: !!link.peerAvatar
3599
3714
  };
3600
3715
  }
3601
3716
  /** A chat's details: the summary plus what its connection panel shows. */
3602
- function chatDetailsJson(link) {
3717
+ function chatDetailsJson(link, showSecret) {
3603
3718
  return {
3604
- ...chatJson(link),
3719
+ ...chatJson(link, showSecret),
3605
3720
  progress: link.pairingProgress ?? null,
3606
3721
  transports: {
3607
3722
  mine: link.availableTransports ?? [],
@@ -6057,7 +6172,8 @@ async function verifyIdToken(token, options) {
6057
6172
  }
6058
6173
  //#endregion
6059
6174
  //#region ../browser/src/proofs/oidc/flow.ts
6060
- 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}$/;
6061
6177
  function scopeFor$1(provider, reveal) {
6062
6178
  const scopes = provider.id === "apple" ? [] : ["openid"];
6063
6179
  const claims = [];
@@ -8612,13 +8728,15 @@ var IDENTITY_METHODS = {
8612
8728
  },
8613
8729
  async "identity.cancel"(ctx, params) {
8614
8730
  const draftId = str(params, "draft", true);
8615
- 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");
8616
8732
  await node(ctx).cancelIdentityProof({ draftId });
8617
8733
  return { cancelled: draftId };
8618
8734
  },
8619
8735
  async "identity.remove"(ctx, params) {
8620
- await node(ctx).removeIdentityProof({ id: str(params, "id", true) });
8621
- 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 };
8622
8740
  },
8623
8741
  async "identity.share"(ctx, params) {
8624
8742
  const link = chatOf(ctx, params);
@@ -8633,13 +8751,15 @@ var IDENTITY_METHODS = {
8633
8751
  },
8634
8752
  async "identity.withdraw"(ctx, params) {
8635
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})`);
8636
8756
  await node(ctx).withdrawIdentityProof({
8637
8757
  linkId: link.id,
8638
- id: str(params, "id", true)
8758
+ id
8639
8759
  });
8640
8760
  return {
8641
8761
  chat: link.id,
8642
- withdrawn: str(params, "id", true)
8762
+ withdrawn: id
8643
8763
  };
8644
8764
  },
8645
8765
  /** What a contact shared in a chat, as this device checked it, and what this side shares there. */
@@ -8875,6 +8995,15 @@ async function proxy(request, response, chunks, send, cookies) {
8875
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)}`);
8876
8996
  }
8877
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
+ }
8878
9007
  var SERVICE_METHODS = {
8879
9008
  async "service.list"(ctx) {
8880
9009
  return { services: state(ctx).services.map((s) => ({
@@ -8901,29 +9030,32 @@ var SERVICE_METHODS = {
8901
9030
  });
8902
9031
  },
8903
9032
  async "service.remove"(ctx, params) {
8904
- await node(ctx).removeService({ serviceId: str(params, "service", true) });
8905
- return { removed: str(params, "service", true) };
9033
+ const serviceId = serviceOf(ctx, params);
9034
+ await node(ctx).removeService({ serviceId });
9035
+ return { removed: serviceId };
8906
9036
  },
8907
9037
  async "service.enable"(ctx, params) {
9038
+ const serviceId = serviceOf(ctx, params);
8908
9039
  await node(ctx).setServiceEnabled({
8909
- serviceId: str(params, "service", true),
9040
+ serviceId,
8910
9041
  enabled: !bool(params, "off")
8911
9042
  });
8912
9043
  return {
8913
- service: str(params, "service", true),
9044
+ service: serviceId,
8914
9045
  enabled: !bool(params, "off")
8915
9046
  };
8916
9047
  },
8917
9048
  async "service.share"(ctx, params) {
8918
9049
  const link = chatOf(ctx, params);
8919
9050
  const shared = !bool(params, "off");
9051
+ const serviceId = serviceOf(ctx, params);
8920
9052
  await node(ctx).setServiceShared({
8921
- serviceId: str(params, "service", true),
9053
+ serviceId,
8922
9054
  peerPubKeyZ32: link.peerPubKeyZ32,
8923
9055
  shared
8924
9056
  });
8925
9057
  return {
8926
- service: str(params, "service", true),
9058
+ service: serviceId,
8927
9059
  chat: link.id,
8928
9060
  shared
8929
9061
  };
@@ -12533,6 +12665,25 @@ function checkTextSize(text) {
12533
12665
  max: LIMITS.maxChatMessageBytes
12534
12666
  });
12535
12667
  }
12668
+ /**
12669
+ * The engine's state as `engine getState` prints it without `--show-secret`: the settings' credentials, a group's
12670
+ * entry link (anyone who reads it can join) and a pending chat's invite code (whoever reads it can take the chat)
12671
+ * are `<hidden>`.
12672
+ */
12673
+ function hideStateSecrets(state) {
12674
+ return {
12675
+ ...state,
12676
+ settings: redactSettings(state.settings),
12677
+ ...state.groups ? { groups: state.groups.map((g) => g.entryLink ? {
12678
+ ...g,
12679
+ entryLink: "<hidden>"
12680
+ } : g) } : {},
12681
+ ...state.links ? { links: state.links.map((l) => l.inviteCode ? {
12682
+ ...l,
12683
+ inviteCode: "<hidden>"
12684
+ } : l) } : {}
12685
+ };
12686
+ }
12536
12687
  /** Settings as a bot may read them: storage credentials and other secrets masked unless asked for. */
12537
12688
  function redactSettings(settings, showSecret = false) {
12538
12689
  if (showSecret) return settings;
@@ -12560,7 +12711,9 @@ var SETTABLE = {
12560
12711
  online: "boolean",
12561
12712
  shareProfile: "boolean",
12562
12713
  sendTyping: "boolean",
12563
- nick: "string"
12714
+ nick: "string",
12715
+ autoDownloads: "boolean",
12716
+ serveFiles: "boolean"
12564
12717
  };
12565
12718
  var TRANSPORT_NAMES = {
12566
12719
  auto: "auto",
@@ -12723,13 +12876,14 @@ var METHODS = {
12723
12876
  node(ctx).setActiveLink({ linkId });
12724
12877
  return { chat: linkId };
12725
12878
  },
12726
- async "chat.list"(ctx) {
12727
- return { chats: state(ctx).links.map(chatJson).sort((a, b) => b.lastMessageAt - a.lastMessageAt || b.createdAt - a.createdAt) };
12879
+ async "chat.list"(ctx, params) {
12880
+ const showSecret = bool(params, "showSecret");
12881
+ return { chats: state(ctx).links.map((link) => chatJson(link, showSecret)).sort((a, b) => b.lastMessageAt - a.lastMessageAt || b.createdAt - a.createdAt) };
12728
12882
  },
12729
12883
  async "chat.get"(ctx, params) {
12730
12884
  const link = chatOf(ctx, params);
12731
12885
  return {
12732
- ...chatDetailsJson(link),
12886
+ ...chatDetailsJson(link, bool(params, "showSecret")),
12733
12887
  heldUntil: holdOf(ctx, link.id)
12734
12888
  };
12735
12889
  },
@@ -12795,7 +12949,7 @@ var METHODS = {
12795
12949
  messageId,
12796
12950
  text
12797
12951
  });
12798
- if (result.error) throw editRefused(await node(ctx).getMessages(link.id), messageId, "chat", result.error, result.refused);
12952
+ if (result.error) throw editRefused(await messageOf(ctx, link.id, messageId), messageId, "chat", result.error, result.refused);
12799
12953
  const id = result.messageId ?? messageId;
12800
12954
  let message = await node(ctx).getMessage(link.id, id);
12801
12955
  if (wait === "confirmed" && message?.edit?.pending) message = await waitForEdit(ctx, link.id, id, num(params, "timeout", 30, {
@@ -12849,7 +13003,7 @@ var METHODS = {
12849
13003
  },
12850
13004
  /** WISP 400 § Reactions: one emoji per person per message, a new one replaces mine; `remove` takes mine back. */
12851
13005
  async "chat.react"(ctx, params) {
12852
- const link = chatOf(ctx, params);
13006
+ const link = notAGroup(ctx, params, () => chatOf(ctx, params), "group react", "<message> <emoji>");
12853
13007
  return {
12854
13008
  chat: link.id,
12855
13009
  ...await react(ctx, link.id, params)
@@ -12886,10 +13040,12 @@ var METHODS = {
12886
13040
  if (!messageIds.length) throw new CliError("bad_request", "Name the messages to forward (their ids, from history or an event)");
12887
13041
  const to = list(params, "to").map((ref) => chatOrGroup(ctx, ref));
12888
13042
  if (!to.length) throw new CliError("bad_request", "Forward to which chat? Name one or more with --to");
12889
- const history = from.group ? await node(ctx).groupMessages({ groupId: from.id }) : await node(ctx).getMessages(from.id);
12890
- const chosen = messageIds.map((id) => history.find((m) => m.id === id) ?? (() => {
12891
- throw new CliError("not_found", `No message ${id} in this ${from.group ? "group" : "chat"}`);
12892
- })());
13043
+ const chosen = [];
13044
+ for (const id of messageIds) {
13045
+ const message = await node(ctx).getMessage(from.linkId, id);
13046
+ if (!message) throw new CliError("not_found", `No message ${id} in this ${from.group ? "group" : "chat"}`);
13047
+ chosen.push(message);
13048
+ }
12893
13049
  if (!bool(params, "force")) for (const message of chosen) {
12894
13050
  const secret = message.file ? null : findSecret(message.text);
12895
13051
  if (secret) throw new CliError("confirm", `A message looks like ${secret.kind === "cashu" ? "a Cashu token (money anyone who reads it can take)" : "a secret (a seed or a private key)"}; forward it with --force if you mean to`, {
@@ -12923,7 +13079,7 @@ var METHODS = {
12923
13079
  await waitForGroupFrame(ctx, to[i].id, messageId, void 0, ms);
12924
13080
  continue;
12925
13081
  }
12926
- const sent = (await node(ctx).getMessages(to[i].id)).find((m) => m.id === messageId);
13082
+ const sent = await node(ctx).getMessage(to[i].id, messageId);
12927
13083
  if (sent?.file) await FILE_METHODS["file.wait"](ctx, {
12928
13084
  chat: to[i].id,
12929
13085
  file: sent.file.id,
@@ -12943,19 +13099,21 @@ var METHODS = {
12943
13099
  },
12944
13100
  async "chat.retry"(ctx, params) {
12945
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`);
12946
13104
  await node(ctx).retryMessage({
12947
13105
  linkId: link.id,
12948
- messageId: str(params, "message", true)
13106
+ messageId
12949
13107
  });
12950
13108
  return {
12951
13109
  chat: link.id,
12952
- messageId: params.message
13110
+ messageId
12953
13111
  };
12954
13112
  },
12955
13113
  async "chat.delete"(ctx, params) {
12956
13114
  const link = chatOf(ctx, params);
12957
13115
  const messageId = str(params, "message", true);
12958
- if (!(await node(ctx).getMessages(link.id)).some((m) => m.id === messageId)) throw new CliError("not_found", `No message ${messageId} in this chat`);
13116
+ if (!await node(ctx).getMessage(link.id, messageId)) throw new CliError("not_found", `No message ${messageId} in this chat`);
12959
13117
  await node(ctx).deleteMessage({
12960
13118
  linkId: link.id,
12961
13119
  messageId
@@ -13071,7 +13229,13 @@ var METHODS = {
13071
13229
  };
13072
13230
  },
13073
13231
  async "group.join"(ctx, params) {
13074
- const { groupId } = await node(ctx).joinGroupByLink({ link: str(params, "link", true) });
13232
+ let groupId;
13233
+ try {
13234
+ ({groupId} = await node(ctx).joinGroupByLink({ link: str(params, "link", true) }));
13235
+ } catch (error) {
13236
+ if (error instanceof Error && error.message === "This is not a link to a group") throw new CliError("bad_request", error.message);
13237
+ throw error;
13238
+ }
13075
13239
  return { group: groupId };
13076
13240
  },
13077
13241
  async "group.list"(ctx, params) {
@@ -13140,15 +13304,16 @@ var METHODS = {
13140
13304
  }
13141
13305
  const mentions = mentionsFor(text, list(params, "mentions"), group);
13142
13306
  const wait = oneOf(params, "wait", ["none", "sent"], "none");
13143
- const before = (await node(ctx).groupMessages({ groupId: group.id })).find((m) => m.id === messageId)?.edit?.seq ?? 0;
13307
+ const linkId = `group:${group.id}`;
13308
+ const before = (await node(ctx).getMessage(linkId, messageId))?.edit?.seq ?? 0;
13144
13309
  const result = await node(ctx).editMessage({
13145
- linkId: `group:${group.id}`,
13310
+ linkId,
13146
13311
  messageId,
13147
13312
  text,
13148
13313
  ...mentions.length ? { mentions } : {}
13149
13314
  });
13150
- if (result.error) throw editRefused(await node(ctx).groupMessages({ groupId: group.id }), messageId, "group", result.error, result.refused);
13151
- const message = (await node(ctx).groupMessages({ groupId: group.id })).find((m) => m.id === messageId);
13315
+ if (result.error) throw editRefused(await messageOf(ctx, linkId, messageId), messageId, "group", result.error, result.refused);
13316
+ const message = await node(ctx).getMessage(linkId, messageId);
13152
13317
  const edits = message?.edit?.seq ?? 0;
13153
13318
  if (edits === before) return {
13154
13319
  group: group.id,
@@ -13314,12 +13479,32 @@ function groupSendRefused(error) {
13314
13479
  return new CliError("unavailable", error);
13315
13480
  }
13316
13481
  /**
13482
+ * A chat command given a group's id, prefix or name: `not_found` that says it is a group, with the group command that
13483
+ * takes it (`react` on a group, where `group react` is meant). A name no chat and no group has stays the chat's error.
13484
+ */
13485
+ function notAGroup(ctx, params, chat, command, rest) {
13486
+ try {
13487
+ return chat();
13488
+ } catch (error) {
13489
+ if (!(error instanceof CliError) || error.code !== "not_found") throw error;
13490
+ const ref = str(params, "chat", true);
13491
+ let group;
13492
+ try {
13493
+ group = findGroup(state(ctx).groups, ref);
13494
+ } catch {
13495
+ throw error;
13496
+ }
13497
+ const shown = /^[\w.:-]+$/.test(ref) ? ref : JSON.stringify(ref);
13498
+ throw new CliError("not_found", `${JSON.stringify(ref)} is a group, not a chat: ghostly ${command} ${shown} ${rest}`, { group: group.id });
13499
+ }
13500
+ }
13501
+ /**
13317
13502
  * An edit the engine refused. A message that is not in the chat or group at all is `not_found` (exit 3), naming the id
13318
13503
  * to give, as `button update` and `task update` do; before, a mistyped or lost id read "Only your own text messages
13319
13504
  * can be edited" (`refused`, exit 1), as if the message were someone else's.
13320
13505
  */
13321
- function editRefused(messages, ref, where, error, refused) {
13322
- if (refused && !messages.some((m) => m.id === ref || m.wireId === ref)) return new CliError("not_found", `No message ${JSON.stringify(ref)} in this ${where}: give the messageId ${where === "group" ? "group send" : "send"} printed`);
13506
+ function editRefused(message, ref, where, error, refused) {
13507
+ if (refused && !message) return new CliError("not_found", `No message ${JSON.stringify(ref)} in this ${where}: give the messageId ${where === "group" ? "group send" : "send"} printed`);
13323
13508
  return new CliError(refused ? "refused" : "unavailable", error);
13324
13509
  }
13325
13510
  /**
@@ -14482,6 +14667,11 @@ var showSecret = { "show-secret": {
14482
14667
  type: "boolean",
14483
14668
  description: "Print the group's entry link (anyone who has it can join)"
14484
14669
  } };
14670
+ /** A pending chat's invite code lets whoever reads it take the chat: printed only when asked for. */
14671
+ var showInvite = { "show-secret": {
14672
+ type: "boolean",
14673
+ description: "Print a pending chat's invite code (whoever has it can take the chat)"
14674
+ } };
14485
14675
  var secret = (options) => options["show-secret"] === true ? { showSecret: true } : {};
14486
14676
  /**
14487
14677
  * `--for` of the typing commands: the seconds it is kept on, 1 to 600, and never with `--stop`. A usage error (exit 2)
@@ -15050,8 +15240,10 @@ var commands$6 = {
15050
15240
  },
15051
15241
  "chat list": {
15052
15242
  method: "chat.list",
15053
- usage: "chat list",
15054
- summary: "Chats, newest first"
15243
+ usage: "chat list [--show-secret]",
15244
+ summary: "Chats, newest first (a pending invite code only with --show-secret)",
15245
+ options: showInvite,
15246
+ params: ({ options }) => secret(options)
15055
15247
  },
15056
15248
  "chat remove": {
15057
15249
  method: "chat.remove",
@@ -15079,10 +15271,14 @@ var commands$6 = {
15079
15271
  },
15080
15272
  "chat show": {
15081
15273
  method: "chat.get",
15082
- usage: "chat show <chat>",
15083
- summary: "One chat and its connection",
15274
+ usage: "chat show <chat> [--show-secret]",
15275
+ summary: "One chat and its connection (its pending invite code only with --show-secret)",
15084
15276
  args: ["chat"],
15085
- params: (_, { chat }) => ({ chat })
15277
+ options: showInvite,
15278
+ params: ({ options }, { chat }) => ({
15279
+ chat,
15280
+ ...secret(options)
15281
+ })
15086
15282
  },
15087
15283
  "chat transport": {
15088
15284
  method: "chat.transport",
@@ -17173,6 +17369,14 @@ var refuseAnswers = Number(process.env.GHOSTLY_CALL_REFUSE_ANSWERS) || 0;
17173
17369
  var GATHER_MS = 5e3;
17174
17370
  var SETTLE_MS = 400;
17175
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
+ /**
17176
17380
  * One call's peer connection. Its SDP never travels: the compact signal does (`local`), and each side rebuilds the
17177
17381
  * other's SDP from it (`buildSdpFromSignal`), so the payload types, SSRC and mids here are the ones that rebuilt SDP
17178
17382
  * uses: audio is mid 0, Opus is 111 unless the contact's offer says otherwise.
@@ -17244,7 +17448,7 @@ var CallMedia = class CallMedia {
17244
17448
  const track = pc.addTrack(audioSection);
17245
17449
  let videoTrack = null;
17246
17450
  if (offer?.m?.includes("v")) {
17247
- const video = new ndc.Video(String(offer.m.indexOf("v")), "RecvOnly");
17451
+ const video = new ndc.Video(String(offer.m.indexOf("v")), "Inactive");
17248
17452
  video.addVP8Codec(offer.vp ?? 96);
17249
17453
  videoTrack = pc.addTrack(video);
17250
17454
  videoTrack.onMessage(() => {});
@@ -17263,6 +17467,7 @@ var CallMedia = class CallMedia {
17263
17467
  ss: [ssrc]
17264
17468
  };
17265
17469
  if (offer?.m) local.m = [...offer.m];
17470
+ if (videoTrack) local.vr = 0;
17266
17471
  const listeners = [];
17267
17472
  track.onMessage((packet) => {
17268
17473
  for (const listener of listeners) listener(packet);
@@ -17343,20 +17548,26 @@ function mdnsNote(signal, log) {
17343
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`));
17344
17549
  }
17345
17550
  }
17346
- /** 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). */
17347
17552
  function gathering(pc) {
17348
17553
  return new Promise((resolve) => {
17349
17554
  let settle = null;
17555
+ let any = false;
17350
17556
  const done = () => {
17351
17557
  clearTimeout(cap);
17558
+ clearTimeout(bounded);
17352
17559
  if (settle) clearTimeout(settle);
17353
17560
  resolve();
17354
17561
  };
17355
17562
  const cap = setTimeout(done, GATHER_MS);
17563
+ const bounded = setTimeout(() => {
17564
+ if (any) done();
17565
+ }, HOST_GATHER_MS);
17356
17566
  pc.onGatheringStateChange((state) => {
17357
17567
  if (state === "complete") done();
17358
17568
  });
17359
17569
  pc.onLocalCandidate((candidate) => {
17570
+ any = true;
17360
17571
  if (!settle && / typ srflx/.test(candidate)) settle = setTimeout(done, SETTLE_MS);
17361
17572
  });
17362
17573
  });
@@ -18012,23 +18223,15 @@ var EventHub = class {
18012
18223
  request.onsuccess = () => resolve(request.result);
18013
18224
  request.onerror = () => reject(request.error);
18014
18225
  });
18015
- const rows = await new Promise((resolve, reject) => {
18016
- const out = [];
18017
- const request = this.db.transaction(SEEN).objectStore(SEEN).openCursor();
18018
- request.onsuccess = () => {
18019
- const cursor = request.result;
18020
- if (!cursor) return resolve(out);
18021
- out.push({
18022
- key: cursor.key,
18023
- value: cursor.value
18024
- });
18025
- cursor.continue();
18026
- };
18226
+ const store = this.db.transaction(SEEN).objectStore(SEEN);
18227
+ const all = (request) => new Promise((resolve, reject) => {
18228
+ request.onsuccess = () => resolve(request.result);
18027
18229
  request.onerror = () => reject(request.error);
18028
18230
  });
18029
- for (const { key, value } of rows) {
18030
- const [chat, id] = key;
18031
- 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]);
18032
18235
  }
18033
18236
  this.mentionsNoted = this.firstRun || this.seen.get(FORMAT)?.get(MENTIONS_NOTED) === "yes";
18034
18237
  }
@@ -18514,8 +18717,13 @@ var EventHub = class {
18514
18717
  }
18515
18718
  if (changes.length) this.persist(key, changes);
18516
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
+ */
18517
18725
  persist(chat, changes) {
18518
- const store = this.db.transaction(SEEN, "readwrite").objectStore(SEEN);
18726
+ const store = this.db.transaction(SEEN, "readwrite", { durability: "relaxed" }).objectStore(SEEN);
18519
18727
  for (const [id, state] of changes) if (state === null) store.delete([chat, id]);
18520
18728
  else store.put(state, [chat, id]);
18521
18729
  }
@@ -18890,7 +19098,7 @@ function federationInfo(federationId, config) {
18890
19098
  };
18891
19099
  }
18892
19100
  /** The first value a subscription gives that is final, or the last one within `waitMs`. */
18893
- function watch(subscribe, final, waitMs) {
19101
+ function watch$1(subscribe, final, waitMs) {
18894
19102
  return new Promise((resolve) => {
18895
19103
  let last, done = false, cancel = () => {};
18896
19104
  const finish = () => {
@@ -18961,13 +19169,13 @@ var RealClient = class {
18961
19169
  return this.wallet.mint.tryCancelSpendNotes(operationId);
18962
19170
  }
18963
19171
  spendState(operationId, waitMs) {
18964
- 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);
18965
19173
  }
18966
19174
  redeem(notes, ghostly) {
18967
19175
  return this.wallet.mint.reissueExternalNotes(notes, { ghostly });
18968
19176
  }
18969
19177
  redeemState(operationId, waitMs) {
18970
- 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) => {
18971
19179
  ok("failed");
18972
19180
  fail(error);
18973
19181
  }), (s) => s === "done" || s === "failed", waitMs);
@@ -18980,7 +19188,7 @@ var RealClient = class {
18980
19188
  };
18981
19189
  }
18982
19190
  receiveState(operationId, waitMs) {
18983
- 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);
18984
19192
  }
18985
19193
  async gatewayFee(amountMsats) {
18986
19194
  await this.wallet.lightning.updateGatewayCache().catch(() => {});
@@ -18998,7 +19206,7 @@ var RealClient = class {
18998
19206
  };
18999
19207
  }
19000
19208
  async payState(operationId, internal, waitMs) {
19001
- 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);
19002
19210
  if (state && typeof state === "object") {
19003
19211
  const s = state;
19004
19212
  if (s.success?.preimage || s.preimage) return {
@@ -19333,6 +19541,10 @@ var SNAPSHOT = "snapshot.bin";
19333
19541
  var JOURNAL = "journal.bin";
19334
19542
  /** A journal past this is folded into the snapshot at the next quiet moment. */
19335
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;
19336
19548
  var BLOB = "__ghostlyBlob";
19337
19549
  var RANGE = "__ghostlyRange";
19338
19550
  var PROBE = "__ghostly_storage_probe__";
@@ -19348,18 +19560,21 @@ async function openPersistentIndexedDb(dir) {
19348
19560
  const factory = new IDBFactory();
19349
19561
  const raw = factory;
19350
19562
  const classes = await probeClasses(factory);
19563
+ dropIndexEntriesByRecord(classes, (a, b) => factory.cmp(a, b));
19351
19564
  let writing = true;
19352
19565
  let restoring = true;
19353
19566
  let journalFd = null;
19354
19567
  let queue = Promise.resolve();
19355
19568
  let queued = 0;
19356
19569
  let schemaDirty = false;
19570
+ /** Set while the journal holds relaxed entries no sync has covered yet. */
19571
+ let relaxed = null;
19357
19572
  const pending = /* @__PURE__ */ new WeakMap();
19358
19573
  const snapshotPath = join(dir, SNAPSHOT);
19359
19574
  const journalPath = join(dir, JOURNAL);
19360
19575
  const snapshotFound = existsSync(snapshotPath), journalFound = existsSync(journalPath);
19361
- if (snapshotFound) restore(classes, raw._databases, deserialize(readFileSync(snapshotPath)), (a, b) => factory.cmp(a, b));
19362
- 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)));
19363
19578
  restoring = false;
19364
19579
  if (journalFound || !snapshotFound) await writeSnapshot();
19365
19580
  const storeProto = classes.ObjectStore.prototype;
@@ -19406,7 +19621,7 @@ async function openPersistentIndexedDb(dir) {
19406
19621
  if (this.mode === "versionchange") schemaDirty = true;
19407
19622
  else {
19408
19623
  const ops = pending.get(this._rollbackLog);
19409
- if (ops?.length) append(ops);
19624
+ if (ops?.length) append(ops, this.durability === "relaxed");
19410
19625
  }
19411
19626
  pending.delete(this._rollbackLog);
19412
19627
  if (schemaDirty) enqueue(writeSnapshot);
@@ -19428,34 +19643,54 @@ async function openPersistentIndexedDb(dir) {
19428
19643
  });
19429
19644
  return request;
19430
19645
  };
19431
- function append(ops) {
19646
+ function append(ops, lazy) {
19432
19647
  if (queued === 0 && !ops.some((op) => op.kind === "put" && hasBlob(op.value))) {
19433
- writeEntry(serialize(ops));
19648
+ writeEntry(serialize(ops), lazy);
19434
19649
  return;
19435
19650
  }
19436
19651
  enqueue(async () => writeEntry(serialize(await Promise.all(ops.map(async (op) => op.kind === "put" ? {
19437
19652
  ...op,
19438
19653
  value: await blobsToDisk(op.value)
19439
- } : op)))));
19654
+ } : op))), lazy));
19440
19655
  }
19441
19656
  function enqueue(work) {
19442
19657
  queued++;
19443
- queue = queue.then(work).catch((error) => {
19444
- process.stderr.write(`ghostly: storage write failed: ${error instanceof Error ? error.message : String(error)}\n`);
19445
- }).finally(() => {
19658
+ queue = queue.then(work).catch(failed).finally(() => {
19446
19659
  queued--;
19447
19660
  });
19448
19661
  return queue;
19449
19662
  }
19450
- 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) {
19451
19672
  if (!writing) return;
19452
19673
  journalFd ??= openSync(journalPath, "a", 384);
19453
19674
  const header = Buffer.alloc(4);
19454
19675
  header.writeUInt32BE(entry.length);
19455
19676
  writeSync(journalFd, Buffer.concat([header, entry]));
19456
- 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();
19457
19685
  if (statSync(journalPath).size > COMPACT_BYTES) enqueue(writeSnapshot);
19458
19686
  }
19687
+ function sync() {
19688
+ if (relaxed) {
19689
+ clearTimeout(relaxed);
19690
+ relaxed = null;
19691
+ }
19692
+ if (journalFd !== null) fsyncSync(journalFd);
19693
+ }
19459
19694
  async function writeSnapshot() {
19460
19695
  schemaDirty = false;
19461
19696
  const snapshot = {
@@ -19498,12 +19733,29 @@ async function openPersistentIndexedDb(dir) {
19498
19733
  closeSync(fd);
19499
19734
  }
19500
19735
  renameSync(temporary, snapshotPath);
19736
+ if (relaxed) {
19737
+ clearTimeout(relaxed);
19738
+ relaxed = null;
19739
+ }
19501
19740
  if (journalFd !== null) {
19502
19741
  closeSync(journalFd);
19503
19742
  journalFd = null;
19504
19743
  }
19505
19744
  rmSync(journalPath, { force: true });
19506
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
+ }
19507
19759
  const installed = globalThis;
19508
19760
  installed.indexedDB = factory;
19509
19761
  installed.IDBKeyRange = IDBKeyRange$1;
@@ -19512,6 +19764,7 @@ async function openPersistentIndexedDb(dir) {
19512
19764
  flush: () => queue,
19513
19765
  compact: () => enqueue(writeSnapshot),
19514
19766
  async close() {
19767
+ await settled();
19515
19768
  await enqueue(writeSnapshot);
19516
19769
  writing = false;
19517
19770
  if (journalFd !== null) {
@@ -19535,7 +19788,8 @@ async function probeClasses(factory) {
19535
19788
  const classes = {
19536
19789
  Database: rawDb.constructor,
19537
19790
  ObjectStore: rawStore.constructor,
19538
- Index: rawStore.rawIndexes.get("i").constructor
19791
+ Index: rawStore.rawIndexes.get("i").constructor,
19792
+ RecordStore: rawStore.records.constructor
19539
19793
  };
19540
19794
  db.close();
19541
19795
  await new Promise((resolve) => {
@@ -19544,6 +19798,108 @@ async function probeClasses(factory) {
19544
19798
  });
19545
19799
  return classes;
19546
19800
  }
19801
+ /** The classes whose index upkeep is already by record: they are fake-indexeddb's own, shared by every store opened. */
19802
+ var byRecord = /* @__PURE__ */ new WeakSet();
19803
+ /**
19804
+ * fake-indexeddb drops an overwritten or deleted record from an index by walking every entry of it (an index is in
19805
+ * its own key's order, not the record's): each delivery mark, edit or reaction cost time with every message of the
19806
+ * profile, 170 ms at 10,000, and a long chat slowed every other one. The store has the records it just replaced or
19807
+ * removed in hand, and they say which entries to drop; a call that comes any other way still walks.
19808
+ */
19809
+ function dropIndexEntriesByRecord(classes, cmp) {
19810
+ const storeProto = classes.ObjectStore.prototype;
19811
+ const recordsProto = classes.RecordStore.prototype;
19812
+ if (byRecord.has(storeProto)) return;
19813
+ byRecord.add(storeProto);
19814
+ const { storeRecord, deleteRecord } = storeProto;
19815
+ const { put, delete: remove, deleteByValue } = recordsProto;
19816
+ let gone = null;
19817
+ storeProto.storeRecord = function(record, noOverwrite, rollbackLog) {
19818
+ if (!this.rawIndexes.size) return storeRecord.call(this, record, noOverwrite, rollbackLog);
19819
+ const records = this.records, indexes = this.rawIndexes, outer = gone;
19820
+ records.put = (next, keep) => {
19821
+ const replaced = put.call(this.records, next, keep);
19822
+ gone = {
19823
+ indexes,
19824
+ records: replaced ? [replaced] : []
19825
+ };
19826
+ return replaced;
19827
+ };
19828
+ try {
19829
+ return storeRecord.call(this, record, noOverwrite, rollbackLog);
19830
+ } finally {
19831
+ delete records.put;
19832
+ gone = outer;
19833
+ }
19834
+ };
19835
+ storeProto.deleteRecord = function(key, rollbackLog) {
19836
+ if (!this.rawIndexes.size) return deleteRecord.call(this, key, rollbackLog);
19837
+ const records = this.records, indexes = this.rawIndexes, outer = gone;
19838
+ records.delete = (range) => {
19839
+ const removed = remove.call(this.records, range);
19840
+ gone = {
19841
+ indexes,
19842
+ records: removed
19843
+ };
19844
+ return removed;
19845
+ };
19846
+ try {
19847
+ deleteRecord.call(this, key, rollbackLog);
19848
+ } finally {
19849
+ delete records.delete;
19850
+ gone = outer;
19851
+ }
19852
+ };
19853
+ recordsProto.deleteByValue = function(key) {
19854
+ let index;
19855
+ if (gone) {
19856
+ for (const candidate of gone.indexes.values()) if (candidate.records === this) index = candidate;
19857
+ }
19858
+ if (!gone || !index) return deleteByValue.call(this, key);
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;
19895
+ }
19896
+ return a.length === b.length ? 0 : a.length > b.length ? 1 : -1;
19897
+ }
19898
+ if (Array.isArray(b) && (first === "number" || first === "string")) return -1;
19899
+ return cmp(a, b);
19900
+ };
19901
+ return order;
19902
+ }
19547
19903
  /**
19548
19904
  * The snapshot's records come in key order (as `writeSnapshot` walks them), so each store's tree and each index's are
19549
19905
  * built whole from sorted arrays: storing record by record compared every key down the tree, a 70,000-message profile
@@ -19639,21 +19995,27 @@ function fill(store, sorted) {
19639
19995
  /**
19640
19996
  * Replays whole entries; a torn last entry (the process died mid-write) was never acknowledged and is dropped.
19641
19997
  *
19642
- * 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
19643
19999
  * overwritten or deleted record from an index by walking every record of it, so a crash after a busy chat took
19644
- * 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.
19645
20003
  */
19646
- function replay(databases, journal) {
20004
+ function replay(databases, journal, cmp) {
19647
20005
  const touched = /* @__PURE__ */ new Map();
19648
20006
  try {
19649
20007
  replayEntries(databases, journal, touched);
19650
20008
  } finally {
19651
- 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;
19652
20013
  store.rawIndexes = indexes;
19653
20014
  for (const index of indexes.values()) {
19654
- index.records.clear();
20015
+ if (!was) index.records.clear();
19655
20016
  if (!index.initialized) continue;
19656
- 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);
19657
20019
  }
19658
20020
  }
19659
20021
  }
@@ -19673,19 +20035,50 @@ function replayEntries(databases, journal, touched) {
19673
20035
  for (const op of ops) {
19674
20036
  const store = databases.get(op.db)?.rawObjectStores.get(op.store);
19675
20037
  if (!store) continue;
19676
- if (!touched.has(store)) {
19677
- touched.set(store, store.rawIndexes);
19678
- store.rawIndexes = /* @__PURE__ */ new Map();
19679
- }
20038
+ let change = touched.get(store);
20039
+ if (!change) touched.set(store, change = watch(store));
19680
20040
  if (op.kind === "put") store.storeRecord({
19681
20041
  key: op.key,
19682
20042
  value: blobsFromDisk(op.value)
19683
20043
  }, false);
19684
20044
  else if (op.kind === "delete") store.deleteRecord(keyFromDisk(op.key));
19685
- else store.clear();
20045
+ else {
20046
+ change.was = null;
20047
+ store.clear();
20048
+ }
19686
20049
  }
19687
20050
  }
19688
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
+ }
19689
20082
  function keyToDisk(key) {
19690
20083
  if (key instanceof IDBKeyRange$1) return { [RANGE]: {
19691
20084
  lower: key.lower,
@@ -19939,14 +20332,14 @@ async function startRuntime(paths, options = {}) {
19939
20332
  (await import("./assets/ghostly_iroh_web-Dvzh3Y6E.js")).initSync({ module: irohWasmBytes() });
19940
20333
  const [{ registerFileBytes }, { NodeFileBytes }] = await Promise.all([import("./assets/fileBytes-BcYZhNgF.js").then((n) => n.o), import("./assets/fileBytes-Dbifu5I5.js")]);
19941
20334
  registerFileBytes("native", async () => new NodeFileBytes(paths.files), true);
19942
- const [{ EngineServer }, { createHyperEndpoint }] = await Promise.all([import("./assets/server-D5FBZYW8.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")]);
19943
20336
  const network = hyperdhtNetwork();
19944
20337
  const dht = mainlineNetwork();
19945
- const mainline = dht.off ? null : new (await (import("./assets/mainline-DVYdrus6.js"))).Mainline({
20338
+ const mainline = dht.off ? null : new (await (import("./assets/mainline-DriRu_hb.js"))).Mainline({
19946
20339
  bootstrap: dht.bootstrap,
19947
20340
  host: dht.host
19948
20341
  });
19949
- const transport = mainline || pinned ? new (await (import("./assets/mainline-DVYdrus6.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;
19950
20343
  const server = new EngineServer({
19951
20344
  ...transport ? { transport } : {},
19952
20345
  irohWeb: true,
@@ -20091,10 +20484,14 @@ var guarded = false;
20091
20484
  /**
20092
20485
  * A daemon says an error nobody caught (with its stack, on stderr: the daemon's log when detached) and keeps serving:
20093
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).
20094
20491
  */
20095
20492
  function keepServing(proc = process, write = (text) => {
20096
20493
  process.stderr.write(text);
20097
- }) {
20494
+ }, outputs = proc === process ? [process.stdout, process.stderr] : []) {
20098
20495
  if (proc === process) {
20099
20496
  if (guarded) return;
20100
20497
  guarded = true;
@@ -20104,6 +20501,7 @@ function keepServing(proc = process, write = (text) => {
20104
20501
  write(`ghostly: ${what}, the daemon keeps serving: ${error instanceof Error ? error.stack ?? error.message : String(error)}\n`);
20105
20502
  } catch {}
20106
20503
  };
20504
+ for (const output of outputs) output.on("error", () => {});
20107
20505
  proc.on("unhandledRejection", log("unhandled rejection"));
20108
20506
  proc.on("uncaughtException", log("uncaught exception"));
20109
20507
  }
@@ -20307,6 +20705,18 @@ function readCursor(path) {
20307
20705
  const value = Number(readFileSync(path, "utf8").trim());
20308
20706
  return Number.isInteger(value) && value >= 0 ? value : void 0;
20309
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
+ }
20310
20720
  function writeCursor(path, seq) {
20311
20721
  rmSync(path + ".tmp", { force: true });
20312
20722
  writeFileSync(path + ".tmp", String(seq) + "\n", {
@@ -20603,7 +21013,7 @@ var SPECIAL = [
20603
21013
  "The profile's settings",
20604
21014
  { "show-secret": o("boolean", "Show credentials too") }
20605
21015
  ],
20606
- ["settings set <key> <json-value>", "Change one: relays, irohRelays, hyperdhtRelay, readRelays, iceServers, publicProfiles, online, shareProfile, sendTyping, nick"],
21016
+ ["settings set <key> <json-value>", "Change one: relays, irohRelays, hyperdhtRelay, readRelays, iceServers, publicProfiles, online, shareProfile, sendTyping, autoDownloads, serveFiles, nick"],
20607
21017
  [
20608
21018
  "engine <method> [json-params | -] [--confirm-real] [--show-secret]",
20609
21019
  "Any call of the app's engine, with its own parameters",
@@ -20649,6 +21059,14 @@ function help() {
20649
21059
  "2 usage, 3 not found, 4 timed out, 5 needs --force/--yes/--confirm-real. See packages/cli/README.md."
20650
21060
  ].join("\n");
20651
21061
  }
21062
+ /** What `ghostly help help` answers. */
21063
+ var HELP_USAGE = [
21064
+ "Usage: ghostly help [<command>...]",
21065
+ "",
21066
+ " Help for every command, or for one command or a group of them (also ghostly <command> --help)",
21067
+ "",
21068
+ "Options for every command: --profile, -p <name> --home <dir> --pretty --help, -h"
21069
+ ].join("\n");
20652
21070
  /** Every command's help row: usage, summary and, for the table and text commands, their options. */
20653
21071
  function helpRows() {
20654
21072
  return [
@@ -20675,6 +21093,7 @@ function commandWords(usage) {
20675
21093
  * options; for a group (`ghostly help file`), the group's commands.
20676
21094
  */
20677
21095
  function commandHelp(words) {
21096
+ if (words[0] === "help") return HELP_USAGE;
20678
21097
  const named = (list) => helpRows().filter((row) => {
20679
21098
  const own = commandWords(row.usage);
20680
21099
  return list.every((word, i) => own[i] === word);
@@ -20954,32 +21373,42 @@ async function daemonCommand(argv) {
20954
21373
  }
20955
21374
  if (sub === "stop") {
20956
21375
  knownProfile(g);
21376
+ const stopFor = seconds(parsed, 20);
20957
21377
  const client = await connectDaemon(g.paths.socket);
20958
21378
  if (!client) throw new CliError("not_found", `No daemon runs profile ${g.profile}`);
20959
21379
  print({
20960
21380
  stopped: true,
20961
- pid: await stopDaemon(g, client, Number(parsed.options.timeout) || 20)
21381
+ pid: await stopDaemon(g, client, stopFor)
20962
21382
  });
20963
21383
  return;
20964
21384
  }
20965
21385
  if (sub === "restart") {
21386
+ const stopFor = seconds(parsed, 20), startFor = seconds(parsed, 60);
20966
21387
  const client = await connectDaemon(g.paths.socket);
20967
21388
  const before = client ? (await client.call("status").catch(() => null))?.version ?? null : null;
20968
21389
  print({
20969
21390
  restarted: true,
20970
- stopped: client ? await stopDaemon(g, client, Number(parsed.options.timeout) || 20) : null,
21391
+ stopped: client ? await stopDaemon(g, client, stopFor) : null,
20971
21392
  before,
20972
21393
  version: VERSION,
20973
- ...await startDetached(g, Number(parsed.options.timeout) || 60)
21394
+ ...await startDetached(g, startFor)
20974
21395
  });
20975
21396
  return;
20976
21397
  }
20977
21398
  if (sub) throw new CliError("usage", "ghostly daemon [--detach] | daemon status | daemon stop | daemon restart");
21399
+ const startFor = seconds(parsed, 60);
20978
21400
  if (!parsed.options.detach) {
20979
21401
  await runDaemon(g);
20980
21402
  return;
20981
21403
  }
20982
- print(await startDetached(g, Number(parsed.options.timeout) || 60));
21404
+ print(await startDetached(g, startFor));
21405
+ }
21406
+ /** `--timeout <s>`, or `fallback` without one: seconds above 0 (0 or less would not wait at all). */
21407
+ function seconds(parsed, fallback) {
21408
+ const value = parsed.options.timeout;
21409
+ if (value === void 0) return fallback;
21410
+ if (typeof value !== "number" || !(value > 0)) throw new CliError("usage", "--timeout takes a number of seconds above 0");
21411
+ return value;
20983
21412
  }
20984
21413
  /**
20985
21414
  * Asks the daemon to stop and waits until its process is gone, not only until it let go of the profile (it releases
@@ -21039,6 +21468,10 @@ async function startDetached(g, seconds) {
21039
21468
  }
21040
21469
  if (!client) {
21041
21470
  heldBy(g, child.pid);
21471
+ if (child.exitCode === null && child.pid !== void 0 && processAlive(child.pid)) throw new CliError("timeout", `The daemon (process ${child.pid}) is still starting; ghostly daemon status shows when it is up`, {
21472
+ pid: child.pid,
21473
+ log: g.paths.log
21474
+ });
21042
21475
  throw new CliError("engine", `The daemon did not start; see ${g.paths.log}`);
21043
21476
  }
21044
21477
  client.close();
@@ -21089,6 +21522,7 @@ async function listenCommand(argv) {
21089
21522
  description: ""
21090
21523
  }
21091
21524
  });
21525
+ noMoreThan(0, parsed.positionals, SPECIAL.find(([usage]) => usage.startsWith("listen "))[0]);
21092
21526
  const g = globals(parsed);
21093
21527
  pretty = false;
21094
21528
  if (parsed.options.webhook) checkWebhook(String(parsed.options.webhook));
@@ -21097,6 +21531,7 @@ async function listenCommand(argv) {
21097
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");
21098
21532
  const from = parsed.options.from ?? [], groups = parsed.options.group ?? [];
21099
21533
  const cursor = parsed.options.cursor;
21534
+ checkCursor(cursor);
21100
21535
  let since = parsed.options.since ?? readCursor(cursor);
21101
21536
  let handle = null;
21102
21537
  const start = async (call, peerOf) => {
@@ -21226,17 +21661,7 @@ async function engineCommand(argv) {
21226
21661
  params,
21227
21662
  confirmReal
21228
21663
  }));
21229
- if (method === "getState" && !showSecret && result && typeof result === "object") {
21230
- const state = result;
21231
- result = {
21232
- ...state,
21233
- settings: redactSettings(state.settings),
21234
- ...state.groups ? { groups: state.groups.map((g) => g.entryLink ? {
21235
- ...g,
21236
- entryLink: "<hidden>"
21237
- } : g) } : {}
21238
- };
21239
- }
21664
+ if (method === "getState" && !showSecret && result && typeof result === "object") result = hideStateSecrets(result);
21240
21665
  print(result);
21241
21666
  }
21242
21667
  async function settingsCommand(sub, argv) {
@@ -21469,6 +21894,7 @@ async function main(input) {
21469
21894
  return 0;
21470
21895
  }
21471
21896
  if (argv[0] === "--version" || argv[0] === "version") {
21897
+ pretty = argv.slice(1).some((word) => word === "--pretty" || word === "--pretty=true");
21472
21898
  print({ version: VERSION });
21473
21899
  return 0;
21474
21900
  }
@@ -21564,7 +21990,29 @@ async function run(argv) {
21564
21990
  }
21565
21991
  }
21566
21992
  //#endregion
21993
+ //#region src/runtime/webStorage.ts
21994
+ /**
21995
+ * Node 25 and newer define `globalThis.localStorage` as a getter that, without `--localstorage-file`, answers
21996
+ * `undefined` and writes an ExperimentalWarning on stderr the first time it is read. The engine reads it where a
21997
+ * browser keeps a setting (`globalThis.localStorage?.getItem(...)`), so every command that opens a profile printed the
21998
+ * warning. Without the flag the CLI has no localStorage, as on Node 22: the getter is replaced by `undefined` before
21999
+ * anything reads it. With the flag, Node's storage stays.
22000
+ */
22001
+ function quietWebStorage(scope = globalThis, execArgv = process.execArgv, nodeOptions = process.env.NODE_OPTIONS) {
22002
+ const own = Object.getOwnPropertyDescriptor(scope, "localStorage");
22003
+ if (!own?.get || !own.configurable) return;
22004
+ const flag = (arg) => arg === "--localstorage-file" || arg.startsWith("--localstorage-file=");
22005
+ if (execArgv.some(flag) || (nodeOptions ?? "").split(/\s+/).some(flag)) return;
22006
+ Object.defineProperty(scope, "localStorage", {
22007
+ value: void 0,
22008
+ writable: true,
22009
+ configurable: true,
22010
+ enumerable: false
22011
+ });
22012
+ }
22013
+ //#endregion
21567
22014
  //#region src/bin.ts
22015
+ quietWebStorage();
21568
22016
  run(process.argv.slice(2));
21569
22017
  //#endregion
21570
22018
  export { BACKUP_MEDIA_TYPE as $, requireTurn as A, paymentWireId as At, networkLabel as B, breezDatabasesFor as C, storedGroupFiles as Ct, sourceKey as D, replyRef as Dt, ProviderSources as E, receivedPairedReply as Et, sealSeed as F, streamStored as Ft, BITCOIN_INVOICE_ON_TESTNET as G, cashuMint as H, unsealSeed as I, fakesLightning as J, DEFAULT_MINTS as K, ModeChanged as L, setSingleWriterGate as M, readStored as Mt, intentRepository as N, removeStored as Nt, awayFrom as O, replyTo as Ot, newDeviceKey as P, storedSize as Pt, paysItsOwnInvoices as Q, ModeGate as R, breezDatabaseInUse as S, groupOfFile as St, lightningPaying as T, pairedWireReply as Tt, MESSAGE_DETAILS_MAX_BYTES as U, CASHU_MINT_SOURCE as V, WALLET_TYPES as W, isWorthlessMint as X, isTestMint as Y, mintNetwork as Z, LightningCards as _, verifyIdentity as _t, irohRelayProblem as a, reserveAdapterIds as at, normalizePhrase as b, groupFileId as bt, createFailure as c, SourceConfigError as ct, paymentNetwork as d, networkMode as dt, MAX_BACKUP_BYTES as et, paymentNetworksOf as f, redact as ft, CASHU_CARD as g, boundedIdentityFetch as gt, rpcHost as h, setOwnDidSource as ht, createIrohWebEndpoint as i, registeredOnchainProviders as it, setAwayWallets as j, FileAppender as jt, refuseAway as k, paymentAlias as kt, createTiming as l, SourceUnreachableError as lt, networkReason as m, resolveAtprotoDid as mt, loadFedimintSdk as n, onAdaptersChanged as nt, WALLET_NAMES as o, NothingSpentError as ot, walletInstances as p, assertPublicServer as pt, TEST_MINT as q, DEFAULT_IROH_RELAYS as r, registeredLightningProviders as rt, assertConfirmedReal as s, PROVIDER_PLATFORMS as st, fedimintDatabase as t, identityProviders as tt, crossNetwork as u, isNothingSpentError as ut, cardsKey as v, chosenResolver as vt, dropBreezDatabase as w, groupReply as wt, breezDatabase as x, groupFileText as xt, isRecoveryPhrase as y, GroupFileDesk as yt, WrongNetworkError as z };