@ghostlytools/cli 1.2.2 → 1.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/ghostly.mjs CHANGED
@@ -1,18 +1,18 @@
1
1
  #!/usr/bin/env node
2
2
  import { t as __exportAll } from "./assets/rolldown-runtime-8H4AJuhK.js";
3
- import { $ as groupName, $a as APP_PREFIXES, $c as sanitizeAvatar, $n as readGroupWantNo, At as checkDidSignature, Ba as RUN_RESULTS, Bo as signalHasVideo, Bt as parseDid, Ci as recordsFromWellKnown, Cn as pubkyProofFile, Ct as AtprotoProofError, Dn as requestLnurlInvoice, Dr as ChatFiles, Ei as wellKnownUrl, En as pubkyHomeserverOf, Et as DID_FILE_MAX_BYTES, Fa as replySnippet, Fo as buildSdpFromSignal, Ft as didWebFile, Ga as checkStatusCard, Gc as openRelayPayload, Gt as strictBase64Url, Ha as buttonLabelClash, Hc as RELAY_PAYLOAD_MAX_BYTES, Hn as engineText, Ht as parseDidSignaturePaste, Ic as setLinkTraceSink, Ii as boundVerifiedIdentity, Io as callRtcConfig, It as didWebFileNames, Jn as groupFileFallback, Kn as GROUP_WANT_NO_FRAME, La as ITEM_STATES, Lo as callSignalHeardAt, Lt as didWebFileUrl, Mo as CALL_SIGNAL_MAX_AGE_MS, Mr as TYPING_REFRESH_MS, Mt as didJwsAlg, No as RTC_CONFIG, Nt as didSignedMessages, On as resolveLightningDestination, Ot as DidError, Pi as IdentityCheckUnavailable, Po as answersOffer, Pr as sanitizeTypingStatus, Pt as didWebDocumentUrl, Qc as MAX_AVATAR_LENGTH, Qn as readGroupWant, Ra as PR_CHECKS, Ro as extractParamsFromSdp, Rt as didWebHost, Si as recordsFromTxt, Sn as pubkyProofCapability, St as shortAtprotoDid, Ti as txtValue, Tn as homeserverWebEndpoint, To as GhostlyHttpError, Tt as DID_DOCUMENT_MAX_BYTES$1, Ua as cardLine, Un as GROUP_FILE_LIMITS, Ut as signingKeys, Va as STATUS_CARD_LIMITS, Vn as engineError, Vt as parseDidDocument, Wa as cardUrl, Wn as GROUP_HAVE_FRAME, Wt as staticDidDocument, Xi as irohRelayUrl, Xr as checkPushEndpoint, Xt as resolveDidDht, Yi as irohDescriptor, Yn as isGroupMessageId, Zn as readGroupHave, _ as checkAppManifest, _a as RateWindow, _i as domainWellKnownFile, _n as PUBKY_PROOF_MAX_BYTES, _t as atprotoProofRkey, ac as inviteLink, al as utf8Decode, at as sameSshKey, bi as nip05RootKey, bn as newPubkyProofFolder, bt as normalizeAtprotoHandle, c as readAppStore, ca as PLAYABLE_AUDIO, co as signAppObject, ct as verifyBitcoinMessage, d as APP_BUNDLE_LIMITS, da as fileMessageText, dn as walletNetworkOf, do as canonicalJsonBytes, dt as ATPROTO_DECLARED_SCOPE, eo as APP_STATEMENT_LIMITS, fa as parseVoiceMeta, ft as ATPROTO_FULL_SCOPE, g as assembleAppBundle, gc as seedSigner, gi as domainTxtRecord, gt as atprotoProofRecord, hi as dnsProofName, ht as atprotoDidMethod, il as toZ32, it as parseSshSignature, jr as TYPING_KINDS, jt as didGhostlyServiceEntry, kn as decodeBolt11, ko as LIMITS, kr as transferEnded, kt as checkDidJws, la as VOICE_LIMITS, lo as signAppRevocation, lt as BitcoinAddressError, mi as decodeDnsResponse, mt as ATPROTO_PROOF_SCOPE, n as appStoreDecision, nl as randomBytes$1, no as appRef, nt as SshSigError, oa as sanitizeFileName, ol as utf8Encode, on as WALLET_NETWORKS, ot as sshSignCommand, pa as voicePeaksOf, pi as DOMAIN_FILE_MAX_BYTES, pt as ATPROTO_PROOF_COLLECTION, qn as fetchesByItself, ra as readImageMeta, rl as toBase64Url, ro as checkAppRevokeStatement, rt as parseSshPublicKey, s as checkAppStoreIndex, sa as sanitizeMime, so as readAppRevocations, st as verifySshSignature, ti as BUTTONS_CAPABILITY, tl as fromBase64Url$1, to as appFingerprint, tt as SSHSIG_NAMESPACE, u as signAppStore, ua as baseMime, uo as canonicalJson, ut as decodeAnyBitcoinAddress, v as isAppUrl, vi as encodeDnsQuery, vn as isPubkyKey, vt as checkAtprotoProofRecord, wi as recordsName, wn as pubkyProofPath, wt as verifyAtprotoRecordProof, xi as normalizeDomain, xn as normalizePubkyKey, xt as parseAtprotoDidDocument, y as readAppBundle, yi as isPublicAddress, yn as isPubkyProofFolder, yt as isAtprotoDid, za as PR_STATES, zi as identityStatement, zo as parseCallSignal, zt as normalizeDid } from "./assets/src-XzcHzneo.js";
3
+ import { $n as readGroupHave, An as resolveLightningDestination, At as DidError, Ba as PR_CHECKS, Bo as extractParamsFromSdp, Bt as didWebHost, Ci as normalizeDomain, Cn as normalizePubkyKey, Ct as parseAtprotoDidDocument, Di as txtValue, Dn as homeserverWebEndpoint, Do as GhostlyHttpError, Dt as DID_DOCUMENT_MAX_BYTES$1, Ei as recordsName, En as pubkyProofPath, Et as verifyAtprotoRecordProof, Fo as RTC_CONFIG, Ft as didSignedMessages, Ga as cardLine, Gn as GROUP_FILE_LIMITS, Gt as signingKeys, Ha as RUN_RESULTS, Ho as signalHasVideo, Ht as parseDid, Ii as IdentityCheckUnavailable, Io as answersOffer, Ir as sanitizeTypingStatus, It as didWebDocumentUrl, Jn as GROUP_WANT_NO_FRAME, Ka as cardUrl, Kn as GROUP_HAVE_FRAME, Kt as staticDidDocument, La as replySnippet, Lo as buildSdpFromSignal, Lt as didWebFile, Mt as checkDidSignature, Nr as TYPING_KINDS, Nt as didGhostlyServiceEntry, Oi as wellKnownUrl, On as pubkyHomeserverOf, Ot as DID_FILE_MAX_BYTES, Po as CALL_SIGNAL_MAX_AGE_MS, Pr as TYPING_REFRESH_MS, Pt as didJwsAlg, Qi as irohRelayUrl, Qr as checkPushEndpoint, Qt as resolveDidDht, Ri as boundVerifiedIdentity, Ro as callRtcConfig, Rt as didWebFileNames, Si as nip05RootKey, Sn as newPubkyProofFolder, St as normalizeAtprotoHandle, Ti as recordsFromWellKnown, Tn as pubkyProofFile, Tt as AtprotoProofError, Ua as STATUS_CARD_LIMITS, Un as engineError, Ut as parseDidDocument, Va as PR_STATES, Vc as setLinkTraceSink, Vi as identityStatement, Vo as parseCallSignal, Vt as normalizeDid, Wa as buttonLabelClash, Wn as engineText, Wt as parseDidSignaturePaste, Xc as openRelayPayload, Xn as groupFileFallback, Yn as fetchesByItself, Zi as irohDescriptor, Zn as isGroupMessageId, _ as checkAppManifest, _i as dnsProofName, _t as atprotoDidMethod, aa as readImageMeta, ao as checkAppRevokeStatement, at as parseSshPublicKey, bi as encodeDnsQuery, bn as isPubkyKey, bt as checkAtprotoProofRecord, c as readAppStore, ca as sanitizeFileName, cl as toBase64Url, cn as WALLET_NETWORKS, ct as sshSignCommand, d as APP_BUNDLE_LIMITS, da as VOICE_LIMITS, dl as utf8Encode, do as signAppRevocation, dt as BitcoinAddressError, er as readGroupWant, fa as baseMime, fo as canonicalJson, ft as decodeAnyBitcoinAddress, g as assembleAppBundle, gi as decodeDnsResponse, gt as ATPROTO_PROOF_SCOPE, ha as voicePeaksOf, hi as DOMAIN_FILE_MAX_BYTES, ht as ATPROTO_PROOF_COLLECTION, il as sanitizeAvatar, io as appRef, it as SshSigError, jn as decodeBolt11, jo as LIMITS, jr as transferEnded, jt as checkDidJws, kn as requestLnurlInvoice, kr as ChatFiles, la as sanitizeMime, ll as toZ32, lo as readAppRevocations, lt as verifySshSignature, ma as parseVoiceMeta, mt as ATPROTO_FULL_SCOPE, n as appStoreDecision, no as APP_STATEMENT_LIMITS, ol as fromBase64Url$1, ot as parseSshSignature, pa as fileMessageText, pn as walletNetworkOf, po as canonicalJsonBytes, pt as ATPROTO_DECLARED_SCOPE, qa as checkStatusCard, qc as RELAY_PAYLOAD_MAX_BYTES, qt as strictBase64Url, ri as BUTTONS_CAPABILITY, rl as MAX_AVATAR_LENGTH, ro as appFingerprint, rt as SSHSIG_NAMESPACE, s as checkAppStoreIndex, sc as inviteLink, sl as randomBytes$1, st as sameSshKey, to as APP_PREFIXES, tr as readGroupWantNo, tt as groupName, u as signAppStore, ua as PLAYABLE_AUDIO, ul as utf8Decode, uo as signAppObject, ut as verifyBitcoinMessage, v as isAppUrl, vc as seedSigner, vi as domainTxtRecord, vt as atprotoProofRecord, wi as recordsFromTxt, wn as pubkyProofCapability, wt as shortAtprotoDid, xi as isPublicAddress, xn as isPubkyProofFolder, xt as isAtprotoDid, y as readAppBundle, ya as RateWindow, yi as domainWellKnownFile, yn as PUBKY_PROOF_MAX_BYTES, yt as atprotoProofRkey, za as ITEM_STATES, zo as callSignalHeardAt, zt as didWebFileUrl } from "./assets/src-CbLkHfz8.js";
4
4
  import { c as fileStore, f as store$1, i as STORES, m as wrap, o as databaseName, p as transact, u as openDb } from "./assets/idb-CTF6LF5B.js";
