@ghostlytools/cli 1.2.1 → 1.2.2

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,6 +1,6 @@
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 { $ as groupName, $a as APP_PREFIXES, $c as sanitizeAvatar, $n as readGroupWantNo, At as checkDidSignature, Ba as RUN_RESULTS, Bo as signalHasVideo, Bt as parseDid, Ci as recordsFromWellKnown, Cn as pubkyProofFile, Ct as AtprotoProofError, Dn as requestLnurlInvoice, Dr as ChatFiles, Ei as wellKnownUrl, En as pubkyHomeserverOf, Et as DID_FILE_MAX_BYTES, Fa as replySnippet, Fo as buildSdpFromSignal, Ft as didWebFile, Ga as checkStatusCard, Gc as openRelayPayload, Gt as strictBase64Url, Ha as buttonLabelClash, Hc as RELAY_PAYLOAD_MAX_BYTES, Hn as engineText, Ht as parseDidSignaturePaste, Ic as setLinkTraceSink, Ii as boundVerifiedIdentity, Io as callRtcConfig, It as didWebFileNames, Jn as groupFileFallback, Kn as GROUP_WANT_NO_FRAME, La as ITEM_STATES, Lo as callSignalHeardAt, Lt as didWebFileUrl, Mo as CALL_SIGNAL_MAX_AGE_MS, Mr as TYPING_REFRESH_MS, Mt as didJwsAlg, No as RTC_CONFIG, Nt as didSignedMessages, On as resolveLightningDestination, Ot as DidError, Pi as IdentityCheckUnavailable, Po as answersOffer, Pr as sanitizeTypingStatus, Pt as didWebDocumentUrl, Qc as MAX_AVATAR_LENGTH, Qn as readGroupWant, Ra as PR_CHECKS, Ro as extractParamsFromSdp, Rt as didWebHost, Si as recordsFromTxt, Sn as pubkyProofCapability, St as shortAtprotoDid, Ti as txtValue, Tn as homeserverWebEndpoint, To as GhostlyHttpError, Tt as DID_DOCUMENT_MAX_BYTES$1, Ua as cardLine, Un as GROUP_FILE_LIMITS, Ut as signingKeys, Va as STATUS_CARD_LIMITS, Vn as engineError, Vt as parseDidDocument, Wa as cardUrl, Wn as GROUP_HAVE_FRAME, Wt as staticDidDocument, Xi as irohRelayUrl, Xr as checkPushEndpoint, Xt as resolveDidDht, Yi as irohDescriptor, Yn as isGroupMessageId, Zn as readGroupHave, _ as checkAppManifest, _a as RateWindow, _i as domainWellKnownFile, _n as PUBKY_PROOF_MAX_BYTES, _t as atprotoProofRkey, ac as inviteLink, al as utf8Decode, at as sameSshKey, bi as nip05RootKey, bn as newPubkyProofFolder, bt as normalizeAtprotoHandle, c as readAppStore, ca as PLAYABLE_AUDIO, co as signAppObject, ct as verifyBitcoinMessage, d as APP_BUNDLE_LIMITS, da as fileMessageText, dn as walletNetworkOf, do as canonicalJsonBytes, dt as ATPROTO_DECLARED_SCOPE, eo as APP_STATEMENT_LIMITS, fa as parseVoiceMeta, ft as ATPROTO_FULL_SCOPE, g as assembleAppBundle, gc as seedSigner, gi as domainTxtRecord, gt as atprotoProofRecord, hi as dnsProofName, ht as atprotoDidMethod, il as toZ32, it as parseSshSignature, jr as TYPING_KINDS, jt as didGhostlyServiceEntry, kn as decodeBolt11, ko as LIMITS, kr as transferEnded, kt as checkDidJws, la as VOICE_LIMITS, lo as signAppRevocation, lt as BitcoinAddressError, mi as decodeDnsResponse, mt as ATPROTO_PROOF_SCOPE, n as appStoreDecision, nl as randomBytes$1, no as appRef, nt as SshSigError, oa as sanitizeFileName, ol as utf8Encode, on as WALLET_NETWORKS, ot as sshSignCommand, pa as voicePeaksOf, pi as DOMAIN_FILE_MAX_BYTES, pt as ATPROTO_PROOF_COLLECTION, qn as fetchesByItself, ra as readImageMeta, rl as toBase64Url, ro as checkAppRevokeStatement, rt as parseSshPublicKey, s as checkAppStoreIndex, sa as sanitizeMime, so as readAppRevocations, st as verifySshSignature, ti as BUTTONS_CAPABILITY, tl as fromBase64Url$1, to as appFingerprint, tt as SSHSIG_NAMESPACE, u as signAppStore, ua as baseMime, uo as canonicalJson, ut as decodeAnyBitcoinAddress, v as isAppUrl, vi as encodeDnsQuery, vn as isPubkyKey, vt as checkAtprotoProofRecord, wi as recordsName, wn as pubkyProofPath, wt as verifyAtprotoRecordProof, xi as normalizeDomain, xn as normalizePubkyKey, xt as parseAtprotoDidDocument, y as readAppBundle, yi as isPublicAddress, yn as isPubkyProofFolder, yt as isAtprotoDid, za as PR_STATES, zi as identityStatement, zo as parseCallSignal, zt as normalizeDid } from "./assets/src-XzcHzneo.js";
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";
@@ -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.2",
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,6 +1272,56 @@ 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
1326
  * one member in `announceWindowMs` (past that they would get the text, not the file); null when one may go now.