5
5
  import { a as fileBytesOf, i as fileBytes, l as FILE_BYTES_STEP } from "./assets/fileBytes-BcYZhNgF.js";
6
6
  import { createRequire } from "node:module";
7
7
  import { spawn } from "node:child_process";
8
8
  import { connect, createServer, isIP } from "node:net";
9
- import { appendFileSync, chmodSync, closeSync, existsSync, fsyncSync, lstatSync, mkdirSync, openAsBlob, openSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync, writeSync } from "node:fs";
9
+ import { accessSync, appendFileSync, chmodSync, closeSync, constants, existsSync, fsyncSync, lstatSync, mkdirSync, openAsBlob, openSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync, writeSync } from "node:fs";
10
10
  import { ed25519 } from "@noble/curves/ed25519.js";
11
11
  import { sha256 } from "@noble/hashes/sha2.js";
12
12
  import { createBase58check } from "@scure/base";
13
13
  import { schnorr } from "@noble/curves/secp256k1.js";
14
14
  import { wordlist } from "@scure/bip39/wordlists/english.js";
15
- import { access, constants, link, mkdir, open, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
15
+ import { access, constants as constants$1, link, mkdir, open, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
16
16
  import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
17
17
  import { createHash, createPrivateKey, generateKeyPairSync, randomBytes, timingSafeEqual } from "node:crypto";
18
18
  import { decode, npubEncode } from "nostr-tools/nip19";
@@ -35,7 +35,7 @@ import { IDBFactory, IDBKeyRange as IDBKeyRange$1, IDBTransaction } from "fake-i
35
35
  import { format } from "node:util";
36
36
  var package_default = {
37
37
  name: "@ghostlytools/cli",
38
- version: "1.2.2",
38
+ version: "1.2.4",
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",
@@ -795,6 +795,14 @@ var SECRET_RESULTS = /* @__PURE__ */ new Set([
795
795
  //#region src/apiKit.ts
796
796
  var node = (ctx) => ctx.runtime.server.node;
797
797
  var state = (ctx) => node(ctx).getState();
798
+ /**
799
+ * The time of a send the engine names its row after (`me_<timestamp>`: a file, a payment, a request), its own even
800
+ * within one millisecond (a bot paying or sending several times at once over the socket, `file send` run in
801
+ * parallel), or the chat keeps only one. One clock for all of them: they share the row names. A file's resend after a
802
+ * drop finds its row by it, as a forward does.
803
+ */
804
+ var lastSentAt = 0;
805
+ var sentAt = () => lastSentAt = Math.max(Date.now(), lastSentAt + 1);
798
806
  function str(params, name, required = false) {
799
807
  const value = params[name];
800
808
  if (value === void 0 || value === null || value === "") {
@@ -804,10 +812,12 @@ function str(params, name, required = false) {
804
812
  if (typeof value !== "string") throw new CliError("bad_request", `${name} must be a string`);
805
813
  return value;
806
814
  }
807
- function num(params, name, fallback, { min = 0, max = Number.MAX_SAFE_INTEGER } = {}) {
815
+ /** A number in `[min, max]`; `whole` takes only whole ones (a count of messages), as the engine needs them. */
816
+ function num(params, name, fallback, { min = 0, max = Number.MAX_SAFE_INTEGER, whole = false } = {}) {
808
817
  const value = params[name];
809
818
  if (value === void 0 || value === null) return fallback;
810
- if (typeof value !== "number" || !Number.isFinite(value) || value < min || value > max) throw new CliError("bad_request", `${name} must be a number from ${min} to ${max}`);
819
+ if (typeof value !== "number" || !Number.isFinite(value) || value < min || value > max) throw new CliError("bad_request", `${name} must be a ${whole ? "whole " : ""}number from ${min} to ${max}`);
820
+ if (whole && !Number.isSafeInteger(value)) throw new CliError("bad_request", `${name} must be a whole number from ${min} to ${max}`);
811
821
  return value;
812
822
  }
813
823
  function bool(params, name) {
@@ -1323,7 +1333,7 @@ var GroupFileDesk = class {
1323
1333
  }
1324
1334
  /**
1325
1335
  * An error when this group took as many announcements of mine as it takes in a minute, or as its members take from
1326
- * one member in `announceWindowMs` (past that they would get the text, not the file); null when one may go now.
1336
+ * one member in `announceWindowMs` (past that they would not fetch the file by themselves); null when one may go now.
1327
1337
  */
1328
1338
  mayAnnounce(groupId) {
1329
1339
  const wait = Math.max(...this.sentPaces(groupId).map((pace) => pace.wait()));
@@ -1340,9 +1350,9 @@ var GroupFileDesk = class {
1340
1350
  this.own.delete(`${groupId}\n${meta.d}`);
1341
1351
  }
1342
1352
  /**
1343
- * A message of the group announces a file (mine, or a member's): the file its row shows, or none when the member
1344
- * announced more than the group takes (its text shows). A member's is kept here unfetched, and fetched now when it is
1345
- * one this device takes by itself. The same message again (a catch-up, a whole copy) is the same file.
1353
+ * A message of the group announces a file (mine, or a member's): the file its row shows. A member's is kept here
1354
+ * unfetched, and fetched now when it is one this device takes by itself and the member announced no more than the
1355
+ * group fetches that way. The same message again (a catch-up, a whole copy) is the same file.
1346
1356
  */
1347
1357
  async announced(groupId, m, me) {
1348
1358
  const known = this.byMessage.get(`${groupId}\n${m.id}`);
@@ -1384,9 +1394,9 @@ var GroupFileDesk = class {
1384
1394
  this.queueHave(kept);
1385
1395
  return messageFile(kept);
1386
1396
  }
1387
- if (!this.pace(this.takenPace, `${groupId}\n${m.sender}`, GROUP_FILE_LIMITS.announceTaken, GROUP_FILE_LIMITS.announceWindowMs).take()) return void 0;
1397
+ const paced = this.pace(this.takenPace, `${groupId}\n${m.sender}`, GROUP_FILE_LIMITS.announceTaken, GROUP_FILE_LIMITS.announceWindowMs).take();
1388
1398
  const id = groupFileId(groupId, "in");
1389
- const auto = this.deps.settings().autoDownloads && fetchesByItself(m.file, this.keptAutomatically(groupId));
1399
+ const auto = paced && this.deps.settings().autoDownloads && fetchesByItself(m.file, this.keptAutomatically(groupId));
1390
1400
  const holders = this.ahead.get(`${groupId}\n${m.id}`) ?? [];
1391
1401
  this.ahead.delete(`${groupId}\n${m.id}`);
1392
1402
  const kept = {
@@ -2700,7 +2710,7 @@ var FILE_METHODS = {
2700
2710
  throw new CliError("not_found", `No file ${path}`);
2701
2711
  }
2702
2712
  if (!info.isFile()) throw new CliError("bad_request", `${path} is not a file`);
2703
- await access(path, constants.R_OK);
2713
+ await access(path, constants$1.R_OK);
2704
2714
  if ("group" in target) return sendToGroup(ctx, params, target.group, path, info.size);
2705
2715
  const { link } = target;
2706
2716
  if (link.profile && link.dataLink === "open" && !link.capabilities?.files) throw new CliError("unavailable", "The contact's app takes no files");
@@ -2723,7 +2733,7 @@ var FILE_METHODS = {
2723
2733
  ...voice ? { voice } : {},
2724
2734
  ...image ? { image } : {}
2725
2735
  };
2726
- const timestamp = Date.now();
2736
+ const timestamp = sentAt();
2727
2737
  const bytes = await fileBytes();
2728
2738
  const digest = await bytes.stage(file.id, source);
2729
2739
  await fileStore.put({
@@ -6172,7 +6182,8 @@ async function verifyIdToken(token, options) {
6172
6182
  }
6173
6183
  //#endregion
6174
6184
  //#region ../browser/src/proofs/oidc/flow.ts
6175
- var STATE = /^(?:d\.\d{1,5}\.)?[A-Za-z0-9_-]{43}$/;
6185
+ /** Optional platform prefix (desktop `d.<port>.`, Android deep link `a.`), then 32 random bytes. */
6186
+ var STATE = /^(?:d\.\d{1,5}\.|a\.)?[A-Za-z0-9_-]{43}$/;
6176
6187
  function scopeFor$1(provider, reveal) {
6177
6188
  const scopes = provider.id === "apple" ? [] : ["openid"];
6178
6189
  const claims = [];
@@ -8727,13 +8738,15 @@ var IDENTITY_METHODS = {
8727
8738
  },
8728
8739
  async "identity.cancel"(ctx, params) {
8729
8740
  const draftId = str(params, "draft", true);
8730
- drafts.delete(draftId);
8741
+ if (!drafts.delete(draftId)) throw new CliError("not_found", "No such draft here: it is kept by the daemon that made it, for an hour");
8731
8742
  await node(ctx).cancelIdentityProof({ draftId });
8732
8743
  return { cancelled: draftId };
8733
8744
  },
8734
8745
  async "identity.remove"(ctx, params) {
8735
- await node(ctx).removeIdentityProof({ id: str(params, "id", true) });
8736
- return { removed: str(params, "id", true) };
8746
+ const id = str(params, "id", true);
8747
+ if (!state(ctx).identityProofs.some((p) => p.id === id)) throw new CliError("not_found", `No identity proof ${JSON.stringify(id)} (ghostly identity list)`);
8748
+ await node(ctx).removeIdentityProof({ id });
8749
+ return { removed: id };
8737
8750
  },
8738
8751
  async "identity.share"(ctx, params) {
8739
8752
  const link = chatOf(ctx, params);
@@ -8748,13 +8761,15 @@ var IDENTITY_METHODS = {
8748
8761
  },
8749
8762
  async "identity.withdraw"(ctx, params) {
8750
8763
  const link = chatOf(ctx, params);
8764
+ const id = str(params, "id", true);
8765
+ if (!link.identities?.shared.some((s) => s.id === id)) throw new CliError("not_found", `No identity proof ${JSON.stringify(id)} shared in this chat (ghostly identity contact ${link.id})`);
8751
8766
  await node(ctx).withdrawIdentityProof({
8752
8767
  linkId: link.id,
8753
- id: str(params, "id", true)
8768
+ id
8754
8769
  });
8755
8770
  return {
8756
8771
  chat: link.id,
8757
- withdrawn: str(params, "id", true)
8772
+ withdrawn: id
8758
8773
  };
8759
8774
  },
8760
8775
  /** What a contact shared in a chat, as this device checked it, and what this side shares there. */
@@ -8990,6 +9005,15 @@ async function proxy(request, response, chunks, send, cookies) {
8990
9005
  plain(response, gone ? 503 : 502, gone ? "This service is not reachable. Services exist while their ghost is online." : `The request failed: ${error instanceof Error ? error.message : String(error)}`);
8991
9006
  }
8992
9007
  }
9008
+ /**
9009
+ * The service a command names, by its id: one of this profile's. The engine changes nothing for an id it does not
9010
+ * know and says nothing, so a mistyped id read as done ("shared": true, with a contact).
9011
+ */
9012
+ function serviceOf(ctx, params) {
9013
+ const serviceId = str(params, "service", true);
9014
+ if (!state(ctx).services.some((s) => s.id === serviceId)) throw new CliError("not_found", `No service ${JSON.stringify(serviceId)} (ghostly service list)`);
9015
+ return serviceId;
9016
+ }
8993
9017
  var SERVICE_METHODS = {
8994
9018
  async "service.list"(ctx) {
8995
9019
  return { services: state(ctx).services.map((s) => ({
@@ -9016,29 +9040,32 @@ var SERVICE_METHODS = {
9016
9040
  });
9017
9041
  },
9018
9042
  async "service.remove"(ctx, params) {
9019
- await node(ctx).removeService({ serviceId: str(params, "service", true) });
9020
- return { removed: str(params, "service", true) };
9043
+ const serviceId = serviceOf(ctx, params);
9044
+ await node(ctx).removeService({ serviceId });
9045
+ return { removed: serviceId };
9021
9046
  },
9022
9047
  async "service.enable"(ctx, params) {
9048
+ const serviceId = serviceOf(ctx, params);
9023
9049
  await node(ctx).setServiceEnabled({
9024
- serviceId: str(params, "service", true),
9050
+ serviceId,
9025
9051
  enabled: !bool(params, "off")
9026
9052
  });
9027
9053
  return {
9028
- service: str(params, "service", true),
9054
+ service: serviceId,
9029
9055
  enabled: !bool(params, "off")
9030
9056
  };
9031
9057
  },
9032
9058
  async "service.share"(ctx, params) {
9033
9059
  const link = chatOf(ctx, params);
9034
9060
  const shared = !bool(params, "off");
9061
+ const serviceId = serviceOf(ctx, params);
9035
9062
  await node(ctx).setServiceShared({
9036
- serviceId: str(params, "service", true),
9063
+ serviceId,
9037
9064
  peerPubKeyZ32: link.peerPubKeyZ32,
9038
9065
  shared
9039
9066
  });
9040
9067
  return {
9041
- service: str(params, "service", true),
9068
+ service: serviceId,
9042
9069
  chat: link.id,
9043
9070
  shared
9044
9071
  };
@@ -9395,10 +9422,6 @@ async function readBackupHeader(source) {
9395
9422
  line: start.slice(0, end + 1)
9396
9423
  };
9397
9424
  }
9398
- /** How a backup is protected, read from its clear header without opening it: version 1 is always sealed. */
9399
- async function backupProtection(source) {
9400
- return (await readBackupHeader(source))?.header.protection ?? "passphrase";
9401
- }
9402
9425
  var DAMAGED = "This backup is damaged: it was changed or cut short";
9403
9426
  /** Reads a version 2 backup record by record. Nothing of a frame is handed out before it passed its check. */
9404
9427
  var BackupReader = class BackupReader {
@@ -9519,15 +9542,21 @@ var keptInLight = (size, voice) => size <= (voice ? LIGHT_VOICE_BYTES : LIGHT_FI
9519
9542
  */
9520
9543
  var DEFAULT_PROFILE = "default";
9521
9544
  var NAME = /^[a-z0-9][a-z0-9._-]{0,63}$/i;
9545
+ /**
9546
+ * An empty `--home` or `--profile` (an unset variable in a script) is a usage error: taken as not given, it ran the
9547
+ * command in the current folder, or as the current profile. An empty variable of the environment counts as unset.
9548
+ */
9522
9549
  function ghostlyHome(option) {
9523
- return resolve(option ?? process.env.GHOSTLY_HOME ?? join(homedir(), ".ghostly"));
9550
+ if (option === "") throw new CliError("usage", "--home needs a folder");
9551
+ return resolve(option ?? (process.env.GHOSTLY_HOME || join(homedir(), ".ghostly")));
9524
9552
  }
9525
9553
  function checkProfileName(name) {
9526
9554
  if (!NAME.test(name) || name.includes("..")) throw new CliError("usage", `Not a profile name: ${JSON.stringify(name)} (letters, digits, ".", "_" and "-", up to 64)`);
9527
9555
  return name;
9528
9556
  }
9529
9557
  function currentProfile(home, option) {
9530
- if (option) return checkProfileName(option);
9558
+ if (option === "") throw new CliError("usage", "--profile needs a profile name");
9559
+ if (option !== void 0) return checkProfileName(option);
9531
9560
  if (process.env.GHOSTLY_PROFILE) return checkProfileName(process.env.GHOSTLY_PROFILE);
9532
9561
  const file = join(home, "current");
9533
9562
  if (existsSync(file)) {
@@ -9804,11 +9833,24 @@ async function fileSource(path) {
9804
9833
  close: () => handle.close()
9805
9834
  };
9806
9835
  }
9807
- /** Whether the backup at `file` is sealed with a passphrase or was made without one, read from its clear header. */
9836
+ /** A version 1 backup is one JSON object, and `seal` writes its format first. */
9837
+ var VERSION_1_START = /^\s*\{\s*"format"\s*:\s*"ghostly-backup"/;
9838
+ /**
9839
+ * Whether the backup at `file` is sealed with a passphrase or was made without one, read from its clear header.
9840
+ * A file that is neither version is refused here, before any passphrase is asked for: the path is what is wrong.
9841
+ */
9808
9842
  async function backupFileProtection(file) {
9809
9843
  const source = await fileSource(file);
9810
9844
  try {
9811
- return await backupProtection(source);
9845
+ let found;
9846
+ try {
9847
+ found = await readBackupHeader(source);
9848
+ } catch (error) {
9849
+ throw new CliError("refused", error instanceof Error ? error.message : String(error));
9850
+ }
9851
+ if (found) return found.header.protection;
9852
+ if (!VERSION_1_START.test(new TextDecoder().decode(await source.read(0, 256)))) throw new CliError("refused", "This is not a Ghostly backup");
9853
+ return "passphrase";
9812
9854
  } finally {
9813
9855
  await source.close();
9814
9856
  }
@@ -12312,6 +12354,15 @@ function paymentJson(p) {
12312
12354
  }
12313
12355
  var isInvoice = (text) => /^(lightning:)?ln(bc|tb|tbs|sb|bcrt)[0-9a-z]+$/i.test(text.trim());
12314
12356
  var isLnurl = (text) => /^(lightning:)?lnurl[0-9a-z]+$/i.test(text.trim()) || /^[a-z0-9._+-]+@[a-z0-9.-]+\.[a-z]{2,}$/i.test(text.trim());
12357
+ /**
12358
+ * The engine refusing the mint URL the person typed (not a URL, or not https): their value, so `bad_request`, as
12359
+ * `service add` says; before, it was `engine` (exit 1), which a bot reads as the engine failing and may retry. Read by
12360
+ * its shape: the engine's `EngineError` carries `engineCode`.
12361
+ */
12362
+ function badMintUrl(error) {
12363
+ const code = error instanceof Error ? error.engineCode : void 0;
12364
+ return code === "invalidMintUrl" || code === "mintNotHttps";
12365
+ }
12315
12366
  var WALLET_METHODS = {
12316
12367
  async "wallet.list"(ctx, params) {
12317
12368
  await federationsOpen(ctx);
@@ -12398,7 +12449,8 @@ var WALLET_METHODS = {
12398
12449
  const n = network(params);
12399
12450
  const limit = num(params, "limit", 50, {
12400
12451
  min: 1,
12401
- max: 1e4
12452
+ max: 1e4,
12453
+ whole: true
12402
12454
  });
12403
12455
  await federationsOpen(ctx);
12404
12456
  return {
@@ -12454,6 +12506,8 @@ var WALLET_METHODS = {
12454
12506
  return node(ctx).walletAddMint({
12455
12507
  url: str(params, "url", true),
12456
12508
  ...bool(params, "primary") ? { primary: true } : {}
12509
+ }).catch((error) => {
12510
+ throw badMintUrl(error) ? new CliError("bad_request", error.message) : error;
12457
12511
  });
12458
12512
  },
12459
12513
  async "lightning.default"(ctx, params) {
@@ -12538,7 +12592,7 @@ var WALLET_METHODS = {
12538
12592
  const { paymentId } = await node(ctx).sendPayment({
12539
12593
  linkId: link.id,
12540
12594
  amount,
12541
- timestamp: Date.now(),
12595
+ timestamp: sentAt(),
12542
12596
  network: n,
12543
12597
  ...str(params, "memo") ? { memo: str(params, "memo") } : {},
12544
12598
  ...real(params)
@@ -12559,7 +12613,7 @@ var WALLET_METHODS = {
12559
12613
  const { paymentId } = await node(ctx).requestPayment({
12560
12614
  linkId: link.id,
12561
12615
  amount,
12562
- timestamp: Date.now(),
12616
+ timestamp: sentAt(),
12563
12617
  network: network(params),
12564
12618
  ...str(params, "memo") ? { memo: str(params, "memo") } : {},
12565
12619
  ...method ? { method } : {},
@@ -12746,6 +12800,33 @@ function mentionsFor(text, refs, group) {
12746
12800
  }
12747
12801
  return out;
12748
12802
  }
12803
+ /** Per host: stops watching the invite it marked as the chat on screen. */
12804
+ var inviteShown = /* @__PURE__ */ new WeakMap();
12805
+ /**
12806
+ * Marks a new invite as the chat on screen (native listeners, the quick pace) only until it is paired: a paired chat
12807
+ * keeps its listeners anyway, and a daemon nobody looks at reads an away contact at the background pace, as its
12808
+ * other chats and the same chat after a restart do.
12809
+ */
12810
+ function showUntilPaired(ctx, linkId) {
12811
+ inviteShown.get(ctx)?.();
12812
+ node(ctx).setActiveLink({ linkId });
12813
+ let seen = false;
12814
+ const done = (s) => {
12815
+ const link = s.links.find((l) => l.id === linkId);
12816
+ if (link) seen = true;
12817
+ return link ? !!link.peerParticipationKey : seen;
12818
+ };
12819
+ const stop = () => {
12820
+ off();
12821
+ if (inviteShown.get(ctx) === stop) inviteShown.delete(ctx);
12822
+ };
12823
+ const off = ctx.hub.onState((s) => {
12824
+ if (!done(s)) return;
12825
+ stop();
12826
+ node(ctx).setActiveLink({ linkId: null });
12827
+ });
12828
+ inviteShown.set(ctx, stop);
12829
+ }
12749
12830
  var METHODS = {
12750
12831
  ...WALLET_METHODS,
12751
12832
  ...FILE_METHODS,
@@ -12822,7 +12903,7 @@ var METHODS = {
12822
12903
  linkId,
12823
12904
  label
12824
12905
  });
12825
- node(ctx).setActiveLink({ linkId });
12906
+ showUntilPaired(ctx, linkId);
12826
12907
  const progressOf = (s) => s.links.find((l) => l.id === linkId)?.pairingProgress;
12827
12908
  const progress = await waitForState(ctx, (s) => {
12828
12909
  const p = progressOf(s);
@@ -12856,7 +12937,7 @@ var METHODS = {
12856
12937
  linkId,
12857
12938
  label
12858
12939
  });
12859
- node(ctx).setActiveLink({ linkId });
12940
+ showUntilPaired(ctx, linkId);
12860
12941
  return { chat: linkId };
12861
12942
  },
12862
12943
  async "chat.list"(ctx, params) {
@@ -13082,13 +13163,15 @@ var METHODS = {
13082
13163
  },
13083
13164
  async "chat.retry"(ctx, params) {
13084
13165
  const link = chatOf(ctx, params);
13166
+ const messageId = str(params, "message", true);
13167
+ if (!await node(ctx).getMessage(link.id, messageId)) throw new CliError("not_found", `No message ${messageId} in this chat`);
13085
13168
  await node(ctx).retryMessage({
13086
13169
  linkId: link.id,
13087
- messageId: str(params, "message", true)
13170
+ messageId
13088
13171
  });
13089
13172
  return {
13090
13173
  chat: link.id,
13091
- messageId: params.message
13174
+ messageId
13092
13175
  };
13093
13176
  },
13094
13177
  async "chat.delete"(ctx, params) {
@@ -13406,6 +13489,7 @@ var METHODS = {
13406
13489
  if (args && "confirmedReal" in args && !bool(params, "confirmReal")) throw new CliError("confirm", `${method} spends real money: pass --confirm-real (confirmReal: true over the socket) to confirm it`);
13407
13490
  if (method === "getMessages") return node(ctx).getMessages(str(args ?? {}, "linkId", true));
13408
13491
  if (method === "getState") return state(ctx);
13492
+ if (method === "setActiveLink") inviteShown.get(ctx)?.();
13409
13493
  const target = node(ctx)[method];
13410
13494
  if (typeof target !== "function") throw new CliError("not_found", `The engine has no call ${method}`);
13411
13495
  if (args === void 0) args = void 0;
@@ -13496,7 +13580,8 @@ async function historyOf(ctx, chat, params, json = messageJson) {
13496
13580
  if (params.after !== void 0 && params.after !== null) return history(await node(ctx).getMessages(chat), params, json);
13497
13581
  const limit = num(params, "limit", 50, {
13498
13582
  min: 1,
13499
- max: 1e4
13583
+ max: 1e4,
13584
+ whole: true
13500
13585
  });
13501
13586
  const before = params.before ?? void 0;
13502
13587
  if (before !== void 0 && typeof before !== "number" && typeof before !== "string") throw new CliError("bad_request", "before must be a timestamp or a message id");
@@ -13515,7 +13600,8 @@ async function historyOf(ctx, chat, params, json = messageJson) {
13515
13600
  function history(messages, params, json = messageJson) {
13516
13601
  const limit = num(params, "limit", 50, {
13517
13602
  min: 1,
13518
- max: 1e4
13603
+ max: 1e4,
13604
+ whole: true
13519
13605
  });
13520
13606
  const before = params.before;
13521
13607
  const after = params.after;
@@ -14772,6 +14858,7 @@ function taskFields(options) {
14772
14858
  set("title", options.title);
14773
14859
  set("status", options.status);
14774
14860
  percent(options, "progress");
14861
+ if (options.progress !== void 0 && options.steps !== void 0) throw new CliError("usage", "--progress or --steps, not both");
14775
14862
  set("progress", options.progress);
14776
14863
  set("step", options.step);
14777
14864
  set("branch", options.branch);
@@ -14976,6 +15063,7 @@ function usageWindowOf(value) {
14976
15063
  function usageFields(options) {
14977
15064
  const fields = { ...cardJson(options.json) };
14978
15065
  percent(options, "left");
15066
+ if (options.left !== void 0 && (options.used !== void 0 || options.limit !== void 0)) throw new CliError("usage", "--left or --used with --limit, not both");
14979
15067
  for (const key of [
14980
15068
  "id",
14981
15069
  "left",
@@ -15783,6 +15871,7 @@ var commands$4 = {
15783
15871
  } },
15784
15872
  params: ({ options }, a) => {
15785
15873
  if (a.path === void 0 && options.clear !== true) throw new CliError("usage", "Missing <jpeg>: ghostly profile picture <jpeg> | --clear");
15874
+ if (a.path !== void 0 && options.clear === true) throw new CliError("usage", "--clear removes the picture: not with a <jpeg>");
15786
15875
  return {
15787
15876
  path: here(a.path),
15788
15877
  clear: options.clear === true
@@ -15899,12 +15988,20 @@ var commands$3 = {
15899
15988
  description: "A hub if their app stays online (the default)"
15900
15989
  }
15901
15990
  },
15902
- params: ({ options }, a) => ({
15903
- group: a.group,
15904
- member: a.member,
15905
- role: options.pin === true ? "pin" : options.exclude === true ? "exclude" : "auto",
15906
- ...secret(options)
15907
- })
15991
+ params: ({ options }, a) => {
15992
+ const roles = [
15993
+ "pin",
15994
+ "exclude",
15995
+ "auto"
15996
+ ].filter((role) => options[role] === true);
15997
+ if (roles.length > 1) throw new CliError("usage", "--pin, --exclude or --auto: one of them");
15998
+ return {
15999
+ group: a.group,
16000
+ member: a.member,
16001
+ role: roles[0] ?? "auto",
16002
+ ...secret(options)
16003
+ };
16004
+ }
15908
16005
  },
15909
16006
  "group invite": {
15910
16007
  method: "group.invite",
@@ -15947,11 +16044,14 @@ var commands$3 = {
15947
16044
  description: "Replace it: the old link stops working"
15948
16045
  }
15949
16046
  },
15950
- params: ({ options }, a) => ({
15951
- group: a.group,
15952
- on: options.off !== true,
15953
- reset: options.reset === true
15954
- })
16047
+ params: ({ options }, a) => {
16048
+ if (options.off === true && options.reset === true) throw new CliError("usage", "--off turns the link off: not with --reset");
16049
+ return {
16050
+ group: a.group,
16051
+ on: options.off !== true,
16052
+ reset: options.reset === true
16053
+ };
16054
+ }
15955
16055
  },
15956
16056
  "group list": {
15957
16057
  method: "group.list",
@@ -15971,6 +16071,7 @@ var commands$3 = {
15971
16071
  } },
15972
16072
  params: ({ options }, a) => {
15973
16073
  if (a.path === void 0 && options.clear !== true) throw new CliError("usage", "Missing <jpeg>: ghostly group picture <group> <jpeg> | --clear");
16074
+ if (a.path !== void 0 && options.clear === true) throw new CliError("usage", "--clear removes the picture: not with a <jpeg>");
15974
16075
  return {
15975
16076
  group: a.group,
15976
16077
  path: here(a.path),
@@ -17350,6 +17451,14 @@ var refuseAnswers = Number(process.env.GHOSTLY_CALL_REFUSE_ANSWERS) || 0;
17350
17451
  var GATHER_MS = 5e3;
17351
17452
  var SETTLE_MS = 400;
17352
17453
  /**
17454
+ * With a candidate in (a host one comes in milliseconds), the server reflexive one is waited for this long at most,
17455
+ * not the whole of `GATHER_MS`: a STUN server that answers does in tens of milliseconds, and one that never does (UDP
17456
+ * to it blocked, as on many office, school and hotel networks) made every call ring 5 s late and connect 5 s after
17457
+ * the answer. The apps' calls and the chats' data link bound theirs the same way (`HOST_GATHER_MS`,
17458
+ * `OFFER_HOST_GATHER_MS`).
17459
+ */
17460
+ var HOST_GATHER_MS = 2e3;
17461
+ /**
17353
17462
  * One call's peer connection. Its SDP never travels: the compact signal does (`local`), and each side rebuilds the
17354
17463
  * other's SDP from it (`buildSdpFromSignal`), so the payload types, SSRC and mids here are the ones that rebuilt SDP
17355
17464
  * uses: audio is mid 0, Opus is 111 unless the contact's offer says otherwise.
@@ -17421,7 +17530,7 @@ var CallMedia = class CallMedia {
17421
17530
  const track = pc.addTrack(audioSection);
17422
17531
  let videoTrack = null;
17423
17532
  if (offer?.m?.includes("v")) {
17424
- const video = new ndc.Video(String(offer.m.indexOf("v")), "RecvOnly");
17533
+ const video = new ndc.Video(String(offer.m.indexOf("v")), "Inactive");
17425
17534
  video.addVP8Codec(offer.vp ?? 96);
17426
17535
  videoTrack = pc.addTrack(video);
17427
17536
  videoTrack.onMessage(() => {});
@@ -17440,6 +17549,7 @@ var CallMedia = class CallMedia {
17440
17549
  ss: [ssrc]
17441
17550
  };
17442
17551
  if (offer?.m) local.m = [...offer.m];
17552
+ if (videoTrack) local.vr = 0;
17443
17553
  const listeners = [];
17444
17554
  track.onMessage((packet) => {
17445
17555
  for (const listener of listeners) listener(packet);
@@ -17520,20 +17630,26 @@ function mdnsNote(signal, log) {
17520
17630
  lookup(host, { all: true }).then((found) => log(`${host} resolves here to ${found.map((a) => a.address).join(", ")}`), () => log(`${host} does not resolve here: the call connects only if the contact's checks reach this side`));
17521
17631
  }
17522
17632
  }
17523
- /** Resolves once the local description has its candidates (see GATHER_MS). */
17633
+ /** Resolves once the local description has its candidates (see GATHER_MS and HOST_GATHER_MS). */
17524
17634
  function gathering(pc) {
17525
17635
  return new Promise((resolve) => {
17526
17636
  let settle = null;
17637
+ let any = false;
17527
17638
  const done = () => {
17528
17639
  clearTimeout(cap);
17640
+ clearTimeout(bounded);
17529
17641
  if (settle) clearTimeout(settle);
17530
17642
  resolve();
17531
17643
  };
17532
17644
  const cap = setTimeout(done, GATHER_MS);
17645
+ const bounded = setTimeout(() => {
17646
+ if (any) done();
17647
+ }, HOST_GATHER_MS);
17533
17648
  pc.onGatheringStateChange((state) => {
17534
17649
  if (state === "complete") done();
17535
17650
  });
17536
17651
  pc.onLocalCandidate((candidate) => {
17652
+ any = true;
17537
17653
  if (!settle && / typ srflx/.test(candidate)) settle = setTimeout(done, SETTLE_MS);
17538
17654
  });
17539
17655
  });
@@ -18189,23 +18305,15 @@ var EventHub = class {
18189
18305
  request.onsuccess = () => resolve(request.result);
18190
18306
  request.onerror = () => reject(request.error);
18191
18307
  });
18192
- const rows = await new Promise((resolve, reject) => {
18193
- const out = [];
18194
- const request = this.db.transaction(SEEN).objectStore(SEEN).openCursor();
18195
- request.onsuccess = () => {
18196
- const cursor = request.result;
18197
- if (!cursor) return resolve(out);
18198
- out.push({
18199
- key: cursor.key,
18200
- value: cursor.value
18201
- });
18202
- cursor.continue();
18203
- };
18308
+ const store = this.db.transaction(SEEN).objectStore(SEEN);
18309
+ const all = (request) => new Promise((resolve, reject) => {
18310
+ request.onsuccess = () => resolve(request.result);
18204
18311
  request.onerror = () => reject(request.error);
18205
18312
  });
18206
- for (const { key, value } of rows) {
18207
- const [chat, id] = key;
18208
- this.seenOf(chat).set(id, value);
18313
+ const [keys, values] = await Promise.all([all(store.getAllKeys()), all(store.getAll())]);
18314
+ for (let i = 0; i < keys.length; i++) {
18315
+ const [chat, id] = keys[i];
18316
+ this.seenOf(chat).set(id, values[i]);
18209
18317
  }
18210
18318
  this.mentionsNoted = this.firstRun || this.seen.get(FORMAT)?.get(MENTIONS_NOTED) === "yes";
18211
18319
  }
@@ -18691,8 +18799,13 @@ var EventHub = class {
18691
18799
  }
18692
18800
  if (changes.length) this.persist(key, changes);
18693
18801
  }
18802
+ /**
18803
+ * Relaxed: nobody waits for it, and a note lost to a power cut is made again from the history at the next start
18804
+ * (its event is then reported a second time, as after a crash between a message's row and its note). A disk sync
18805
+ * for each one held the whole daemon for milliseconds, three times per message sent.
18806
+ */
18694
18807
  persist(chat, changes) {
18695
- const store = this.db.transaction(SEEN, "readwrite").objectStore(SEEN);
18808
+ const store = this.db.transaction(SEEN, "readwrite", { durability: "relaxed" }).objectStore(SEEN);
18696
18809
  for (const [id, state] of changes) if (state === null) store.delete([chat, id]);
18697
18810
  else store.put(state, [chat, id]);
18698
18811
  }
@@ -19067,7 +19180,7 @@ function federationInfo(federationId, config) {
19067
19180
  };
19068
19181
  }
19069
19182
  /** The first value a subscription gives that is final, or the last one within `waitMs`. */
19070
- function watch(subscribe, final, waitMs) {
19183
+ function watch$1(subscribe, final, waitMs) {
19071
19184
  return new Promise((resolve) => {
19072
19185
  let last, done = false, cancel = () => {};
19073
19186
  const finish = () => {
@@ -19138,13 +19251,13 @@ var RealClient = class {
19138
19251
  return this.wallet.mint.tryCancelSpendNotes(operationId);
19139
19252
  }
19140
19253
  spendState(operationId, waitMs) {
19141
- return watch((ok, fail) => this.wallet.mint.subscribeSpendNotes(operationId, (s) => ok(SPEND[s] ?? "created"), fail), spendFinal, waitMs);
19254
+ return watch$1((ok, fail) => this.wallet.mint.subscribeSpendNotes(operationId, (s) => ok(SPEND[s] ?? "created"), fail), spendFinal, waitMs);
19142
19255
  }
19143
19256
  redeem(notes, ghostly) {
19144
19257
  return this.wallet.mint.reissueExternalNotes(notes, { ghostly });
19145
19258
  }
19146
19259
  redeemState(operationId, waitMs) {
19147
- return watch((ok, fail) => this.wallet.mint.subscribeReissueExternalNotes(operationId, (s) => ok(typeof s === "string" ? REDEEM[s] ?? "created" : s && typeof s === "object" && "Failed" in s ? "failed" : "created"), (error) => {
19260
+ return watch$1((ok, fail) => this.wallet.mint.subscribeReissueExternalNotes(operationId, (s) => ok(typeof s === "string" ? REDEEM[s] ?? "created" : s && typeof s === "object" && "Failed" in s ? "failed" : "created"), (error) => {
19148
19261
  ok("failed");
19149
19262
  fail(error);
19150
19263
  }), (s) => s === "done" || s === "failed", waitMs);
@@ -19157,7 +19270,7 @@ var RealClient = class {
19157
19270
  };
19158
19271
  }
19159
19272
  receiveState(operationId, waitMs) {
19160
- return watch((ok, fail) => this.wallet.lightning.subscribeLnReceive(operationId, (s) => ok(receiveOf(s)), fail), (s) => s === "claimed" || s === "canceled", waitMs);
19273
+ return watch$1((ok, fail) => this.wallet.lightning.subscribeLnReceive(operationId, (s) => ok(receiveOf(s)), fail), (s) => s === "claimed" || s === "canceled", waitMs);
19161
19274
  }
19162
19275
  async gatewayFee(amountMsats) {
19163
19276
  await this.wallet.lightning.updateGatewayCache().catch(() => {});
@@ -19175,7 +19288,7 @@ var RealClient = class {
19175
19288
  };
19176
19289
  }
19177
19290
  async payState(operationId, internal, waitMs) {
19178
- const state = await watch((ok, fail) => internal ? this.wallet.lightning.subscribeInternalPayment(operationId, ok, fail) : this.wallet.lightning.subscribeLnPay(operationId, ok, fail), (s) => typeof s === "object" && !!s && ("success" in s || "preimage" in s || "refunded" in s || "refund_success" in s || "unexpected_error" in s || "funding_failed" in s) || s === "canceled", waitMs);
19291
+ const state = await watch$1((ok, fail) => internal ? this.wallet.lightning.subscribeInternalPayment(operationId, ok, fail) : this.wallet.lightning.subscribeLnPay(operationId, ok, fail), (s) => typeof s === "object" && !!s && ("success" in s || "preimage" in s || "refunded" in s || "refund_success" in s || "unexpected_error" in s || "funding_failed" in s) || s === "canceled", waitMs);
19179
19292
  if (state && typeof state === "object") {
19180
19293
  const s = state;
19181
19294
  if (s.success?.preimage || s.preimage) return {
@@ -19510,6 +19623,10 @@ var SNAPSHOT = "snapshot.bin";
19510
19623
  var JOURNAL = "journal.bin";
19511
19624
  /** A journal past this is folded into the snapshot at the next quiet moment. */
19512
19625
  var COMPACT_BYTES = 33554432;
19626
+ /** How long `close` waits for the transactions still running: one left open for good does not keep the process. */
19627
+ var CLOSE_WAIT_MS = 5e3;
19628
+ /** How long a relaxed transaction's entry may wait for a sync when no other transaction brings one. */
19629
+ var RELAXED_SYNC_MS = 1e3;
19513
19630
  var BLOB = "__ghostlyBlob";
19514
19631
  var RANGE = "__ghostlyRange";
19515
19632
  var PROBE = "__ghostly_storage_probe__";
@@ -19532,12 +19649,14 @@ async function openPersistentIndexedDb(dir) {
19532
19649
  let queue = Promise.resolve();
19533
19650
  let queued = 0;
19534
19651
  let schemaDirty = false;
19652
+ /** Set while the journal holds relaxed entries no sync has covered yet. */
19653
+ let relaxed = null;
19535
19654
  const pending = /* @__PURE__ */ new WeakMap();
19536
19655
  const snapshotPath = join(dir, SNAPSHOT);
19537
19656
  const journalPath = join(dir, JOURNAL);
19538
19657
  const snapshotFound = existsSync(snapshotPath), journalFound = existsSync(journalPath);
19539
- if (snapshotFound) restore(classes, raw._databases, deserialize(readFileSync(snapshotPath)), (a, b) => factory.cmp(a, b));
19540
- if (journalFound) replay(raw._databases, readFileSync(journalPath));
19658
+ if (snapshotFound) restore(classes, raw._databases, deserialize(readFileSync(snapshotPath)), keyOrder((a, b) => factory.cmp(a, b)));
19659
+ if (journalFound) replay(raw._databases, readFileSync(journalPath), keyOrder((a, b) => factory.cmp(a, b)));
19541
19660
  restoring = false;
19542
19661
  if (journalFound || !snapshotFound) await writeSnapshot();
19543
19662
  const storeProto = classes.ObjectStore.prototype;
@@ -19584,7 +19703,7 @@ async function openPersistentIndexedDb(dir) {
19584
19703
  if (this.mode === "versionchange") schemaDirty = true;
19585
19704
  else {
19586
19705
  const ops = pending.get(this._rollbackLog);
19587
- if (ops?.length) append(ops);
19706
+ if (ops?.length) append(ops, this.durability === "relaxed");
19588
19707
  }
19589
19708
  pending.delete(this._rollbackLog);
19590
19709
  if (schemaDirty) enqueue(writeSnapshot);
@@ -19606,34 +19725,54 @@ async function openPersistentIndexedDb(dir) {
19606
19725
  });
19607
19726
  return request;
19608
19727
  };
19609
- function append(ops) {
19728
+ function append(ops, lazy) {
19610
19729
  if (queued === 0 && !ops.some((op) => op.kind === "put" && hasBlob(op.value))) {
19611
- writeEntry(serialize(ops));
19730
+ writeEntry(serialize(ops), lazy);
19612
19731
  return;
19613
19732
  }
19614
19733
  enqueue(async () => writeEntry(serialize(await Promise.all(ops.map(async (op) => op.kind === "put" ? {
19615
19734
  ...op,
19616
19735
  value: await blobsToDisk(op.value)
19617
- } : op)))));
19736
+ } : op))), lazy));
19618
19737
  }
19619
19738
  function enqueue(work) {
19620
19739
  queued++;
19621
- queue = queue.then(work).catch((error) => {
19622
- process.stderr.write(`ghostly: storage write failed: ${error instanceof Error ? error.message : String(error)}\n`);
19623
- }).finally(() => {
19740
+ queue = queue.then(work).catch(failed).finally(() => {
19624
19741
  queued--;
19625
19742
  });
19626
19743
  return queue;
19627
19744
  }
19628
- function writeEntry(entry) {
19745
+ function failed(error) {
19746
+ process.stderr.write(`ghostly: storage write failed: ${error instanceof Error ? error.message : String(error)}\n`);
19747
+ }
19748
+ /**
19749
+ * `lazy`: a relaxed transaction's entry. A sync blocks the whole process for milliseconds, and what such a
19750
+ * transaction wrote can be made again (the event set's note of a message it already reported): written, it outlives
19751
+ * the process; a power cut may take its last second.
19752
+ */
19753
+ function writeEntry(entry, lazy = false) {
19629
19754
  if (!writing) return;
19630
19755
  journalFd ??= openSync(journalPath, "a", 384);
19631
19756
  const header = Buffer.alloc(4);
19632
19757
  header.writeUInt32BE(entry.length);
19633
19758
  writeSync(journalFd, Buffer.concat([header, entry]));
19634
- fsyncSync(journalFd);
19759
+ if (lazy) relaxed ??= setTimeout(() => {
19760
+ try {
19761
+ sync();
19762
+ } catch (error) {
19763
+ failed(error);
19764
+ }
19765
+ }, RELAXED_SYNC_MS).unref();
19766
+ else sync();
19635
19767
  if (statSync(journalPath).size > COMPACT_BYTES) enqueue(writeSnapshot);
19636
19768
  }
19769
+ function sync() {
19770
+ if (relaxed) {
19771
+ clearTimeout(relaxed);
19772
+ relaxed = null;
19773
+ }
19774
+ if (journalFd !== null) fsyncSync(journalFd);
19775
+ }
19637
19776
  async function writeSnapshot() {
19638
19777
  schemaDirty = false;
19639
19778
  const snapshot = {
@@ -19676,12 +19815,29 @@ async function openPersistentIndexedDb(dir) {
19676
19815
  closeSync(fd);
19677
19816
  }
19678
19817
  renameSync(temporary, snapshotPath);
19818
+ if (relaxed) {
19819
+ clearTimeout(relaxed);
19820
+ relaxed = null;
19821
+ }
19679
19822
  if (journalFd !== null) {
19680
19823
  closeSync(journalFd);
19681
19824
  journalFd = null;
19682
19825
  }
19683
19826
  rmSync(journalPath, { force: true });
19684
19827
  }
19828
+ /**
19829
+ * Resolves once no transaction is left to run. The engine does not wait for every write (`void db.deleteLink(…)`),
19830
+ * and fake-indexeddb runs a transaction on a later turn of the event loop: a command that closed in the turn it wrote
19831
+ * in (no chat to say goodbye to) took its last snapshot before the write, and answered done for what was not on disk.
19832
+ * Two quiet turns in a row: a write that begins when another ends (a chat's messages after its row) is seen too.
19833
+ */
19834
+ async function settled() {
19835
+ const until = Date.now() + CLOSE_WAIT_MS;
19836
+ for (let quiet = 0; quiet < 2 && Date.now() < until;) {
19837
+ await new Promise((resolve) => setImmediate(resolve));
19838
+ quiet = [...raw._databases.values()].some((db) => db.transactions.some((transaction) => transaction._state !== "finished")) ? 0 : quiet + 1;
19839
+ }
19840
+ }
19685
19841
  const installed = globalThis;
19686
19842
  installed.indexedDB = factory;
19687
19843
  installed.IDBKeyRange = IDBKeyRange$1;
@@ -19690,6 +19846,7 @@ async function openPersistentIndexedDb(dir) {
19690
19846
  flush: () => queue,
19691
19847
  compact: () => enqueue(writeSnapshot),
19692
19848
  async close() {
19849
+ await settled();
19693
19850
  await enqueue(writeSnapshot);
19694
19851
  writing = false;
19695
19852
  if (journalFd !== null) {
@@ -19781,17 +19938,49 @@ function dropIndexEntriesByRecord(classes, cmp) {
19781
19938
  for (const candidate of gone.indexes.values()) if (candidate.records === this) index = candidate;
19782
19939
  }
19783
19940
  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;
19941
+ return dropEntries(index, gone.records, cmp);
19942
+ };
19943
+ }
19944
+ /** Drops from an index the entries of records that left its store, and gives back the ones it dropped. */
19945
+ function dropEntries(index, records, cmp) {
19946
+ const dropped = [];
19947
+ for (const entry of indexEntries(index, records, cmp)) {
19948
+ if (index.records.keysAreUnique) {
19949
+ const held = index.records.get(entry.key);
19950
+ if (!held || cmp(held.value, entry.value) !== 0) continue;
19951
+ }
19952
+ index.records.records.delete(entry);
19953
+ dropped.push(entry);
19954
+ }
19955
+ return dropped;
19956
+ }
19957
+ /**
19958
+ * `cmp` for keys that are stored already (a record's, an index entry's): numbers, strings and arrays of them are
19959
+ * ordered here, as IndexedDB orders them (a number before a string before an array; strings by code unit; arrays item
19960
+ * by item, then the shorter first). Anything else (a date, bytes, a value that is no key) is left to `cmp`, which
19961
+ * 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.
19962
+ */
19963
+ function keyOrder(cmp) {
19964
+ const order = (a, b) => {
19965
+ const first = typeof a, second = typeof b;
19966
+ if (first === "number" && second === "number") return a === b ? 0 : a > b ? 1 : a < b ? -1 : cmp(a, b);
19967
+ if (first === "string" && second === "string") return a === b ? 0 : a > b ? 1 : -1;
19968
+ if (first === "number" && second === "string") return -1;
19969
+ if (first === "string" && second === "number") return 1;
19970
+ if (Array.isArray(a)) {
19971
+ if (second === "number" || second === "string") return 1;
19972
+ if (!Array.isArray(b)) return cmp(a, b);
19973
+ const length = Math.min(a.length, b.length);
19974
+ for (let at = 0; at < length; at++) {
19975
+ const item = order(a[at], b[at]);
19976
+ if (item !== 0) return item;
19789
19977
  }
19790
- this.records.delete(entry);
19791
- dropped.push(entry);
19978
+ return a.length === b.length ? 0 : a.length > b.length ? 1 : -1;
19792
19979
  }
19793
- return dropped;
19980
+ if (Array.isArray(b) && (first === "number" || first === "string")) return -1;
19981
+ return cmp(a, b);
19794
19982
  };
19983
+ return order;
19795
19984
  }
19796
19985
  /**
19797
19986
  * The snapshot's records come in key order (as `writeSnapshot` walks them), so each store's tree and each index's are
@@ -19888,21 +20077,27 @@ function fill(store, sorted) {
19888
20077
  /**
19889
20078
  * Replays whole entries; a torn last entry (the process died mid-write) was never acknowledged and is dropped.
19890
20079
  *
19891
- * A store's indexes are set aside while its ops replay and rebuilt once at the end: fake-indexeddb drops an
20080
+ * A store's indexes are set aside while its ops replay and brought in step once at the end: fake-indexeddb drops an
19892
20081
  * overwritten or deleted record from an index by walking every record of it, so a crash after a busy chat took
19893
- * tens of seconds to minutes to start, one write at a time against the whole profile.
20082
+ * tens of seconds to minutes to start, one write at a time against the whole profile. Only the rows the journal
20083
+ * touched change in an index (what each was before goes, what it is now comes in): a journal of one message costs one
20084
+ * message, not the profile's history once per index. A store the journal cleared has its indexes built again whole.
19894
20085
  */
19895
- function replay(databases, journal) {
20086
+ function replay(databases, journal, cmp) {
19896
20087
  const touched = /* @__PURE__ */ new Map();
19897
20088
  try {
19898
20089
  replayEntries(databases, journal, touched);
19899
20090
  } finally {
19900
- for (const [store, indexes] of touched) {
20091
+ for (const [store, { indexes, was, now }] of touched) {
20092
+ const records = store.records;
20093
+ delete records.put;
20094
+ delete records.delete;
19901
20095
  store.rawIndexes = indexes;
19902
20096
  for (const index of indexes.values()) {
19903
- index.records.clear();
20097
+ if (!was) index.records.clear();
19904
20098
  if (!index.initialized) continue;
19905
- for (const record of store.records.values()) index.storeRecord(record);
20099
+ if (was) dropEntries(index, [...was], cmp);
20100
+ for (const record of was ? now : store.records.values()) if (!absent(index.keyPath, record.value)) index.storeRecord(record);
19906
20101
  }
19907
20102
  }
19908
20103
  }
@@ -19922,19 +20117,50 @@ function replayEntries(databases, journal, touched) {
19922
20117
  for (const op of ops) {
19923
20118
  const store = databases.get(op.db)?.rawObjectStores.get(op.store);
19924
20119
  if (!store) continue;
19925
- if (!touched.has(store)) {
19926
- touched.set(store, store.rawIndexes);
19927
- store.rawIndexes = /* @__PURE__ */ new Map();
19928
- }
20120
+ let change = touched.get(store);
20121
+ if (!change) touched.set(store, change = watch(store));
19929
20122
  if (op.kind === "put") store.storeRecord({
19930
20123
  key: op.key,
19931
20124
  value: blobsFromDisk(op.value)
19932
20125
  }, false);
19933
20126
  else if (op.kind === "delete") store.deleteRecord(keyFromDisk(op.key));
19934
- else store.clear();
20127
+ else {
20128
+ change.was = null;
20129
+ store.clear();
20130
+ }
19935
20131
  }
19936
20132
  }
19937
20133
  }
20134
+ /**
20135
+ * Sets a store's indexes aside and notes, as its records are written over and removed, which ones left and which
20136
+ * ones came: the record store hands back what a put replaced and what a delete removed, so a row costs no lookup.
20137
+ * A record that came and left again was never in an index.
20138
+ */
20139
+ function watch(store) {
20140
+ const change = {
20141
+ indexes: store.rawIndexes,
20142
+ was: store.rawIndexes.size ? /* @__PURE__ */ new Set() : null,
20143
+ now: /* @__PURE__ */ new Set()
20144
+ };
20145
+ store.rawIndexes = /* @__PURE__ */ new Map();
20146
+ if (!change.was) return change;
20147
+ const { put, delete: remove } = store.records;
20148
+ const left = (record) => {
20149
+ if (!change.now.delete(record)) change.was?.add(record);
20150
+ };
20151
+ store.records.put = (next, keep) => {
20152
+ const replaced = put.call(store.records, next, keep);
20153
+ if (replaced) left(replaced);
20154
+ change.now.add(next);
20155
+ return replaced;
20156
+ };
20157
+ store.records.delete = (key) => {
20158
+ const removed = remove.call(store.records, key);
20159
+ removed.forEach(left);
20160
+ return removed;
20161
+ };
20162
+ return change;
20163
+ }
19938
20164
  function keyToDisk(key) {
19939
20165
  if (key instanceof IDBKeyRange$1) return { [RANGE]: {
19940
20166
  lower: key.lower,
@@ -20188,14 +20414,14 @@ async function startRuntime(paths, options = {}) {
20188
20414
  (await import("./assets/ghostly_iroh_web-Dvzh3Y6E.js")).initSync({ module: irohWasmBytes() });
20189
20415
  const [{ registerFileBytes }, { NodeFileBytes }] = await Promise.all([import("./assets/fileBytes-BcYZhNgF.js").then((n) => n.o), import("./assets/fileBytes-Dbifu5I5.js")]);
20190
20416
  registerFileBytes("native", async () => new NodeFileBytes(paths.files), true);
20191
- const [{ EngineServer }, { createHyperEndpoint }] = await Promise.all([import("./assets/server-Cy4ox7oD.js").then((n) => n.t), import("./assets/endpoint-pgFkR2AS.js")]);
20417
+ const [{ EngineServer }, { createHyperEndpoint }] = await Promise.all([import("./assets/server-0zfNpABQ.js").then((n) => n.t), import("./assets/endpoint-pgFkR2AS.js")]);
20192
20418
  const network = hyperdhtNetwork();
20193
20419
  const dht = mainlineNetwork();
20194
- const mainline = dht.off ? null : new (await (import("./assets/mainline-1v8OTYvp.js"))).Mainline({
20420
+ const mainline = dht.off ? null : new (await (import("./assets/mainline-BH6s8HVi.js"))).Mainline({
20195
20421
  bootstrap: dht.bootstrap,
20196
20422
  host: dht.host
20197
20423
  });
20198
- const transport = mainline || pinned ? new (await (import("./assets/mainline-1v8OTYvp.js"))).RelaysAndDht(mainline, pinned ? { relays: pinned } : {}, !!pinned) : null;
20424
+ const transport = mainline || pinned ? new (await (import("./assets/mainline-BH6s8HVi.js"))).RelaysAndDht(mainline, pinned ? { relays: pinned } : {}, !!pinned) : null;
20199
20425
  const server = new EngineServer({
20200
20426
  ...transport ? { transport } : {},
20201
20427
  irohWeb: true,
@@ -20340,10 +20566,14 @@ var guarded = false;
20340
20566
  /**
20341
20567
  * A daemon says an error nobody caught (with its stack, on stderr: the daemon's log when detached) and keeps serving:
20342
20568
  * one request gone wrong must not take the profile offline. One-shot commands do not call this; they still fail loudly.
20569
+ *
20570
+ * `outputs`: what it writes to. A write there that fails (a pipe whose reader is gone: the process that started the
20571
+ * daemon died) is dropped, not an error nobody caught: saying it would be one more failed write, and the next, with
20572
+ * no turn left for anything else (a full core, and no stop on SIGTERM).
20343
20573
  */
20344
20574
  function keepServing(proc = process, write = (text) => {
20345
20575
  process.stderr.write(text);
20346
- }) {
20576
+ }, outputs = proc === process ? [process.stdout, process.stderr] : []) {
20347
20577
  if (proc === process) {
20348
20578
  if (guarded) return;
20349
20579
  guarded = true;
@@ -20353,6 +20583,7 @@ function keepServing(proc = process, write = (text) => {
20353
20583
  write(`ghostly: ${what}, the daemon keeps serving: ${error instanceof Error ? error.stack ?? error.message : String(error)}\n`);
20354
20584
  } catch {}
20355
20585
  };
20586
+ for (const output of outputs) output.on("error", () => {});
20356
20587
  proc.on("unhandledRejection", log("unhandled rejection"));
20357
20588
  proc.on("uncaughtException", log("uncaught exception"));
20358
20589
  }
@@ -20556,6 +20787,18 @@ function readCursor(path) {
20556
20787
  const value = Number(readFileSync(path, "utf8").trim());
20557
20788
  return Number.isInteger(value) && value >= 0 ? value : void 0;
20558
20789
  }
20790
+ /**
20791
+ * Whether a cursor can be kept at `path`, asked before anything starts: its folder is there and takes a file. Before, a
20792
+ * folder that was not there was found at the first event: the listener ran, said "event N failed: ENOENT" for each one
20793
+ * and never wrote the cursor, so the next start began at the journal's end and skipped what came while it was down.
20794
+ * Throws what the file system says (`asCliError` names the path and the code: not_found, refused).
20795
+ */
20796
+ function checkCursor(path) {
20797
+ if (!path) return;
20798
+ const folder = dirname(resolve(path));
20799
+ if (!statSync(folder).isDirectory()) throw new CliError("not_found", `No such file or folder: ${folder}`);
20800
+ accessSync(folder, constants.W_OK | constants.X_OK);
20801
+ }
20559
20802
  function writeCursor(path, seq) {
20560
20803
  rmSync(path + ".tmp", { force: true });
20561
20804
  writeFileSync(path + ".tmp", String(seq) + "\n", {
@@ -21097,7 +21340,7 @@ async function profileCommand(sub, argv) {
21097
21340
  if (!file || !name) throw new CliError("usage", "ghostly profile restore <file> <new profile> [--passphrase-file f] [--use]");
21098
21341
  noMoreThan(2, parsed.positionals, "profile restore <file> <new profile> [--passphrase-file f] [--use]");
21099
21342
  const protection = await backupFileProtection(file);
21100
- const paths = await restoreProfile(g.home, checkProfileName(name), file, protection === "none" ? void 0 : backupPassphrase(parsed.options["passphrase-file"]));
21343
+ const paths = await restoreProfile(g.home, checkProfileName(name), file, protection === "none" ? void 0 : backupPassphrase(parsed.options["passphrase-file"], RESTORE_PASSPHRASE));
21101
21344
  if (parsed.options.use) selectProfile(g.home, name);
21102
21345
  print({
21103
21346
  restored: name,
@@ -21133,10 +21376,13 @@ function onStopSignals(stop) {
21133
21376
  "SIGHUP"
21134
21377
  ]) process.on(signal, stop);
21135
21378
  }
21379
+ var BACKUP_PASSPHRASE = "The backup passphrase comes from --passphrase-file or GHOSTLY_BACKUP_PASSPHRASE (at least 12 characters). For a backup that is not encrypted, say so with --no-passphrase";
21380
+ /** Restore takes no --no-passphrase: a backup made without one says so itself. */
21381
+ var RESTORE_PASSPHRASE = "This backup is encrypted: its passphrase comes from --passphrase-file or GHOSTLY_BACKUP_PASSPHRASE";
21136
21382
  /** The backup passphrase: from a file or the environment, never the command line (it would sit in shell history). */
21137
- function backupPassphrase(file) {
21383
+ function backupPassphrase(file, missing = BACKUP_PASSPHRASE) {
21138
21384
  const value = typeof file === "string" ? readFileSync(file, "utf8").replace(/\r?\n$/, "") : process.env.GHOSTLY_BACKUP_PASSPHRASE;
21139
- if (!value) throw new CliError("usage", "The backup passphrase comes from --passphrase-file or GHOSTLY_BACKUP_PASSPHRASE (at least 12 characters). For a backup that is not encrypted, say so with --no-passphrase");
21385
+ if (!value) throw new CliError("usage", missing);
21140
21386
  return value;
21141
21387
  }
21142
21388
  async function runDaemon(g) {
@@ -21361,6 +21607,7 @@ async function listenCommand(argv) {
21361
21607
  description: ""
21362
21608
  }
21363
21609
  });
21610
+ noMoreThan(0, parsed.positionals, SPECIAL.find(([usage]) => usage.startsWith("listen "))[0]);
21364
21611
  const g = globals(parsed);
21365
21612
  pretty = false;
21366
21613
  if (parsed.options.webhook) checkWebhook(String(parsed.options.webhook));
@@ -21369,6 +21616,7 @@ async function listenCommand(argv) {
21369
21616
  if (turns && types.length) throw new CliError("usage", "--turns takes the place of --type: a turn is a message received, or a group message that mentions you");
21370
21617
  const from = parsed.options.from ?? [], groups = parsed.options.group ?? [];
21371
21618
  const cursor = parsed.options.cursor;
21619
+ checkCursor(cursor);
21372
21620
  let since = parsed.options.since ?? readCursor(cursor);
21373
21621
  let handle = null;
21374
21622
  const start = async (call, peerOf) => {