1235
1327
  */
@@ -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;
@@ -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 ?? [],
@@ -12533,6 +12648,25 @@ function checkTextSize(text) {
12533
12648
  max: LIMITS.maxChatMessageBytes
12534
12649
  });
12535
12650
  }
12651
+ /**
12652
+ * The engine's state as `engine getState` prints it without `--show-secret`: the settings' credentials, a group's
12653
+ * entry link (anyone who reads it can join) and a pending chat's invite code (whoever reads it can take the chat)
12654
+ * are `<hidden>`.
12655
+ */
12656
+ function hideStateSecrets(state) {
12657
+ return {
12658
+ ...state,
12659
+ settings: redactSettings(state.settings),
12660
+ ...state.groups ? { groups: state.groups.map((g) => g.entryLink ? {
12661
+ ...g,
12662
+ entryLink: "<hidden>"
12663
+ } : g) } : {},
12664
+ ...state.links ? { links: state.links.map((l) => l.inviteCode ? {
12665
+ ...l,
12666
+ inviteCode: "<hidden>"
12667
+ } : l) } : {}
12668
+ };
12669
+ }
12536
12670
  /** Settings as a bot may read them: storage credentials and other secrets masked unless asked for. */
12537
12671
  function redactSettings(settings, showSecret = false) {
12538
12672
  if (showSecret) return settings;
@@ -12560,7 +12694,9 @@ var SETTABLE = {
12560
12694
  online: "boolean",
12561
12695
  shareProfile: "boolean",
12562
12696
  sendTyping: "boolean",
12563
- nick: "string"
12697
+ nick: "string",
12698
+ autoDownloads: "boolean",
12699
+ serveFiles: "boolean"
12564
12700
  };
12565
12701
  var TRANSPORT_NAMES = {
12566
12702
  auto: "auto",
@@ -12723,13 +12859,14 @@ var METHODS = {
12723
12859
  node(ctx).setActiveLink({ linkId });
12724
12860
  return { chat: linkId };
12725
12861
  },
12726
- async "chat.list"(ctx) {
12727
- return { chats: state(ctx).links.map(chatJson).sort((a, b) => b.lastMessageAt - a.lastMessageAt || b.createdAt - a.createdAt) };
12862
+ async "chat.list"(ctx, params) {
12863
+ const showSecret = bool(params, "showSecret");
12864
+ return { chats: state(ctx).links.map((link) => chatJson(link, showSecret)).sort((a, b) => b.lastMessageAt - a.lastMessageAt || b.createdAt - a.createdAt) };
12728
12865
  },
12729
12866
  async "chat.get"(ctx, params) {
12730
12867
  const link = chatOf(ctx, params);
12731
12868
  return {
12732
- ...chatDetailsJson(link),
12869
+ ...chatDetailsJson(link, bool(params, "showSecret")),
12733
12870
  heldUntil: holdOf(ctx, link.id)
12734
12871
  };
12735
12872
  },
@@ -12795,7 +12932,7 @@ var METHODS = {
12795
12932
  messageId,
12796
12933
  text
12797
12934
  });
12798
- if (result.error) throw editRefused(await node(ctx).getMessages(link.id), messageId, "chat", result.error, result.refused);
12935
+ if (result.error) throw editRefused(await messageOf(ctx, link.id, messageId), messageId, "chat", result.error, result.refused);
12799
12936
  const id = result.messageId ?? messageId;
12800
12937
  let message = await node(ctx).getMessage(link.id, id);
12801
12938
  if (wait === "confirmed" && message?.edit?.pending) message = await waitForEdit(ctx, link.id, id, num(params, "timeout", 30, {
@@ -12849,7 +12986,7 @@ var METHODS = {
12849
12986
  },
12850
12987
  /** WISP 400 § Reactions: one emoji per person per message, a new one replaces mine; `remove` takes mine back. */
12851
12988
  async "chat.react"(ctx, params) {
12852
- const link = chatOf(ctx, params);
12989
+ const link = notAGroup(ctx, params, () => chatOf(ctx, params), "group react", "<message> <emoji>");
12853
12990
  return {
12854
12991
  chat: link.id,
12855
12992
  ...await react(ctx, link.id, params)
@@ -12886,10 +13023,12 @@ var METHODS = {
12886
13023
  if (!messageIds.length) throw new CliError("bad_request", "Name the messages to forward (their ids, from history or an event)");
12887
13024
  const to = list(params, "to").map((ref) => chatOrGroup(ctx, ref));
12888
13025
  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
- })());
13026
+ const chosen = [];
13027
+ for (const id of messageIds) {
13028
+ const message = await node(ctx).getMessage(from.linkId, id);
13029
+ if (!message) throw new CliError("not_found", `No message ${id} in this ${from.group ? "group" : "chat"}`);
13030
+ chosen.push(message);
13031
+ }
12893
13032
  if (!bool(params, "force")) for (const message of chosen) {
12894
13033
  const secret = message.file ? null : findSecret(message.text);
12895
13034
  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 +13062,7 @@ var METHODS = {
12923
13062
  await waitForGroupFrame(ctx, to[i].id, messageId, void 0, ms);
12924
13063
  continue;
12925
13064
  }
12926
- const sent = (await node(ctx).getMessages(to[i].id)).find((m) => m.id === messageId);
13065
+ const sent = await node(ctx).getMessage(to[i].id, messageId);
12927
13066
  if (sent?.file) await FILE_METHODS["file.wait"](ctx, {
12928
13067
  chat: to[i].id,
12929
13068
  file: sent.file.id,
@@ -12955,7 +13094,7 @@ var METHODS = {
12955
13094
  async "chat.delete"(ctx, params) {
12956
13095
  const link = chatOf(ctx, params);
12957
13096
  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`);
13097
+ if (!await node(ctx).getMessage(link.id, messageId)) throw new CliError("not_found", `No message ${messageId} in this chat`);
12959
13098
  await node(ctx).deleteMessage({
12960
13099
  linkId: link.id,
12961
13100
  messageId
@@ -13071,7 +13210,13 @@ var METHODS = {
13071
13210
  };
13072
13211
  },
13073
13212
  async "group.join"(ctx, params) {
13074
- const { groupId } = await node(ctx).joinGroupByLink({ link: str(params, "link", true) });
13213
+ let groupId;
13214
+ try {
13215
+ ({groupId} = await node(ctx).joinGroupByLink({ link: str(params, "link", true) }));
13216
+ } catch (error) {
13217
+ if (error instanceof Error && error.message === "This is not a link to a group") throw new CliError("bad_request", error.message);
13218
+ throw error;
13219
+ }
13075
13220
  return { group: groupId };
13076
13221
  },
13077
13222
  async "group.list"(ctx, params) {
@@ -13140,15 +13285,16 @@ var METHODS = {
13140
13285
  }
13141
13286
  const mentions = mentionsFor(text, list(params, "mentions"), group);
13142
13287
  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;
13288
+ const linkId = `group:${group.id}`;
13289
+ const before = (await node(ctx).getMessage(linkId, messageId))?.edit?.seq ?? 0;
13144
13290
  const result = await node(ctx).editMessage({
13145
- linkId: `group:${group.id}`,
13291
+ linkId,
13146
13292
  messageId,
13147
13293
  text,
13148
13294
  ...mentions.length ? { mentions } : {}
13149
13295
  });
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);
13296
+ if (result.error) throw editRefused(await messageOf(ctx, linkId, messageId), messageId, "group", result.error, result.refused);
13297
+ const message = await node(ctx).getMessage(linkId, messageId);
13152
13298
  const edits = message?.edit?.seq ?? 0;
13153
13299
  if (edits === before) return {
13154
13300
  group: group.id,
@@ -13314,12 +13460,32 @@ function groupSendRefused(error) {
13314
13460
  return new CliError("unavailable", error);
13315
13461
  }
13316
13462
  /**
13463
+ * A chat command given a group's id, prefix or name: `not_found` that says it is a group, with the group command that
13464
+ * takes it (`react` on a group, where `group react` is meant). A name no chat and no group has stays the chat's error.
13465
+ */
13466
+ function notAGroup(ctx, params, chat, command, rest) {
13467
+ try {
13468
+ return chat();
13469
+ } catch (error) {
13470
+ if (!(error instanceof CliError) || error.code !== "not_found") throw error;
13471
+ const ref = str(params, "chat", true);
13472
+ let group;
13473
+ try {
13474
+ group = findGroup(state(ctx).groups, ref);
13475
+ } catch {
13476
+ throw error;
13477
+ }
13478
+ const shown = /^[\w.:-]+$/.test(ref) ? ref : JSON.stringify(ref);
13479
+ throw new CliError("not_found", `${JSON.stringify(ref)} is a group, not a chat: ghostly ${command} ${shown} ${rest}`, { group: group.id });
13480
+ }
13481
+ }
13482
+ /**
13317
13483
  * 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
13484
  * to give, as `button update` and `task update` do; before, a mistyped or lost id read "Only your own text messages
13319
13485
  * can be edited" (`refused`, exit 1), as if the message were someone else's.
13320
13486
  */
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`);
13487
+ function editRefused(message, ref, where, error, refused) {
13488
+ 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
13489
  return new CliError(refused ? "refused" : "unavailable", error);
13324
13490
  }
13325
13491
  /**
@@ -14482,6 +14648,11 @@ var showSecret = { "show-secret": {
14482
14648
  type: "boolean",
14483
14649
  description: "Print the group's entry link (anyone who has it can join)"
14484
14650
  } };
14651
+ /** A pending chat's invite code lets whoever reads it take the chat: printed only when asked for. */
14652
+ var showInvite = { "show-secret": {
14653
+ type: "boolean",
14654
+ description: "Print a pending chat's invite code (whoever has it can take the chat)"
14655
+ } };
14485
14656
  var secret = (options) => options["show-secret"] === true ? { showSecret: true } : {};
14486
14657
  /**
14487
14658
  * `--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 +15221,10 @@ var commands$6 = {
15050
15221
  },
15051
15222
  "chat list": {
15052
15223
  method: "chat.list",
15053
- usage: "chat list",
15054
- summary: "Chats, newest first"
15224
+ usage: "chat list [--show-secret]",
15225
+ summary: "Chats, newest first (a pending invite code only with --show-secret)",
15226
+ options: showInvite,
15227
+ params: ({ options }) => secret(options)
15055
15228
  },
15056
15229
  "chat remove": {
15057
15230
  method: "chat.remove",
@@ -15079,10 +15252,14 @@ var commands$6 = {
15079
15252
  },
15080
15253
  "chat show": {
15081
15254
  method: "chat.get",
15082
- usage: "chat show <chat>",
15083
- summary: "One chat and its connection",
15255
+ usage: "chat show <chat> [--show-secret]",
15256
+ summary: "One chat and its connection (its pending invite code only with --show-secret)",
15084
15257
  args: ["chat"],
15085
- params: (_, { chat }) => ({ chat })
15258
+ options: showInvite,
15259
+ params: ({ options }, { chat }) => ({
15260
+ chat,
15261
+ ...secret(options)
15262
+ })
15086
15263
  },
15087
15264
  "chat transport": {
15088
15265
  method: "chat.transport",
@@ -19348,6 +19525,7 @@ async function openPersistentIndexedDb(dir) {
19348
19525
  const factory = new IDBFactory();
19349
19526
  const raw = factory;
19350
19527
  const classes = await probeClasses(factory);
19528
+ dropIndexEntriesByRecord(classes, (a, b) => factory.cmp(a, b));
19351
19529
  let writing = true;
19352
19530
  let restoring = true;
19353
19531
  let journalFd = null;
@@ -19535,7 +19713,8 @@ async function probeClasses(factory) {
19535
19713
  const classes = {
19536
19714
  Database: rawDb.constructor,
19537
19715
  ObjectStore: rawStore.constructor,
19538
- Index: rawStore.rawIndexes.get("i").constructor
19716
+ Index: rawStore.rawIndexes.get("i").constructor,
19717
+ RecordStore: rawStore.records.constructor
19539
19718
  };
19540
19719
  db.close();
19541
19720
  await new Promise((resolve) => {
@@ -19544,6 +19723,76 @@ async function probeClasses(factory) {
19544
19723
  });
19545
19724
  return classes;
19546
19725
  }
19726
+ /** The classes whose index upkeep is already by record: they are fake-indexeddb's own, shared by every store opened. */
19727
+ var byRecord = /* @__PURE__ */ new WeakSet();
19728
+ /**
19729
+ * fake-indexeddb drops an overwritten or deleted record from an index by walking every entry of it (an index is in
19730
+ * its own key's order, not the record's): each delivery mark, edit or reaction cost time with every message of the
19731
+ * profile, 170 ms at 10,000, and a long chat slowed every other one. The store has the records it just replaced or
19732
+ * removed in hand, and they say which entries to drop; a call that comes any other way still walks.
19733
+ */
19734
+ function dropIndexEntriesByRecord(classes, cmp) {
19735
+ const storeProto = classes.ObjectStore.prototype;
19736
+ const recordsProto = classes.RecordStore.prototype;
19737
+ if (byRecord.has(storeProto)) return;
19738
+ byRecord.add(storeProto);
19739
+ const { storeRecord, deleteRecord } = storeProto;
19740
+ const { put, delete: remove, deleteByValue } = recordsProto;
19741
+ let gone = null;
19742
+ storeProto.storeRecord = function(record, noOverwrite, rollbackLog) {
19743
+ if (!this.rawIndexes.size) return storeRecord.call(this, record, noOverwrite, rollbackLog);
19744
+ const records = this.records, indexes = this.rawIndexes, outer = gone;
19745
+ records.put = (next, keep) => {
19746
+ const replaced = put.call(this.records, next, keep);
19747
+ gone = {
19748
+ indexes,
19749
+ records: replaced ? [replaced] : []
19750
+ };
19751
+ return replaced;
19752
+ };
19753
+ try {
19754
+ return storeRecord.call(this, record, noOverwrite, rollbackLog);
19755
+ } finally {
19756
+ delete records.put;
19757
+ gone = outer;
19758
+ }
19759
+ };
19760
+ storeProto.deleteRecord = function(key, rollbackLog) {
19761
+ if (!this.rawIndexes.size) return deleteRecord.call(this, key, rollbackLog);
19762
+ const records = this.records, indexes = this.rawIndexes, outer = gone;
19763
+ records.delete = (range) => {
19764
+ const removed = remove.call(this.records, range);
19765
+ gone = {
19766
+ indexes,
19767
+ records: removed
19768
+ };
19769
+ return removed;
19770
+ };
19771
+ try {
19772
+ deleteRecord.call(this, key, rollbackLog);
19773
+ } finally {
19774
+ delete records.delete;
19775
+ gone = outer;
19776
+ }
19777
+ };
19778
+ recordsProto.deleteByValue = function(key) {
19779
+ let index;
19780
+ if (gone) {
19781
+ for (const candidate of gone.indexes.values()) if (candidate.records === this) index = candidate;
19782
+ }
19783
+ if (!gone || !index) return deleteByValue.call(this, key);
19784
+ const dropped = [];
19785
+ for (const entry of indexEntries(index, gone.records, cmp)) {
19786
+ if (this.keysAreUnique) {
19787
+ const held = this.get(entry.key);
19788
+ if (!held || cmp(held.value, entry.value) !== 0) continue;
19789
+ }
19790
+ this.records.delete(entry);
19791
+ dropped.push(entry);
19792
+ }
19793
+ return dropped;
19794
+ };
19795
+ }
19547
19796
  /**
19548
19797
  * The snapshot's records come in key order (as `writeSnapshot` walks them), so each store's tree and each index's are
19549
19798
  * built whole from sorted arrays: storing record by record compared every key down the tree, a 70,000-message profile
@@ -19939,14 +20188,14 @@ async function startRuntime(paths, options = {}) {
19939
20188
  (await import("./assets/ghostly_iroh_web-Dvzh3Y6E.js")).initSync({ module: irohWasmBytes() });
19940
20189
  const [{ registerFileBytes }, { NodeFileBytes }] = await Promise.all([import("./assets/fileBytes-BcYZhNgF.js").then((n) => n.o), import("./assets/fileBytes-Dbifu5I5.js")]);
19941
20190
  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")]);
20191
+ const [{ EngineServer }, { createHyperEndpoint }] = await Promise.all([import("./assets/server-Cy4ox7oD.js").then((n) => n.t), import("./assets/endpoint-pgFkR2AS.js")]);
19943
20192
  const network = hyperdhtNetwork();
19944
20193
  const dht = mainlineNetwork();
19945
- const mainline = dht.off ? null : new (await (import("./assets/mainline-DVYdrus6.js"))).Mainline({
20194
+ const mainline = dht.off ? null : new (await (import("./assets/mainline-1v8OTYvp.js"))).Mainline({
19946
20195
  bootstrap: dht.bootstrap,
19947
20196
  host: dht.host
19948
20197
  });
19949
- const transport = mainline || pinned ? new (await (import("./assets/mainline-DVYdrus6.js"))).RelaysAndDht(mainline, pinned ? { relays: pinned } : {}, !!pinned) : null;
20198
+ const transport = mainline || pinned ? new (await (import("./assets/mainline-1v8OTYvp.js"))).RelaysAndDht(mainline, pinned ? { relays: pinned } : {}, !!pinned) : null;
19950
20199
  const server = new EngineServer({
19951
20200
  ...transport ? { transport } : {},
19952
20201
  irohWeb: true,
@@ -20603,7 +20852,7 @@ var SPECIAL = [
20603
20852
  "The profile's settings",
20604
20853
  { "show-secret": o("boolean", "Show credentials too") }
20605
20854
  ],
20606
- ["settings set <key> <json-value>", "Change one: relays, irohRelays, hyperdhtRelay, readRelays, iceServers, publicProfiles, online, shareProfile, sendTyping, nick"],
20855
+ ["settings set <key> <json-value>", "Change one: relays, irohRelays, hyperdhtRelay, readRelays, iceServers, publicProfiles, online, shareProfile, sendTyping, autoDownloads, serveFiles, nick"],
20607
20856
  [
20608
20857
  "engine <method> [json-params | -] [--confirm-real] [--show-secret]",
20609
20858
  "Any call of the app's engine, with its own parameters",
@@ -20649,6 +20898,14 @@ function help() {
20649
20898
  "2 usage, 3 not found, 4 timed out, 5 needs --force/--yes/--confirm-real. See packages/cli/README.md."
20650
20899
  ].join("\n");
20651
20900
  }
20901
+ /** What `ghostly help help` answers. */
20902
+ var HELP_USAGE = [
20903
+ "Usage: ghostly help [<command>...]",
20904
+ "",
20905
+ " Help for every command, or for one command or a group of them (also ghostly <command> --help)",
20906
+ "",
20907
+ "Options for every command: --profile, -p <name> --home <dir> --pretty --help, -h"
20908
+ ].join("\n");
20652
20909
  /** Every command's help row: usage, summary and, for the table and text commands, their options. */
20653
20910
  function helpRows() {
20654
20911
  return [
@@ -20675,6 +20932,7 @@ function commandWords(usage) {
20675
20932
  * options; for a group (`ghostly help file`), the group's commands.
20676
20933
  */
20677
20934
  function commandHelp(words) {
20935
+ if (words[0] === "help") return HELP_USAGE;
20678
20936
  const named = (list) => helpRows().filter((row) => {
20679
20937
  const own = commandWords(row.usage);
20680
20938
  return list.every((word, i) => own[i] === word);
@@ -20954,32 +21212,42 @@ async function daemonCommand(argv) {
20954
21212
  }
20955
21213
  if (sub === "stop") {
20956
21214
  knownProfile(g);
21215
+ const stopFor = seconds(parsed, 20);
20957
21216
  const client = await connectDaemon(g.paths.socket);
20958
21217
  if (!client) throw new CliError("not_found", `No daemon runs profile ${g.profile}`);
20959
21218
  print({
20960
21219
  stopped: true,
20961
- pid: await stopDaemon(g, client, Number(parsed.options.timeout) || 20)
21220
+ pid: await stopDaemon(g, client, stopFor)
20962
21221
  });
20963
21222
  return;
20964
21223
  }
20965
21224
  if (sub === "restart") {
21225
+ const stopFor = seconds(parsed, 20), startFor = seconds(parsed, 60);
20966
21226
  const client = await connectDaemon(g.paths.socket);
20967
21227
  const before = client ? (await client.call("status").catch(() => null))?.version ?? null : null;
20968
21228
  print({
20969
21229
  restarted: true,
20970
- stopped: client ? await stopDaemon(g, client, Number(parsed.options.timeout) || 20) : null,
21230
+ stopped: client ? await stopDaemon(g, client, stopFor) : null,
20971
21231
  before,
20972
21232
  version: VERSION,
20973
- ...await startDetached(g, Number(parsed.options.timeout) || 60)
21233
+ ...await startDetached(g, startFor)
20974
21234
  });
20975
21235
  return;
20976
21236
  }
20977
21237
  if (sub) throw new CliError("usage", "ghostly daemon [--detach] | daemon status | daemon stop | daemon restart");
21238
+ const startFor = seconds(parsed, 60);
20978
21239
  if (!parsed.options.detach) {
20979
21240
  await runDaemon(g);
20980
21241
  return;
20981
21242
  }
20982
- print(await startDetached(g, Number(parsed.options.timeout) || 60));
21243
+ print(await startDetached(g, startFor));
21244
+ }
21245
+ /** `--timeout <s>`, or `fallback` without one: seconds above 0 (0 or less would not wait at all). */
21246
+ function seconds(parsed, fallback) {
21247
+ const value = parsed.options.timeout;
21248
+ if (value === void 0) return fallback;
21249
+ if (typeof value !== "number" || !(value > 0)) throw new CliError("usage", "--timeout takes a number of seconds above 0");
21250
+ return value;
20983
21251
  }
20984
21252
  /**
20985
21253
  * 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 +21307,10 @@ async function startDetached(g, seconds) {
21039
21307
  }
21040
21308
  if (!client) {
21041
21309
  heldBy(g, child.pid);
21310
+ 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`, {
21311
+ pid: child.pid,
21312
+ log: g.paths.log
21313
+ });
21042
21314
  throw new CliError("engine", `The daemon did not start; see ${g.paths.log}`);
21043
21315
  }
21044
21316
  client.close();
@@ -21226,17 +21498,7 @@ async function engineCommand(argv) {
21226
21498
  params,
21227
21499
  confirmReal
21228
21500
  }));
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
- }
21501
+ if (method === "getState" && !showSecret && result && typeof result === "object") result = hideStateSecrets(result);
21240
21502
  print(result);
21241
21503
  }
21242
21504
  async function settingsCommand(sub, argv) {
@@ -21469,6 +21731,7 @@ async function main(input) {
21469
21731
  return 0;
21470
21732
  }
21471
21733
  if (argv[0] === "--version" || argv[0] === "version") {
21734
+ pretty = argv.slice(1).some((word) => word === "--pretty" || word === "--pretty=true");
21472
21735
  print({ version: VERSION });
21473
21736
  return 0;
21474
21737
  }
@@ -21564,7 +21827,29 @@ async function run(argv) {
21564
21827
  }
21565
21828
  }
21566
21829
  //#endregion
21830
+ //#region src/runtime/webStorage.ts
21831
+ /**
21832
+ * Node 25 and newer define `globalThis.localStorage` as a getter that, without `--localstorage-file`, answers
21833
+ * `undefined` and writes an ExperimentalWarning on stderr the first time it is read. The engine reads it where a
21834
+ * browser keeps a setting (`globalThis.localStorage?.getItem(...)`), so every command that opens a profile printed the
21835
+ * warning. Without the flag the CLI has no localStorage, as on Node 22: the getter is replaced by `undefined` before
21836
+ * anything reads it. With the flag, Node's storage stays.
21837
+ */
21838
+ function quietWebStorage(scope = globalThis, execArgv = process.execArgv, nodeOptions = process.env.NODE_OPTIONS) {
21839
+ const own = Object.getOwnPropertyDescriptor(scope, "localStorage");
21840
+ if (!own?.get || !own.configurable) return;
21841
+ const flag = (arg) => arg === "--localstorage-file" || arg.startsWith("--localstorage-file=");
21842
+ if (execArgv.some(flag) || (nodeOptions ?? "").split(/\s+/).some(flag)) return;
21843
+ Object.defineProperty(scope, "localStorage", {
21844
+ value: void 0,
21845
+ writable: true,
21846
+ configurable: true,
21847
+ enumerable: false
21848
+ });
21849
+ }
21850
+ //#endregion
21567
21851
  //#region src/bin.ts
21852
+ quietWebStorage();
21568
21853
  run(process.argv.slice(2));
21569
21854
  //#endregion
21570
21855
  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 };