@ghostlytools/cli 1.1.7 → 1.2.1

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.
Files changed (35) hide show
  1. package/README.md +27 -19
  2. package/SKILL.md +15 -7
  3. package/dist/assets/arkade-CtCpFaF7.js +362 -0
  4. package/dist/assets/arkade-CtCpFaF7.js.map +1 -0
  5. package/dist/assets/{endpoint-D_3bYafG.js → endpoint-pgFkR2AS.js} +47 -10
  6. package/dist/assets/endpoint-pgFkR2AS.js.map +1 -0
  7. package/dist/assets/{fileBytes-3AQ2tJzW.js → fileBytes-BcYZhNgF.js} +3 -3
  8. package/dist/assets/{fileBytes-3AQ2tJzW.js.map → fileBytes-BcYZhNgF.js.map} +1 -1
  9. package/dist/assets/{fileBytes-CAZDydIe.js → fileBytes-Dbifu5I5.js} +2 -2
  10. package/dist/assets/{fileBytes-CAZDydIe.js.map → fileBytes-Dbifu5I5.js.map} +1 -1
  11. package/dist/assets/{fileBytesIdb-CmG00qE-.js → fileBytesIdb-DzSaqI0A.js} +3 -3
  12. package/dist/assets/{fileBytesIdb-CmG00qE-.js.map → fileBytesIdb-DzSaqI0A.js.map} +1 -1
  13. package/dist/assets/{fileBytesOpfs-CTgqt-p3.js → fileBytesOpfs-Dx8RZiV5.js} +3 -3
  14. package/dist/assets/{fileBytesOpfs-CTgqt-p3.js.map → fileBytesOpfs-Dx8RZiV5.js.map} +1 -1
  15. package/dist/assets/{hyperdhtRelay-DvXgebV7.js → hyperdhtRelay-C0XRWySP.js} +3 -3
  16. package/dist/assets/{hyperdhtRelay-DvXgebV7.js.map → hyperdhtRelay-C0XRWySP.js.map} +1 -1
  17. package/dist/assets/{idb-BIdus0WX.js → idb-CTF6LF5B.js} +62 -6
  18. package/dist/assets/idb-CTF6LF5B.js.map +1 -0
  19. package/dist/assets/{mainline-B7wHcLaW.js → mainline-DVYdrus6.js} +2 -2
  20. package/dist/assets/{mainline-B7wHcLaW.js.map → mainline-DVYdrus6.js.map} +1 -1
  21. package/dist/assets/{restoredRows-lhTKGmCO.js → restoredRows-C_84q0Pd.js} +2 -2
  22. package/dist/assets/{restoredRows-lhTKGmCO.js.map → restoredRows-C_84q0Pd.js.map} +1 -1
  23. package/dist/assets/{server-GJW_5wGI.js → server-D5FBZYW8.js} +1514 -1088
  24. package/dist/assets/server-D5FBZYW8.js.map +1 -0
  25. package/dist/assets/{src-Cp3o_yHN.js → src-aKGjb_yz.js} +855 -262
  26. package/dist/assets/src-aKGjb_yz.js.map +1 -0
  27. package/dist/assets/usdt-CUfucwZf.js +410 -0
  28. package/dist/assets/usdt-CUfucwZf.js.map +1 -0
  29. package/dist/ghostly.mjs +1326 -262
  30. package/dist/ghostly.mjs.map +1 -1
  31. package/package.json +4 -4
  32. package/dist/assets/endpoint-D_3bYafG.js.map +0 -1
  33. package/dist/assets/idb-BIdus0WX.js.map +0 -1
  34. package/dist/assets/server-GJW_5wGI.js.map +0 -1
  35. package/dist/assets/src-Cp3o_yHN.js.map +0 -1
package/dist/ghostly.mjs CHANGED
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  import { t as __exportAll } from "./assets/rolldown-runtime-8H4AJuhK.js";
3
- import { $ as parseSshPublicKey, $a as signAppRevocation, $n as GROUP_FILE_LIMITS, Aa as replySnippet, An as nip05RootKey, At as didWebDocumentUrl, Bc as sanitizeAvatar, Bn as engineText, Bt as staticDidDocument, Cc as setLinkTraceSink, Cn as DOMAIN_FILE_MAX_BYTES, Co as callRtcConfig, Dn as domainWellKnownFile, Do as signalHasVideo, Dt as didGhostlyServiceEntry, En as domainTxtRecord, Eo as parseCallSignal, Et as checkDidSignature, Fa as buttonLabelClash, Fn as txtValue, Ft as normalizeDid, Ga as APP_STATEMENT_LIMITS, Gc as toZ32, Gi as irohRelayUrl, Hc as fromBase64Url$1, Hr as TYPING_REFRESH_MS, Ia as checkStatusCard, In as wellKnownUrl, It as parseDid, Ja as checkAppRevokeStatement, Ka as appFingerprint, Kc as utf8Decode, Ks as inviteLink, Kt as resolveDidDht, Ln as requestLnurlInvoice, Lr as ChatFiles, Lt as parseDidDocument, Ma as ITEM_STATES, Mc as openRelayPayload, Mn as recordsFromTxt, Mt as didWebFileNames, Na as RUN_RESULTS, Nn as recordsFromWellKnown, Nt as didWebFileUrl, On as encodeDnsQuery, Ot as didJwsAlg, Pa as STATUS_CARD_LIMITS, Pi as identityStatement, Pn as recordsName, Pt as didWebHost, Q as SshSigError, Qa as signAppObject, Qi as readImageMeta, Rn as resolveLightningDestination, Rt as parseDidSignaturePaste, Sn as pubkyHomeserverOf, So as buildSdpFromSignal, St as DID_FILE_MAX_BYTES, Tn as dnsProofName, To as extractParamsFromSdp, Tt as checkDidJws, Uc as randomBytes$1, Vn as decodeBolt11, Vr as TYPING_KINDS, Vt as strictBase64Url, Wa as APP_PREFIXES, Wc as toBase64Url$1, Wi as irohDescriptor, Wr as sanitizeTypingStatus, Y as groupName, Z as SSHSIG_NAMESPACE, Za as readAppRevocations, _n as normalizePubkyKey, _t as parseAtprotoDidDocument, a as checkAppStoreIndex, aa as baseMime, ar as isGroupMessageId, at as BitcoinAddressError, bn as pubkyProofPath, bo as RTC_CONFIG, bt as verifyAtprotoRecordProof, c as APP_BUNDLE_LIMITS, ca as voicePeaksOf, cr as readGroupWant, ct as ATPROTO_FULL_SCOPE, di as BUTTONS_CAPABILITY, dt as atprotoDidMethod, eo as canonicalJson, er as GROUP_HAVE_FRAME, et as parseSshSignature, f as assembleAppBundle, fa as RateWindow, fo as GhostlyHttpError, ft as atprotoProofRecord, gn as newPubkyProofFolder, go as LIMITS, gt as normalizeAtprotoHandle, h as readAppBundle, hn as isPubkyProofFolder, ht as isAtprotoDid, ia as VOICE_LIMITS, ir as groupFileFallback, it as verifyBitcoinMessage, ji as boundVerifiedIdentity, jn as normalizeDomain, jt as didWebFile, kc as RELAY_PAYLOAD_MAX_BYTES, ki as IdentityCheckUnavailable, kn as isPublicAddress, kt as didSignedMessages, lr as readGroupWantNo, lt as ATPROTO_PROOF_COLLECTION, m as isAppUrl, mn as isPubkyKey, mt as checkAtprotoProofRecord, na as sanitizeMime, nn as WALLET_NETWORKS, nr as GROUP_WANT_NO_FRAME, nt as sshSignCommand, o as readAppStore, oa as fileMessageText, oi as checkPushEndpoint, ot as decodeAnyBitcoinAddress, p as checkAppManifest, pn as PUBKY_PROOF_MAX_BYTES, pt as atprotoProofRkey, qa as appRef, qc as utf8Encode, ra as PLAYABLE_AUDIO, rc as seedSigner, rr as fetchesByItself, rt as verifySshSignature, s as signAppStore, sa as parseVoiceMeta, sn as walletNetworkOf, sr as readGroupHave, st as ATPROTO_DECLARED_SCOPE, t as appStoreDecision, ta as sanitizeFileName, to as canonicalJsonBytes, tt as sameSshKey, ut as ATPROTO_PROOF_SCOPE, vn as pubkyProofCapability, vt as shortAtprotoDid, wn as decodeDnsResponse, wo as callSignalHeardAt, wt as DidError, xn as homeserverWebEndpoint, xo as answersOffer, xt as DID_DOCUMENT_MAX_BYTES$1, yn as pubkyProofFile, yo as CALL_SIGNAL_MAX_AGE_MS, yt as AtprotoProofError, zc as MAX_AVATAR_LENGTH, zn as engineError, zr as transferEnded, zt as signingKeys } from "./assets/src-Cp3o_yHN.js";
4
- import { c as fileStore, d as store$1, f as transact, i as STORES, l as openDb, o as databaseName, p as wrap } from "./assets/idb-BIdus0WX.js";
5
- import { a as fileBytesOf, i as fileBytes, l as FILE_BYTES_STEP } from "./assets/fileBytes-3AQ2tJzW.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";
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
+ 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";
@@ -12,13 +12,13 @@ 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 { link, mkdir, open, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
16
- import { basename, dirname, extname, join, relative, resolve, sep } from "node:path";
15
+ import { access, constants, link, mkdir, open, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
16
+ import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
17
+ import { createHash, createPrivateKey, generateKeyPairSync, randomBytes, timingSafeEqual } from "node:crypto";
17
18
  import { decode, npubEncode } from "nostr-tools/nip19";
18
19
  import { BunkerSigner } from "nostr-tools/nip46";
19
20
  import { SimplePool } from "nostr-tools/pool";
20
21
  import { generateSecretKey } from "nostr-tools/pure";
21
- import { createHash, createPrivateKey, generateKeyPairSync, randomBytes, timingSafeEqual } from "node:crypto";
22
22
  import { createServer as createServer$1 } from "node:http";
23
23
  import { Readable } from "node:stream";
24
24
  import { pipeline } from "node:stream/promises";
@@ -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.1.7",
38
+ version: "1.2.1",
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",
@@ -63,7 +63,7 @@ var package_default = {
63
63
  dependencies: {
64
64
  "@arkade-os/sdk": "0.4.78",
65
65
  "@atproto/jwk-webcrypto": "^0.3.4",
66
- "@atproto/oauth-client": "^0.8.8",
66
+ "@atproto/oauth-client": "^0.8.9",
67
67
  "@bitcoindevkit/bdk-wallet-web": "0.3.0",
68
68
  "@bitcoinerlab/descriptors-scure": "^3.2.0",
69
69
  "@cashu/cashu-ts": "^4.11.0",
@@ -87,7 +87,7 @@ var package_default = {
87
87
  "ethers": "6.17.0",
88
88
  "eventsource": "^5.1.2",
89
89
  "fake-indexeddb": "6.2.5",
90
- "hyperdht": "6.34.0",
90
+ "hyperdht": "6.34.1",
91
91
  "keet-identity-key": "3.2.0",
92
92
  "mpg123-decoder": "1.0.3",
93
93
  "node-datachannel": "^0.33.4",
@@ -101,7 +101,7 @@ var package_default = {
101
101
  "@ghostly/iroh-web": "*",
102
102
  "@types/node": "^26.6.4",
103
103
  "typescript": "^5.9.3",
104
- "vite": "^8.3.2",
104
+ "vite": "^8.3.3",
105
105
  "vitest": "^5.0.3"
106
106
  }
107
107
  };
@@ -139,7 +139,7 @@ var CliError = class extends Error {
139
139
  /** Anything thrown, as the error a bot sees: the engine's own refusals keep their words. */
140
140
  function asCliError(error) {
141
141
  if (error instanceof CliError) return error;
142
- if (error && typeof error === "object" && "code" in error && "message" in error && typeof error.code === "string" && error.code in EXIT) return new CliError(error.code, String(error.message));
142
+ if (error && typeof error === "object" && "code" in error && "message" in error && typeof error.code === "string" && Object.hasOwn(EXIT, error.code)) return new CliError(error.code, String(error.message));
143
143
  const local = localFileError(error);
144
144
  if (local) return local;
145
145
  const unopened = profileOpenError(error);
@@ -176,6 +176,7 @@ function localFileError(error) {
176
176
  if (code === "ENOENT" || code === "ENOTDIR") return new CliError("not_found", `No such file or folder: ${where}`);
177
177
  if (code === "EACCES" || code === "EPERM" || code === "EROFS") return new CliError("refused", `Not allowed to write or read ${where}`);
178
178
  if (code === "EISDIR") return new CliError("bad_request", `${where} is a folder: name a file`);
179
+ if (code === "ENAMETOOLONG") return new CliError("bad_request", `A name in ${where} is too long for this file system (at most 255 bytes)`);
179
180
  }
180
181
  //#endregion
181
182
  //#region src/args.ts
@@ -282,9 +283,17 @@ function parseArgs(argv, spec, idSlot = () => false) {
282
283
  options
283
284
  };
284
285
  }
286
+ /** The global option a word names (`--profile`, `--profile=bot`, `-p`), else undefined. */
287
+ function globalOption(arg) {
288
+ const eq = arg.indexOf("=");
289
+ 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;
291
+ }
285
292
  /**
286
293
  * Global options given before the command (`ghostly --profile bot send …`) move after it, where every command's
287
- * parser reads them. Stops at `--` and at the first word that is not a global option.
294
+ * parser reads them. Stops at `--` and at the first word that is not a global option. They go before the first word
295
+ * 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 --`).
288
297
  */
289
298
  function liftGlobals(argv) {
290
299
  const lifted = [];
@@ -292,19 +301,18 @@ function liftGlobals(argv) {
292
301
  while (i < argv.length) {
293
302
  const arg = argv[i];
294
303
  const eq = arg.indexOf("=");
295
- 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;
296
- const option = name ? GLOBAL_OPTIONS[name] : void 0;
304
+ const option = globalOption(arg);
297
305
  if (!option || arg === "--") break;
298
306
  const takesValue = option.type !== "boolean" && eq === -1;
299
307
  lifted.push(...argv.slice(i, i + (takesValue ? 2 : 1)));
300
308
  i += takesValue ? 2 : 1;
301
309
  }
302
310
  const rest = argv.slice(i);
303
- const end = rest.indexOf("--");
304
- return end === -1 ? [...rest, ...lifted] : [
305
- ...rest.slice(0, end),
311
+ const dash = rest[0] === "--" ? 0 : rest.findIndex((word, i) => i > 0 && word.startsWith("-"));
312
+ return dash === -1 ? [...rest, ...lifted] : [
313
+ ...rest.slice(0, dash),
306
314
  ...lifted,
307
- ...rest.slice(end)
315
+ ...rest.slice(dash)
308
316
  ];
309
317
  }
310
318
  //#endregion
@@ -675,6 +683,7 @@ var ENGINE_METHODS = [
675
683
  "joinGroupByLink",
676
684
  "sendGroupMessage",
677
685
  "sendGroupFile",
686
+ "groupFileCheck",
678
687
  "pressButton",
679
688
  "groupMessages",
680
689
  "messagePage",
@@ -807,64 +816,104 @@ var CONTACT_KEY = /^[ybndrfg8ejkmcpqxot1uwisza345h769]{52}$/;
807
816
  * itself (which the contact controls, so it never wins over a label).
808
817
  */
809
818
  function findChat(links, ref) {
819
+ return chatBy(links, ref).link;
820
+ }
821
+ /** `findChat`, and whether only the name the contact gave itself (`theirName`) found the chat. */
822
+ function chatBy(links, ref) {
810
823
  const exact = links.find((link) => link.id === ref);
811
- if (exact) return exact;
824
+ if (exact) return {
825
+ link: exact,
826
+ theirName: false
827
+ };
812
828
  if (CONTACT_KEY.test(ref)) {
813
829
  const byKey = links.filter((link) => link.peerPubKeyZ32 === ref);
814
- if (byKey.length === 1) return byKey[0];
830
+ if (byKey.length === 1) return {
831
+ link: byKey[0],
832
+ theirName: false
833
+ };
815
834
  if (byKey.length > 1) throw new CliError("bad_request", `${JSON.stringify(ref)} names more than one chat: use its id`, { matches: byKey.map((link) => link.id) });
816
835
  throw new CliError("not_found", `No chat with ${JSON.stringify(ref)}`);
817
836
  }
818
837
  const byPrefix = links.filter((link) => link.id.startsWith(ref));
819
- if (byPrefix.length === 1) return byPrefix[0];
838
+ if (byPrefix.length === 1) return {
839
+ link: byPrefix[0],
840
+ theirName: false
841
+ };
820
842
  const lower = ref.toLowerCase();
821
843
  const named = (name) => name?.trim().toLowerCase() === lower;
822
844
  const byLabel = links.filter((link) => named(link.label));
823
845
  const byName = byLabel.length ? byLabel : links.filter((link) => named(link.peerNick));
824
- if (byName.length === 1) return byName[0];
846
+ if (byName.length === 1) return {
847
+ link: byName[0],
848
+ theirName: !byLabel.length
849
+ };
825
850
  if (byPrefix.length > 1 || byName.length > 1) throw new CliError("bad_request", `${JSON.stringify(ref)} names more than one chat: use its id`, { matches: [...byPrefix, ...byName].map((link) => link.id) });
826
851
  throw new CliError("not_found", `No chat ${JSON.stringify(ref)}`);
827
852
  }
853
+ /** Only an invitation so far: this profile is not in the group (yet), and whoever invited chose its name. */
854
+ var invitedOnly = (group) => group.status === void 0 && !!group.invitation;
855
+ /**
856
+ * A group by its id, by a unique prefix of its id, or by its name. A group this profile is in wins over an invitation
857
+ * with the same prefix or name (the one who invites chooses those); an invitation is still found by its id, and by
858
+ * prefix or name where no group of this profile has it.
859
+ */
828
860
  function findGroup(groups, ref) {
829
861
  const exact = groups.find((group) => group.id === ref);
830
862
  if (exact) return exact;
831
- const byPrefix = groups.filter((group) => group.id.startsWith(ref));
863
+ const mineFirst = (found) => {
864
+ const mine = found.filter((group) => !invitedOnly(group));
865
+ return mine.length ? mine : found;
866
+ };
867
+ const byPrefix = mineFirst(groups.filter((group) => group.id.startsWith(ref)));
832
868
  if (byPrefix.length === 1) return byPrefix[0];
833
- const byName = groups.filter((group) => group.name.trim().toLowerCase() === ref.toLowerCase());
869
+ const byName = mineFirst(groups.filter((group) => group.name.trim().toLowerCase() === ref.toLowerCase()));
834
870
  if (byName.length === 1) return byName[0];
835
871
  if (byPrefix.length > 1 || byName.length > 1) throw new CliError("bad_request", `${JSON.stringify(ref)} names more than one group: use its id`, { matches: [...byPrefix, ...byName].map((group) => group.id) });
836
872
  throw new CliError("not_found", `No group ${JSON.stringify(ref)}`);
837
873
  }
838
- var chatOf = (ctx, params) => findChat(state(ctx).links, str(params, "chat", true));
839
874
  /**
840
- * A chat or a group, as `forward` names them: `group:<id>` is a group; anything else a chat first (id, prefix or name),
841
- * then a group. `linkId` is what the engine calls it.
875
+ * A chat or a group, where a command takes either: `group:<id>` is a group; anything else a chat first (`findChat`),
876
+ * 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
+ * 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.
842
879
  */
843
- function chatOrGroup(ctx, ref) {
844
- if (ref.startsWith("group:")) {
845
- const group = findGroup(state(ctx).groups, ref.slice(6));
846
- return {
847
- id: group.id,
848
- linkId: `group:${group.id}`,
849
- group: true
850
- };
851
- }
880
+ function findChatOrGroup(links, groups, ref) {
881
+ if (ref.startsWith("group:")) return { group: findGroup(groups, ref.slice(6)) };
882
+ let chat;
852
883
  try {
853
- const link = findChat(state(ctx).links, ref);
854
- return {
855
- id: link.id,
856
- linkId: link.id,
857
- group: false
858
- };
884
+ chat = chatBy(links, ref);
859
885
  } catch (error) {
860
886
  if (!(error instanceof CliError) || error.code !== "not_found") throw error;
861
- const group = findGroup(state(ctx).groups, ref);
862
- return {
863
- id: group.id,
864
- linkId: `group:${group.id}`,
865
- group: true
866
- };
887
+ try {
888
+ return { group: findGroup(groups, ref) };
889
+ } catch (inner) {
890
+ throw inner instanceof CliError && inner.code !== "not_found" ? inner : error;
891
+ }
867
892
  }
893
+ if (!chat.theirName) return { link: chat.link };
894
+ const exact = groups.find((group) => group.id === ref);
895
+ if (exact) return { group: exact };
896
+ const lower = ref.toLowerCase();
897
+ const also = groups.filter((group) => !invitedOnly(group) && (group.id.startsWith(ref) || group.name.trim().toLowerCase() === lower));
898
+ if (also.length) throw new CliError("bad_request", `${JSON.stringify(ref)} names a chat (the name its contact chose) and a group: use the chat's id or group:<id>`, {
899
+ chat: chat.link.id,
900
+ groups: also.map((group) => group.id)
901
+ });
902
+ return { link: chat.link };
903
+ }
904
+ var chatOf = (ctx, params) => findChat(state(ctx).links, str(params, "chat", true));
905
+ /** A chat or a group as `findChatOrGroup` reads it. `linkId` is what the engine calls it. */
906
+ function chatOrGroup(ctx, ref) {
907
+ const found = findChatOrGroup(state(ctx).links, state(ctx).groups, ref);
908
+ return "group" in found ? {
909
+ id: found.group.id,
910
+ linkId: `group:${found.group.id}`,
911
+ group: true
912
+ } : {
913
+ id: found.link.id,
914
+ linkId: found.link.id,
915
+ group: false
916
+ };
868
917
  }
869
918
  var groupOf = (ctx, params) => findGroup(state(ctx).groups, str(params, "group", true));
870
919
  /** Resolves with `check`'s first defined answer: now, or on a later state; `timeout` error after `ms`. */
@@ -1043,7 +1092,7 @@ function groupReply(reply, myKey) {
1043
1092
  //#endregion
1044
1093
  //#region ../browser/src/engine/groupFiles.ts
1045
1094
  /** A group file's local id: its group's and a random part, never anything a member sends. */
1046
- var groupFileId = (groupId, direction, random = toBase64Url$1(randomBytes$1(12))) => `group-${groupId}-${direction}-${random}`;
1095
+ var groupFileId = (groupId, direction, random = toBase64Url(randomBytes$1(12))) => `group-${groupId}-${direction}-${random}`;
1047
1096
  /** The group a local file id names, among `groupIds`. */
1048
1097
  var groupOfFile = (fileId, groupIds) => [...groupIds].find((g) => fileId.startsWith(`group-${g}-in-`) || fileId.startsWith(`group-${g}-out-`));
1049
1098
  /** What a file's message says: its caption, or (when the author wrote none) the file itself, not the line older apps show. */
@@ -1051,23 +1100,28 @@ var groupFileText = (text, meta) => text === groupFileFallback(meta) ? fileMessa
1051
1100
  /** The profile's files store and file storage, as a 1:1 chat's files use them. */
1052
1101
  var storedGroupFiles = {
1053
1102
  list: (groupId) => fileStore.listForLink(`group:${groupId}`),
1103
+ groups: () => fileStore.groupIds(),
1054
1104
  get: (id) => fileStore.get(id),
1055
1105
  put: (file) => fileStore.put(file),
1056
1106
  patch: (id, fields) => fileStore.patch(id, fields),
1057
1107
  remove: (id) => removeStored(id),
1058
- async write(id) {
1108
+ async write(id, resume = false) {
1059
1109
  const stored = await fileStore.get(id);
1060
1110
  const bytes = stored?.bytes && await fileBytesOf(stored.bytes) || await fileBytes();
1061
- if (await bytes.size(id) !== null) await bytes.truncate(id, 0);
1111
+ const size = await bytes.size(id);
1112
+ const offset = resume && size !== null ? size : 0;
1113
+ if (size !== null && size !== offset) await bytes.truncate(id, offset);
1062
1114
  if (stored && stored.bytes !== bytes.kind) await fileStore.patch(id, { bytes: bytes.kind });
1063
- const appender = new FileAppender(bytes, id, 0);
1115
+ const appender = new FileAppender(bytes, id, offset);
1064
1116
  return {
1117
+ offset,
1065
1118
  append: (chunk) => appender.append(chunk),
1066
1119
  flush: () => appender.flush(),
1067
1120
  digest: async () => {
1068
1121
  await appender.close();
1069
1122
  return bytes.digest(id);
1070
1123
  },
1124
+ close: () => appender.close(),
1071
1125
  discard: async () => {
1072
1126
  await appender.close().catch(() => {});
1073
1127
  await bytes.remove(id);
@@ -1077,8 +1131,14 @@ var storedGroupFiles = {
1077
1131
  read: (file, offset, length) => readStored(file, offset, length),
1078
1132
  room: async () => (await fileBytes()).room()
1079
1133
  };
1080
- /** Why a want waits, as the person reads it. */
1081
- var NOBODY_CONNECTED = "Nobody you are connected to has this file yet";
1134
+ /** Why a want waits, as the person reads it (the app shows its own words for each `wait`). */
1135
+ var WAITING_NOTES = {
1136
+ nobody: "Nobody you are connected to has this file yet",
1137
+ busy: "Its holders are busy; asking again soon",
1138
+ damaged: "It arrived damaged; ask again"
1139
+ };
1140
+ /** Why a want stopped: every holder served bytes that are not the author's file. */
1141
+ var DAMAGED$1 = "Every copy of this file arrived damaged and was deleted. Ask for it again later.";
1082
1142
  var GroupFileDesk = class {
1083
1143
  deps;
1084
1144
  kept = /* @__PURE__ */ new Map();
@@ -1090,6 +1150,7 @@ var GroupFileDesk = class {
1090
1150
  own = /* @__PURE__ */ new Map();
1091
1151
  /** Announcements sent per group, and taken per member of a group (WISP 503 § Rules for both, Pace). */
1092
1152
  sentPace = /* @__PURE__ */ new Map();
1153
+ sentTaken = /* @__PURE__ */ new Map();
1093
1154
  takenPace = /* @__PURE__ */ new Map();
1094
1155
  /** `<group>\n<message id>` a `group-have` named before its message came → its holders, newest first. */
1095
1156
  ahead = /* @__PURE__ */ new Map();
@@ -1126,7 +1187,8 @@ var GroupFileDesk = class {
1126
1187
  auto: !!file.group.auto,
1127
1188
  asked: !!file.group.asked,
1128
1189
  held: file.transfer?.state === "done",
1129
- holders: []
1190
+ holders: [],
1191
+ damaged: /* @__PURE__ */ new Set()
1130
1192
  };
1131
1193
  this.add(kept);
1132
1194
  if (kept.held) this.show(kept, {
@@ -1135,6 +1197,14 @@ var GroupFileDesk = class {
1135
1197
  size,
1136
1198
  direction: kept.mine ? "out" : "in"
1137
1199
  });
1200
+ else if (file.transfer?.state === "failed") this.show(kept, {
1201
+ state: "failed",
1202
+ transferred: 0,
1203
+ size,
1204
+ direction: "in",
1205
+ retry: true,
1206
+ error: file.transfer.error ?? DAMAGED$1
1207
+ });
1138
1208
  else if (kept.auto || kept.asked) this.wants.set(kept.id, this.newWant());
1139
1209
  else this.show(kept, {
1140
1210
  state: "transferring",
@@ -1147,9 +1217,27 @@ var GroupFileDesk = class {
1147
1217
  for (const [id] of this.wants) this.ask(this.kept.get(id));
1148
1218
  this.deps.changed();
1149
1219
  }
1150
- /** An error when this group took as many announcements of mine as it takes in a minute; null when one may go now. */
1220
+ /**
1221
+ * At start, once every group was read: the files of a group this device no longer has (`groupIds` are the groups it
1222
+ * has, a tombstone or a join under way among them) go, with their bytes. A group left before Leave took its files
1223
+ * left them behind, where nothing lists them.
1224
+ */
1225
+ async sweep(groupIds) {
1226
+ const here = new Set(groupIds);
1227
+ for (const groupId of await this.deps.store.groups().catch(() => [])) {
1228
+ if (here.has(groupId)) continue;
1229
+ for (const file of await this.deps.store.list(groupId).catch(() => [])) await this.deps.store.remove(file.id).catch(() => {});
1230
+ }
1231
+ }
1232
+ /**
1233
+ * An error when this group took as many announcements of mine as it takes in a minute, or as its members take from
1234
+ * one member in `announceWindowMs` (past that they would get the text, not the file); null when one may go now.
1235
+ */
1151
1236
  mayAnnounce(groupId) {
1152
- return this.pace(this.sentPace, groupId, GROUP_FILE_LIMITS.announcePerMinute, 6e4).wait() ? "You sent many files to this group just now. Wait a minute." : null;
1237
+ const wait = Math.max(...this.sentPaces(groupId).map((pace) => pace.wait()));
1238
+ if (!wait) return null;
1239
+ const minutes = Math.ceil(wait / 6e4);
1240
+ return `You sent many files to this group just now. Wait ${minutes > 1 ? `${minutes} minutes` : "a minute"}.`;
1153
1241
  }
1154
1242
  /** My file, stored under `fileId` with the author's digest, is about to be announced in the group: its message takes it. */
1155
1243
  outgoing(groupId, fileId, meta) {
@@ -1170,7 +1258,7 @@ var GroupFileDesk = class {
1170
1258
  const ownId = m.sender === me ? this.own.get(`${groupId}\n${m.file.d}`) : void 0;
1171
1259
  if (ownId) {
1172
1260
  this.own.delete(`${groupId}\n${m.file.d}`);
1173
- this.pace(this.sentPace, groupId, GROUP_FILE_LIMITS.announcePerMinute, 6e4).take();
1261
+ for (const pace of this.sentPaces(groupId)) pace.take();
1174
1262
  const kept = {
1175
1263
  id: ownId,
1176
1264
  groupId,
@@ -1181,7 +1269,8 @@ var GroupFileDesk = class {
1181
1269
  auto: false,
1182
1270
  asked: false,
1183
1271
  held: true,
1184
- holders: []
1272
+ holders: [],
1273
+ damaged: /* @__PURE__ */ new Set()
1185
1274
  };
1186
1275
  this.add(kept);
1187
1276
  const transfer = {
@@ -1218,7 +1307,8 @@ var GroupFileDesk = class {
1218
1307
  auto,
1219
1308
  asked: false,
1220
1309
  held: false,
1221
- holders
1310
+ holders,
1311
+ damaged: /* @__PURE__ */ new Set()
1222
1312
  };
1223
1313
  const { name, size, mime, voice, video, image } = m.file;
1224
1314
  this.add(kept);
@@ -1271,9 +1361,18 @@ var GroupFileDesk = class {
1271
1361
  kept.asked = true;
1272
1362
  await this.deps.store.patch(kept.id, { group: this.groupField(kept) }).catch(() => {});
1273
1363
  }
1364
+ kept.damaged.clear();
1365
+ if (this.deps.transfers.get(kept.id)?.state === "failed") await this.deps.store.patch(kept.id, { transfer: {
1366
+ state: "transferring",
1367
+ transferred: 0,
1368
+ size: kept.meta.size
1369
+ } }).catch(() => {});
1274
1370
  const want = this.wants.get(kept.id);
1275
1371
  if (!want) this.wants.set(kept.id, this.newWant());
1276
- else if (!want.wire) want.tried.clear();
1372
+ else if (!want.wire) {
1373
+ want.tried.clear();
1374
+ want.why = void 0;
1375
+ }
1277
1376
  this.ask(kept);
1278
1377
  }
1279
1378
  /** The group is gone from this device: its files go too. */
@@ -1348,6 +1447,7 @@ var GroupFileDesk = class {
1348
1447
  }
1349
1448
  edge.taking.set(frame.id, kept.id);
1350
1449
  want.wire = frame.id;
1450
+ want.why = void 0;
1351
1451
  want.movedAt = this.deps.now();
1352
1452
  }
1353
1453
  return edge.files.handle(frame);
@@ -1371,9 +1471,13 @@ var GroupFileDesk = class {
1371
1471
  want.wire = void 0;
1372
1472
  }
1373
1473
  if (want.holder) want.tried.add(want.holder);
1374
- else want.tried.clear();
1474
+ else {
1475
+ want.tried.clear();
1476
+ want.why = void 0;
1477
+ }
1375
1478
  this.ask(kept);
1376
1479
  }
1480
+ this.letGo();
1377
1481
  for (const edge of this.edgesById.values()) {
1378
1482
  if (!edge.have.size || now - edge.haveAt < GROUP_FILE_LIMITS.haveEveryMs || !this.deps.ready(edge.linkId)) continue;
1379
1483
  this.sendHave(edge, [...edge.have]);
@@ -1415,10 +1519,20 @@ var GroupFileDesk = class {
1415
1519
  if (!want || kept.held || want.wire) return;
1416
1520
  const membership = this.deps.membership(kept.groupId);
1417
1521
  const edges = this.deps.edges(kept.groupId);
1418
- const order = [kept.author, ...kept.holders.filter((key) => key !== kept.author)].filter((key) => membership && key !== membership.me && membership.inRoster(key) && !want.tried.has(key));
1522
+ const known = [kept.author, ...kept.holders.filter((key) => key !== kept.author)].filter((key) => membership && key !== membership.me && membership.inRoster(key));
1523
+ if (known.length && known.every((key) => kept.damaged.has(key))) {
1524
+ this.stopDamaged(kept);
1525
+ return;
1526
+ }
1527
+ const order = known.filter((key) => !want.tried.has(key) && !kept.damaged.has(key));
1528
+ let behind = false;
1419
1529
  for (const key of order) {
1420
1530
  const linkId = edges.get(key);
1421
1531
  if (!linkId || !this.deps.ready(linkId) || !this.edgesById.get(linkId)?.files.live) continue;
1532
+ if (this.askedOf(linkId, want)) {
1533
+ behind = true;
1534
+ continue;
1535
+ }
1422
1536
  if (!this.deps.send(linkId, {
1423
1537
  t: "group-want",
1424
1538
  g: kept.groupId,
@@ -1435,7 +1549,7 @@ var GroupFileDesk = class {
1435
1549
  this.show(kept, {
1436
1550
  state: "transferring",
1437
1551
  stage: "queued",
1438
- transferred: 0,
1552
+ transferred: kept.stored ?? 0,
1439
1553
  size: kept.meta.size,
1440
1554
  direction: "in"
1441
1555
  });
@@ -1446,13 +1560,50 @@ var GroupFileDesk = class {
1446
1560
  linkId: void 0,
1447
1561
  at: this.deps.now()
1448
1562
  });
1563
+ if (behind) {
1564
+ this.show(kept, {
1565
+ state: "transferring",
1566
+ stage: "queued",
1567
+ transferred: kept.stored ?? 0,
1568
+ size: kept.meta.size,
1569
+ direction: "in"
1570
+ });
1571
+ return;
1572
+ }
1573
+ const wait = want.why ?? "nobody";
1449
1574
  this.show(kept, {
1450
1575
  state: "transferring",
1451
1576
  stage: "waiting",
1452
- transferred: 0,
1577
+ transferred: kept.stored ?? 0,
1453
1578
  size: kept.meta.size,
1454
1579
  direction: "in",
1455
- note: NOBODY_CONNECTED
1580
+ wait,
1581
+ note: WAITING_NOTES[wait],
1582
+ ...wait === "damaged" && { stalled: true }
1583
+ });
1584
+ }
1585
+ /** Another file is asked of, or coming from, the member at the other end of this edge. */
1586
+ askedOf(linkId, except) {
1587
+ for (const want of this.wants.values()) if (want !== except && want.linkId === linkId) return true;
1588
+ return false;
1589
+ }
1590
+ /** No holder serves the author's bytes: the want stops, and stays stopped after a restart, until Download. */
1591
+ stopDamaged(kept) {
1592
+ this.wants.delete(kept.id);
1593
+ const { size } = kept.meta;
1594
+ this.deps.store.patch(kept.id, { transfer: {
1595
+ state: "failed",
1596
+ transferred: 0,
1597
+ size,
1598
+ error: DAMAGED$1
1599
+ } }).catch(() => {});
1600
+ this.show(kept, {
1601
+ state: "failed",
1602
+ transferred: 0,
1603
+ size,
1604
+ direction: "in",
1605
+ retry: true,
1606
+ error: DAMAGED$1
1456
1607
  });
1457
1608
  }
1458
1609
  /** A holder's no: the next one is asked. One that deleted the file is no holder any more. */
@@ -1461,9 +1612,11 @@ var GroupFileDesk = class {
1461
1612
  const want = kept && this.wants.get(kept.id);
1462
1613
  if (!kept || !want || want.linkId !== linkId || want.wire) return;
1463
1614
  if (why === "gone") kept.holders = kept.holders.filter((key) => key !== peer);
1615
+ if (why === "busy") want.why = "busy";
1464
1616
  want.tried.add(peer);
1465
1617
  want.holder = want.linkId = void 0;
1466
1618
  this.ask(kept);
1619
+ this.retry(groupId);
1467
1620
  }
1468
1621
  /** A member says it holds these files: remembered as a holder of each (newest first), and asked when a want waits. */
1469
1622
  heard(groupId, from, ids) {
@@ -1499,8 +1652,12 @@ var GroupFileDesk = class {
1499
1652
  });
1500
1653
  return;
1501
1654
  }
1502
- const wire = toBase64Url$1(randomBytes$1(12));
1655
+ const wire = toBase64Url(randomBytes$1(12));
1503
1656
  edge.serving.set(wire, kept.id);
1657
+ edge.servedMoved.set(wire, {
1658
+ confirmed: 0,
1659
+ at: this.deps.now()
1660
+ });
1504
1661
  const { name, mime, size, voice, video, image } = kept.meta;
1505
1662
  const file = {
1506
1663
  id: wire,
@@ -1524,8 +1681,13 @@ var GroupFileDesk = class {
1524
1681
  if (!membership || !membership.couldRead(edge.peer, messageId)) return "refused";
1525
1682
  if (!kept) return "gone";
1526
1683
  if (!this.deps.settings().serveFiles || !edge.files.live) return "busy";
1684
+ this.letGo();
1527
1685
  const active = this.servingNow();
1528
- if (active.length >= GROUP_FILE_LIMITS.serveAtOnce || active.some((a) => a.peer === edge.peer)) return "busy";
1686
+ if (active.length >= GROUP_FILE_LIMITS.serveAtOnce) {
1687
+ edge.busy.add(messageId);
1688
+ return "busy";
1689
+ }
1690
+ if (active.some((a) => a.peer === edge.peer && a.sending)) return "busy";
1529
1691
  const today = Math.floor(this.deps.now() / 864e5);
1530
1692
  if (this.served.day !== today) this.served = {
1531
1693
  day: today,
@@ -1534,18 +1696,37 @@ var GroupFileDesk = class {
1534
1696
  const reserved = active.reduce((sum, a) => sum + a.size, 0);
1535
1697
  return this.served.bytes + reserved + kept.meta.size > GROUP_FILE_LIMITS.serveBytesPerDay ? "busy" : null;
1536
1698
  }
1537
- /** Transfers served now, all groups together: to whom, and their size. */
1699
+ /** Transfers served now, all groups together: to whom, their size, and whether bytes are still to go. */
1538
1700
  servingNow() {
1539
1701
  const active = [];
1540
1702
  for (const edge of this.edgesById.values()) for (const wire of edge.serving.keys()) {
1541
1703
  const record = edge.files.get("out", wire);
1542
1704
  if (record && !transferEnded(record)) active.push({
1543
1705
  peer: edge.peer,
1544
- size: record.file.size
1706
+ size: record.file.size,
1707
+ sending: record.confirmed < record.file.size
1545
1708
  });
1546
1709
  }
1547
1710
  return active;
1548
1711
  }
1712
+ /**
1713
+ * Served transfers that no longer count end, their places free for others (WISP 503 § Serving limits): the asker's
1714
+ * session closed, it is not in the roster any more, or its bytes have not moved for `wantWaitMs` (the asker has moved
1715
+ * on by then).
1716
+ */
1717
+ letGo() {
1718
+ const now = this.deps.now();
1719
+ for (const edge of this.edgesById.values()) for (const wire of [...edge.serving.keys()]) {
1720
+ const record = edge.files.get("out", wire);
1721
+ if (!record || transferEnded(record)) continue;
1722
+ let moved = edge.servedMoved.get(wire);
1723
+ if (!moved || moved.confirmed !== record.confirmed) edge.servedMoved.set(wire, moved = {
1724
+ confirmed: record.confirmed,
1725
+ at: now
1726
+ });
1727
+ if (!edge.files.live || !this.deps.membership(edge.groupId)?.inRoster(edge.peer) || now - moved.at >= GROUP_FILE_LIMITS.wantWaitMs) edge.files.cancel("out", wire);
1728
+ }
1729
+ }
1549
1730
  /** Says on an edge which files this device holds. */
1550
1731
  sendHave(edge, ids) {
1551
1732
  for (let i = 0; i < ids.length; i += GROUP_FILE_LIMITS.haveIds) if (!this.deps.send(edge.linkId, {
@@ -1579,9 +1760,11 @@ var GroupFileDesk = class {
1579
1760
  peer,
1580
1761
  files: void 0,
1581
1762
  serving: /* @__PURE__ */ new Map(),
1763
+ servedMoved: /* @__PURE__ */ new Map(),
1582
1764
  taking: /* @__PURE__ */ new Map(),
1583
1765
  have: /* @__PURE__ */ new Set(),
1584
- haveAt: 0
1766
+ haveAt: 0,
1767
+ busy: /* @__PURE__ */ new Set()
1585
1768
  };
1586
1769
  edge.files = new ChatFiles({
1587
1770
  send: (frame) => {
@@ -1615,13 +1798,28 @@ var GroupFileDesk = class {
1615
1798
  const kept = this.kept.get(edge.taking.get(record.id) ?? "");
1616
1799
  if (!kept) throw new Error("No such file");
1617
1800
  await kept.idle;
1618
- const writer = await this.deps.store.write(kept.id);
1801
+ const writer = await this.deps.store.write(kept.id, !kept.fresh);
1802
+ kept.fresh = false;
1803
+ const resumed = writer.offset > 0;
1804
+ let damaged = false;
1619
1805
  return {
1620
- offset: 0,
1806
+ offset: writer.offset,
1621
1807
  append: (bytes) => writer.append(bytes),
1622
1808
  flush: () => writer.flush(),
1623
- verify: async () => await writer.digest() === kept.meta.d,
1624
- discard: () => kept.idle = writer.discard().catch(() => {})
1809
+ verify: async () => {
1810
+ damaged = await writer.digest() !== kept.meta.d;
1811
+ const want = this.wants.get(kept.id);
1812
+ if (damaged && want) want.why = "damaged";
1813
+ return !damaged;
1814
+ },
1815
+ discard: () => {
1816
+ if (damaged) {
1817
+ kept.stored = 0;
1818
+ if (resumed) kept.fresh = kept.unclear = true;
1819
+ else kept.damaged.add(edge.peer);
1820
+ }
1821
+ return kept.idle = (damaged ? writer.discard() : writer.close()).catch(() => {});
1822
+ }
1625
1823
  };
1626
1824
  }
1627
1825
  async openSource(edge, record) {
@@ -1643,6 +1841,12 @@ var GroupFileDesk = class {
1643
1841
  };
1644
1842
  this.served.bytes += record.confirmed;
1645
1843
  edge.serving.delete(record.id);
1844
+ edge.servedMoved.delete(record.id);
1845
+ const serving = this.deps.settings().serveFiles;
1846
+ for (const other of this.edgesById.values()) {
1847
+ if (serving) for (const id of other.busy) other.have.add(id);
1848
+ other.busy.clear();
1849
+ }
1646
1850
  }
1647
1851
  return;
1648
1852
  }
@@ -1652,6 +1856,7 @@ var GroupFileDesk = class {
1652
1856
  want.moved = transferred;
1653
1857
  want.movedAt = this.deps.now();
1654
1858
  }
1859
+ if (record.state !== "failed" && record.state !== "declined" && record.state !== "cancelled") kept.stored = transferred;
1655
1860
  const { size } = kept.meta;
1656
1861
  switch (record.state) {
1657
1862
  case "done":
@@ -1670,12 +1875,14 @@ var GroupFileDesk = class {
1670
1875
  direction: "in"
1671
1876
  });
1672
1877
  this.queueHave(kept);
1878
+ this.retry(kept.groupId);
1673
1879
  return;
1674
1880
  case "failed":
1675
1881
  case "declined":
1676
1882
  case "cancelled":
1677
1883
  edge.taking.delete(record.id);
1678
- if (want.holder) want.tried.add(want.holder);
1884
+ if (want.holder && !kept.unclear) want.tried.add(want.holder);
1885
+ kept.unclear = false;
1679
1886
  Object.assign(want, {
1680
1887
  wire: void 0,
1681
1888
  holder: void 0,
@@ -1683,6 +1890,7 @@ var GroupFileDesk = class {
1683
1890
  moved: 0
1684
1891
  });
1685
1892
  this.ask(kept);
1893
+ this.retry(kept.groupId);
1686
1894
  return;
1687
1895
  case "verifying":
1688
1896
  this.show(kept, {
@@ -1724,6 +1932,10 @@ var GroupFileDesk = class {
1724
1932
  if (!window) windows.set(key, window = new RateWindow(limit, ms, () => this.deps.now()));
1725
1933
  return window;
1726
1934
  }
1935
+ /** My announcements in a group, in a minute and in the window the members count them over. */
1936
+ sentPaces(groupId) {
1937
+ return [this.pace(this.sentPace, groupId, GROUP_FILE_LIMITS.announcePerMinute, 6e4), this.pace(this.sentTaken, groupId, GROUP_FILE_LIMITS.announceTaken, GROUP_FILE_LIMITS.announceWindowMs)];
1938
+ }
1727
1939
  show(kept, view) {
1728
1940
  this.deps.transfers.set(kept.id, view);
1729
1941
  this.deps.changed();
@@ -2205,24 +2417,8 @@ function filesOf(messages, transfers) {
2205
2417
  transfer: transfers[m.file.id] ?? null
2206
2418
  }));
2207
2419
  }
2208
- /**
2209
- * The chat a file command names, or else a group (`group:<id>`, or a group's id, prefix or name where no chat has it),
2210
- * as `forward` reads them. A name that is neither says what a chat would.
2211
- */
2212
- function chatOrGroupOf(ctx, params) {
2213
- const ref = str(params, "chat", true);
2214
- if (ref.startsWith("group:")) return { group: findGroup(state(ctx).groups, ref.slice(6)) };
2215
- try {
2216
- return { link: chatOf(ctx, params) };
2217
- } catch (error) {
2218
- if (!(error instanceof CliError) || error.code !== "not_found") throw error;
2219
- try {
2220
- return { group: findGroup(state(ctx).groups, ref) };
2221
- } catch {
2222
- throw error;
2223
- }
2224
- }
2225
- }
2420
+ /** The chat or the group a file command names (`findChatOrGroup`). */
2421
+ var chatOrGroupOf = (ctx, params) => findChatOrGroup(state(ctx).links, state(ctx).groups, str(params, "chat", true));
2226
2422
  /**
2227
2423
  * The chat or group a file belongs to: a file id starts with its chat's id (a group's file with `group-<id>-`), so it
2228
2424
  * may be left out; given, it must be the file's own. `where` is what the answer names it by.
@@ -2283,6 +2479,8 @@ async function sendToGroup(ctx, params, group, path, size) {
2283
2479
  if (replyTo) {
2284
2480
  if (!(await node(ctx).groupMessages({ groupId: group.id })).some((m) => m.id === replyTo || replyRef(m) === replyTo)) throw new CliError("not_found", "That message is not in this group, or cannot be replied to");
2285
2481
  }
2482
+ const check = await node(ctx).groupFileCheck({ groupId: group.id });
2483
+ if (check.error) throw check.refused ? new CliError("refused", check.error) : groupSendRefused(check.error);
2286
2484
  const { voice, warning } = await voiceOf(params, path, mime);
2287
2485
  const source = await openAsBlob(path, { type: mime });
2288
2486
  const image = voice ? void 0 : await readImageMeta(source, mime);
@@ -2343,6 +2541,29 @@ async function sendToGroup(ctx, params, group, path, size) {
2343
2541
  ...warning ? { warning } : {}
2344
2542
  };
2345
2543
  }
2544
+ /** Bytes a file system takes in one name: 255 on ext4, btrfs, APFS (NTFS counts 255 UTF-16 units, within this too). */
2545
+ var NAME_BYTES = 255;
2546
+ /**
2547
+ * `name` as an entry of a folder: an app's name may have 200 characters, which in UTF-8 can be 800 bytes. The end of
2548
+ * its stem goes until it fits, its extension stays (as `sanitizeFileName` shortens it), so "文…文.txt" is saved as a text.
2549
+ */
2550
+ function fitName(name) {
2551
+ const fits = (candidate) => Buffer.byteLength(candidate) <= NAME_BYTES;
2552
+ if (fits(name)) return name;
2553
+ const extension = /\.[^.\s]{1,15}$/u.exec(name)?.[0] ?? "";
2554
+ const stem = [...name.slice(0, name.length - extension.length)];
2555
+ while (stem.length && !fits(stem.join("").trimEnd() + extension)) stem.pop();
2556
+ return (stem.join("").trimEnd() || "file") + extension;
2557
+ }
2558
+ /**
2559
+ * The name `file save --dir` writes a file under. Its sender chose it, and the folder is one scripts and people run
2560
+ * tools in: a name that starts with a dash is an option to the tool a `*` hands it to ("-rf", "--checkpoint-action=…"),
2561
+ * so it gets a `_` first, as a Windows device name does in `sanitizeFileName`.
2562
+ */
2563
+ function saveName(name) {
2564
+ const clean = sanitizeFileName(name);
2565
+ return fitName(clean.startsWith("-") ? `_${clean}` : clean);
2566
+ }
2346
2567
  var ACTIONS = [
2347
2568
  "accept",
2348
2569
  "decline",
@@ -2363,6 +2584,7 @@ var FILE_METHODS = {
2363
2584
  throw new CliError("not_found", `No file ${path}`);
2364
2585
  }
2365
2586
  if (!info.isFile()) throw new CliError("bad_request", `${path} is not a file`);
2587
+ await access(path, constants.R_OK);
2366
2588
  if ("group" in target) return sendToGroup(ctx, params, target.group, path, info.size);
2367
2589
  const { link } = target;
2368
2590
  if (link.profile && link.dataLink === "open" && !link.capabilities?.files) throw new CliError("unavailable", "The contact's app takes no files");
@@ -2376,7 +2598,7 @@ var FILE_METHODS = {
2376
2598
  const { voice, warning } = await voiceOf(params, path, mime);
2377
2599
  const source = await openAsBlob(path, { type: mime });
2378
2600
  const image = voice ? void 0 : await readImageMeta(source, mime);
2379
- const wireId = toBase64Url$1(randomBytes$1(12));
2601
+ const wireId = toBase64Url(randomBytes$1(12));
2380
2602
  const file = {
2381
2603
  id: `${link.id}-out-${wireId}`,
2382
2604
  name: sanitizeFileName(str(params, "name") ?? basename(path)),
@@ -2530,7 +2752,7 @@ var FILE_METHODS = {
2530
2752
  size: transfer.size
2531
2753
  };
2532
2754
  },
2533
- /** Writes a received (or sent) file to disk: into `dir` under its own name, or to `path`. Never over a file unless `force`. */
2755
+ /** Writes a received (or sent) file to disk: into `dir` under its own name (`saveName`), or to `path`. Never over a file unless `force`. */
2534
2756
  async "file.save"(ctx, params) {
2535
2757
  const fileId = str(params, "file", true);
2536
2758
  if (params.chat !== void 0) fileChat(ctx, params, fileId);
@@ -2543,7 +2765,7 @@ var FILE_METHODS = {
2543
2765
  if (stored.leftOut) throw new CliError("not_found", "Not in this backup: the light backup this profile was restored from left the file out");
2544
2766
  const transfer = state(ctx).transfers[fileId] ?? stored.transfer;
2545
2767
  if (transfer && transfer.state !== "done") throw new CliError("unavailable", `The file is not all here yet (${transfer.transferred} of ${transfer.size} bytes)`);
2546
- const target = str(params, "path") ? resolve(str(params, "path", true)) : join(resolve(str(params, "dir") ?? "."), sanitizeFileName(stored.metadata.name));
2768
+ const target = str(params, "path") ? resolve(str(params, "path", true)) : join(resolve(str(params, "dir") ?? "."), saveName(stored.metadata.name));
2547
2769
  const flags = bool(params, "force") ? "w" : "wx";
2548
2770
  let out;
2549
2771
  try {
@@ -2596,7 +2818,7 @@ var DELIVERY_RANK = {
2596
2818
  async function waitForEdit(ctx, chat, messageId, ms) {
2597
2819
  const until = Date.now() + ms;
2598
2820
  for (;;) {
2599
- const message = (await node(ctx).getMessages(chat)).find((m) => m.id === messageId);
2821
+ const message = await node(ctx).getMessage(chat, messageId);
2600
2822
  if (!message) throw new CliError("not_found", `No message ${messageId} in this chat`);
2601
2823
  if (!message.edit?.pending) return message;
2602
2824
  if (Date.now() >= until) throw new CliError("timeout", `Timed out after ${Math.round(ms / 1e3)} s: the contact has not confirmed the edit yet. It goes by itself once the chat is live and the contact's app shows edits, while this profile is online`, {
@@ -2648,8 +2870,7 @@ async function waitForMessage(ctx, chat, messageId, target, ms) {
2648
2870
  if (error) reject(error);
2649
2871
  else resolve(message);
2650
2872
  };
2651
- const check = (last = false) => void node(ctx).getMessages(chat).then((messages) => {
2652
- const message = messages.find((m) => m.id === messageId);
2873
+ const check = (last = false) => void node(ctx).getMessage(chat, messageId).then((message) => {
2653
2874
  try {
2654
2875
  const done = look(message);
2655
2876
  if (done) finish(null, done);
@@ -2736,12 +2957,74 @@ function checked$1(card) {
2736
2957
  if ("error" in result) throw new CliError("bad_request", `Status card: ${result.error}`, { card });
2737
2958
  return result.card;
2738
2959
  }
2739
- /** The latest message of mine in this chat or group with a card of this kind and id: the one an update edits. */
2960
+ /** Every text in a card's fields (title, step, items, runs, links…), as the contact's app may show it. */
2961
+ function cardTexts(value, out = []) {
2962
+ if (typeof value === "string") out.push(value);
2963
+ else if (Array.isArray(value)) for (const item of value) cardTexts(item, out);
2964
+ else if (isObject(value)) for (const item of Object.values(value)) cardTexts(item, out);
2965
+ return out;
2966
+ }
2967
+ /**
2968
+ * The secret guard `send` has (docs/CLI.md § Safety): a card whose text looks like a seed, a private key or a Cashu
2969
+ * token goes only with `--force`. `fields` are what this command gives (an update's own, not the card it merges over).
2970
+ * A card's text is read as given and as the card carries it (`cardLine`): a line loses its invisible characters on the
2971
+ * way, so a key one of them broke in two is whole again on the card. The fallback text goes as it is, as `send`'s.
2972
+ */
2973
+ function guardSecrets(fields, params, verb) {
2974
+ if (bool(params, "force")) return;
2975
+ const check = (text) => {
2976
+ const secret = findSecret(text);
2977
+ if (secret) throw new CliError("confirm", `The card's text looks like ${secret.kind === "cashu" ? "a Cashu token (money anyone who reads it can take)" : "a secret (a seed or a private key)"}; ${verb} it with --force if you mean to`, { kind: secret.kind });
2978
+ };
2979
+ for (const text of cardTexts([fields, params.run])) {
2980
+ check(text);
2981
+ check(cardLine(text, Infinity) ?? "");
2982
+ }
2983
+ if (typeof params.text === "string") check(params.text);
2984
+ }
2985
+ /** Per card (its chat or group, its kind and its id): the message of mine that carries it, as this process last saw it. */
2986
+ var carriers = /* @__PURE__ */ new WeakMap();
2987
+ var carrierKey = (linkId, kind, id) => `${linkId}\n${kind}\n${id}`;
2988
+ function carriersOf(ctx) {
2989
+ let map = carriers.get(ctx);
2990
+ if (map) return map;
2991
+ carriers.set(ctx, map = /* @__PURE__ */ new Map());
2992
+ const cards = map;
2993
+ ctx.hub.onEvent((event) => {
2994
+ if (event.type !== "message.sent" && event.type !== "group.sent") return;
2995
+ const message = event.message;
2996
+ const card = message?.card;
2997
+ if (typeof message?.id !== "string" || typeof card?.id !== "string" || card.kind !== "task" && card.kind !== "routine" && card.kind !== "usage") return;
2998
+ carried(cards, carrierKey(event.type === "group.sent" ? `group:${String(event.group)}` : String(event.chat), card.kind, card.id), message.id);
2999
+ });
3000
+ return map;
3001
+ }
3002
+ function carried(cards, key, messageId) {
3003
+ cards.delete(key);
3004
+ cards.set(key, messageId);
3005
+ if (cards.size > 1e3) cards.delete(cards.keys().next().value);
3006
+ }
3007
+ /**
3008
+ * The latest message of mine in this chat or group with a card of this kind and id: the one an update edits. A card
3009
+ * this process sent or found before is read by its message's key, so its updates cost the same in a long chat as in a
3010
+ * short one; only a card not seen yet (the first update after a start, a one-shot) is looked for in the whole history.
3011
+ */
2740
3012
  async function cardMessage(ctx, target, kind, id) {
3013
+ const cards = carriersOf(ctx), key = carrierKey(target.linkId, kind, id);
3014
+ const carries = (message) => message?.sender === "me" && message.card?.kind === kind && message.card.id === id;
3015
+ const known = cards.get(key);
3016
+ if (known !== void 0) {
3017
+ const message = await node(ctx).getMessage(target.linkId, known);
3018
+ if (carries(message)) return message;
3019
+ cards.delete(key);
3020
+ }
2741
3021
  const messages = target.group ? await node(ctx).groupMessages({ groupId: target.id }) : await node(ctx).getMessages(target.linkId);
3022
+ if (cards.has(key)) return cardMessage(ctx, target, kind, id);
2742
3023
  for (let i = messages.length - 1; i >= 0; i--) {
2743
3024
  const message = messages[i];
2744
- if (message.sender === "me" && message.card?.kind === kind && message.card.id === id) return message;
3025
+ if (!carries(message)) continue;
3026
+ carried(cards, key, message.id);
3027
+ return message;
2745
3028
  }
2746
3029
  throw new CliError("not_found", `No ${kind} ${JSON.stringify(id)} of yours in this ${target.group ? "group" : "chat"}: send it first with ghostly ${kind} send`);
2747
3030
  }
@@ -2769,8 +3052,9 @@ async function parentThere(ctx, target, card) {
2769
3052
  async function sendCard(ctx, params, kind) {
2770
3053
  const target = chatOrGroup(ctx, str(params, "chat", true));
2771
3054
  const fields = fieldsOf(params);
3055
+ guardSecrets(fields, params, "send");
2772
3056
  const now = Date.now();
2773
- const id = typeof fields.id === "string" && fields.id ? fields.id : `${kind}-${toBase64Url$1(randomBytes$1(6))}`;
3057
+ const id = typeof fields.id === "string" && fields.id ? fields.id : `${kind}-${toBase64Url(randomBytes$1(6))}`;
2774
3058
  const card = checked$1(withRun({
2775
3059
  ...kind === "task" ? {
2776
3060
  status: "running",
@@ -2799,6 +3083,7 @@ async function sendCard(ctx, params, kind) {
2799
3083
  card
2800
3084
  });
2801
3085
  if (result.error || !result.messageId) throw new CliError(result.refused ? "refused" : "unavailable", result.error ?? "Nothing to send");
3086
+ carried(carriersOf(ctx), carrierKey(target.linkId, kind, id), result.messageId);
2802
3087
  const edges = wait === "sent" ? await waitForGroupFrame(ctx, target.id, result.messageId, void 0, ms) : void 0;
2803
3088
  return answer(target, kind, id, result.messageId, {
2804
3089
  card,
@@ -2812,7 +3097,8 @@ async function sendCard(ctx, params, kind) {
2812
3097
  });
2813
3098
  if (result.error) throw new CliError(result.refused ? "refused" : "unavailable", result.error);
2814
3099
  if (!result.messageId) throw new CliError("bad_request", "Nothing to send");
2815
- let message = (await node(ctx).getMessages(target.linkId)).find((m) => m.id === result.messageId);
3100
+ carried(carriersOf(ctx), carrierKey(target.linkId, kind, id), result.messageId);
3101
+ let message = await node(ctx).getMessage(target.linkId, result.messageId);
2816
3102
  if (wait !== "none") message = await waitForMessage(ctx, target.linkId, result.messageId, wait, ms);
2817
3103
  return answer(target, kind, id, result.messageId, {
2818
3104
  card,
@@ -2838,6 +3124,7 @@ async function updateCard(ctx, params, kind) {
2838
3124
  const target = chatOrGroup(ctx, str(params, "chat", true));
2839
3125
  const id = str(params, kind, true);
2840
3126
  const patch = fieldsOf(params);
3127
+ guardSecrets(patch, params, "update");
2841
3128
  const message = await cardMessage(ctx, target, kind, id);
2842
3129
  if ((message.edit?.seq ?? 0) >= STATUS_CARD_LIMITS.edits) throw new CliError("refused", `This ${kind}'s message took ${STATUS_CARD_LIMITS.edits} updates, the most one takes: start a new card with ghostly ${kind} send (a new --id, or the same one: the newest card of an id stands for it)`, {
2843
3130
  messageId: message.id,
@@ -2879,7 +3166,7 @@ async function updateCard(ctx, params, kind) {
2879
3166
  pace.last = Date.now();
2880
3167
  await editCard(ctx, target, message.id, card, text);
2881
3168
  }
2882
- let edited = target.group ? (await node(ctx).groupMessages({ groupId: target.id })).find((m) => m.id === message.id) : (await node(ctx).getMessages(target.linkId)).find((m) => m.id === message.id);
3169
+ let edited = await node(ctx).getMessage(target.linkId, message.id);
2883
3170
  if (wait === "confirmed" && edited?.edit?.pending) edited = await waitForEdit(ctx, target.linkId, message.id, ms);
2884
3171
  const edges = wait === "sent" && edited?.edit ? await waitForGroupFrame(ctx, target.id, message.id, edited.edit.seq, ms) : void 0;
2885
3172
  return answer(target, kind, id, message.id, {
@@ -3049,7 +3336,7 @@ function buttonsCard(params) {
3049
3336
  if (!Array.isArray(buttons)) throw new CliError("bad_request", "buttons must be a list of {id, label, style?, once?}");
3050
3337
  return checked({
3051
3338
  kind: "buttons",
3052
- id: str(params, "buttonsId") ?? `ask-${toBase64Url$1(randomBytes$1(6))}`,
3339
+ id: str(params, "buttonsId") ?? `ask-${toBase64Url(randomBytes$1(6))}`,
3053
3340
  buttons
3054
3341
  });
3055
3342
  }
@@ -3086,6 +3373,7 @@ async function updateButtons(ctx, params) {
3086
3373
  edits: message.edit.seq
3087
3374
  });
3088
3375
  const before = message.card;
3376
+ if (chosen !== void 0 && !before.buttons.some((b) => b.id === chosen)) throw new CliError("bad_request", `That message has no button ${JSON.stringify(chosen)}; its buttons: ${before.buttons.map((b) => b.id).join(", ")}`, { card: before });
3089
3377
  const card = checked({
3090
3378
  ...before,
3091
3379
  ...chosen !== void 0 && { chosen },
@@ -3104,7 +3392,7 @@ async function updateButtons(ctx, params) {
3104
3392
  card
3105
3393
  });
3106
3394
  if (result.error) throw new CliError(result.refused ? "refused" : "unavailable", result.error);
3107
- let edited = (target.group ? await node(ctx).groupMessages({ groupId: target.id }) : await node(ctx).getMessages(target.linkId)).find((m) => m.id === message.id);
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);
3108
3396
  if (wait === "confirmed" && edited?.edit?.pending) edited = await waitForEdit(ctx, target.linkId, message.id, ms);
3109
3397
  const edges = wait === "sent" && edited?.edit ? await waitForGroupFrame(ctx, target.id, message.id, edited.edit.seq, ms) : void 0;
3110
3398
  return {
@@ -3167,7 +3455,7 @@ async function pressButton(ctx, params) {
3167
3455
  edges
3168
3456
  };
3169
3457
  }
3170
- const reply = wait === "none" ? (await node(ctx).getMessages(target.linkId)).find((m) => m.id === replyId) : await waitForMessage(ctx, target.linkId, replyId, wait, ms);
3458
+ const reply = wait === "none" ? await node(ctx).getMessage(target.linkId, replyId) : await waitForMessage(ctx, target.linkId, replyId, wait, ms);
3171
3459
  return {
3172
3460
  ...answer,
3173
3461
  delivery: reply?.delivery ?? null
@@ -3483,10 +3771,15 @@ function groupJson(group, showSecret = false) {
3483
3771
  * Group administration and pictures (WISP 1100, phase 3): what the app's group menu and profile screen do. The
3484
3772
  * engine enforces who may do what (the admin); these check the arguments and name members.
3485
3773
  */
3486
- /** A member by key, unique key prefix or name; never this profile itself. */
3774
+ /**
3775
+ * A member by key, unique key prefix or name; never this profile itself. A whole key is that member, whatever another
3776
+ * member calls itself; a prefix and a name stand side by side (two members is an error, not a guess).
3777
+ */
3487
3778
  function findMember(group, ref) {
3488
3779
  const lower = ref.toLowerCase();
3489
- const matches = group.members.filter((m) => !m.me && (m.key === ref || m.key.startsWith(ref) || m.nick?.trim().toLowerCase() === lower));
3780
+ const others = group.members.filter((m) => !m.me);
3781
+ const byKey = others.filter((m) => m.key === ref);
3782
+ const matches = byKey.length ? byKey : others.filter((m) => m.key.startsWith(ref) || m.nick?.trim().toLowerCase() === lower);
3490
3783
  if (matches.length === 1) return matches[0];
3491
3784
  throw new CliError(matches.length ? "bad_request" : "not_found", matches.length ? `${JSON.stringify(ref)} names more than one member` : `No member ${JSON.stringify(ref)} in ${group.name}`);
3492
3785
  }
@@ -3603,6 +3896,63 @@ var GROUP_ADMIN_METHODS = {
3603
3896
  }
3604
3897
  };
3605
3898
  //#endregion
3899
+ //#region src/turnRefs.ts
3900
+ /**
3901
+ * The id an agent turn names its message by (docs/CLI.md § Agent turns). A message is stored under an id its sender
3902
+ * chose most of (`peer_<the id they sent>`), so a turn carries this one instead, made here from the chat (or group) and
3903
+ * that id: `t_` and 32 hex digits, in which nobody can write a word. Every command that takes a message's id takes it.
3904
+ */
3905
+ function turnRef(where, messageId) {
3906
+ return "t_" + createHash("sha256").update(`${where}\n${messageId}`).digest("hex").slice(0, 32);
3907
+ }
3908
+ var TURN_REF = /^t_[0-9a-f]{32}$/;
3909
+ var isRef = (value) => typeof value === "string" && TURN_REF.test(value);
3910
+ /**
3911
+ * `params` with each turn's id in `message`, `reply` and `messages` as the id its message is stored under: looked for
3912
+ * among the chat's (or group's) newest messages first, where a turn's almost always is, then in all of them. One that
3913
+ * names no message stays as given, and its command says so as for any unknown id.
3914
+ */
3915
+ async function withStoredIds(ctx, params) {
3916
+ const wanted = new Set([
3917
+ params.message,
3918
+ params.reply,
3919
+ ...Array.isArray(params.messages) ? params.messages : []
3920
+ ].filter(isRef));
3921
+ if (!wanted.size) return params;
3922
+ let target = null;
3923
+ try {
3924
+ if (typeof params.group === "string") {
3925
+ const group = findGroup(state(ctx).groups, params.group);
3926
+ target = {
3927
+ id: group.id,
3928
+ linkId: `group:${group.id}`,
3929
+ group: true
3930
+ };
3931
+ } else if (typeof params.chat === "string") target = chatOrGroup(ctx, params.chat);
3932
+ } catch {}
3933
+ if (!target) return params;
3934
+ const { id, linkId, group } = target;
3935
+ const found = /* @__PURE__ */ new Map();
3936
+ const look = (messages) => {
3937
+ for (const message of messages) {
3938
+ const ref = turnRef(id, message.id);
3939
+ if (wanted.has(ref)) found.set(ref, message.id);
3940
+ }
3941
+ return found.size === wanted.size;
3942
+ };
3943
+ if (!look((await node(ctx).messagePage({
3944
+ linkId,
3945
+ limit: 200
3946
+ })).messages)) look(group ? await node(ctx).groupMessages({ groupId: id }) : await node(ctx).getMessages(linkId));
3947
+ const stored = (value) => isRef(value) ? found.get(value) ?? value : value;
3948
+ return {
3949
+ ...params,
3950
+ ...params.message !== void 0 && { message: stored(params.message) },
3951
+ ...params.reply !== void 0 && { reply: stored(params.reply) },
3952
+ ...Array.isArray(params.messages) && { messages: params.messages.map(stored) }
3953
+ };
3954
+ }
3955
+ //#endregion
3606
3956
  //#region ../browser/src/proofs/bitcoinWallets.ts
3607
3957
  /** What a proof proves and what it does not; shown next to every Bitcoin proof, given or received. */
3608
3958
  var BITCOIN_PROOF_LIMITS = [
@@ -4135,7 +4485,7 @@ var ResolverUnavailable = class extends DomainCheckError {};
4135
4485
  async function askResolver(resolver, name, type, options) {
4136
4486
  let response;
4137
4487
  try {
4138
- response = await options.fetch(`${resolver.url}?dns=${toBase64Url$1(encodeDnsQuery(name, type))}`, {
4488
+ response = await options.fetch(`${resolver.url}?dns=${toBase64Url(encodeDnsQuery(name, type))}`, {
4139
4489
  headers: { accept: "application/dns-message" },
4140
4490
  maxBytes: DNS_MAX_BYTES,
4141
4491
  signal: options.signal,
@@ -4845,8 +5195,8 @@ async function preparePgpEvidence(input, options = {}) {
4845
5195
  return {
4846
5196
  evidence: {
4847
5197
  scheme: PGP_SCHEME,
4848
- key: toBase64Url$1(await minimalKey(pgp, key, signer, at, config(pgp))),
4849
- signature: toBase64Url$1(signature)
5198
+ key: toBase64Url(await minimalKey(pgp, key, signer, at, config(pgp))),
5199
+ signature: toBase64Url(signature)
4850
5200
  },
4851
5201
  verified: info
4852
5202
  };
@@ -4858,7 +5208,7 @@ function parsePgpEvidence(raw) {
4858
5208
  if (!e || typeof e !== "object" || Array.isArray(e) || e.scheme !== "openpgp/1" || Object.keys(e).sort().join(",") !== "key,scheme,signature" || typeof e.key !== "string" || typeof e.signature !== "string" || !b64.test(e.key) || !b64.test(e.signature) || e.key.length > Math.ceil(PGP_LIMITS.sharedKey * 4 / 3) || e.signature.length > Math.ceil(PGP_LIMITS.sharedSignature * 4 / 3)) throw new PgpProofError("Invalid OpenPGP proof");
4859
5209
  let canonical;
4860
5210
  try {
4861
- canonical = toBase64Url$1(fromBase64Url$1(e.key)) === e.key && toBase64Url$1(fromBase64Url$1(e.signature)) === e.signature;
5211
+ canonical = toBase64Url(fromBase64Url$1(e.key)) === e.key && toBase64Url(fromBase64Url$1(e.signature)) === e.signature;
4862
5212
  } catch {
4863
5213
  canonical = false;
4864
5214
  }
@@ -5580,7 +5930,7 @@ function segment(value) {
5580
5930
  } catch {
5581
5931
  return refuse("Malformed ID token");
5582
5932
  }
5583
- if (toBase64Url$1(bytes) !== value) refuse("Malformed ID token");
5933
+ if (toBase64Url(bytes) !== value) refuse("Malformed ID token");
5584
5934
  return bytes;
5585
5935
  }
5586
5936
  function json(value) {
@@ -5728,7 +6078,7 @@ async function authorizationRequest(options) {
5728
6078
  if (!clientId) throw new Error(`${provider.name} sign-in is not set up in this Ghostly build yet.`);
5729
6079
  const { nonce } = options;
5730
6080
  if (!/^[A-Za-z0-9_-]{16,128}$/.test(nonce)) throw new Error("Invalid sign-in nonce");
5731
- const state = `${options.statePrefix ?? ""}${toBase64Url$1(randomBytes$1(32))}`;
6081
+ const state = `${options.statePrefix ?? ""}${toBase64Url(randomBytes$1(32))}`;
5732
6082
  const url = new URL(provider.authorizationEndpoint);
5733
6083
  const { scope, claims } = scopeFor$1(provider, options.reveal);
5734
6084
  const params = {
@@ -5743,8 +6093,8 @@ async function authorizationRequest(options) {
5743
6093
  };
5744
6094
  let verifier;
5745
6095
  if (provider.responseType === "code") {
5746
- verifier = toBase64Url$1(randomBytes$1(32));
5747
- params.code_challenge = toBase64Url$1(new Uint8Array(await crypto.subtle.digest("SHA-256", utf8Encode(verifier))));
6096
+ verifier = toBase64Url(randomBytes$1(32));
6097
+ params.code_challenge = toBase64Url(new Uint8Array(await crypto.subtle.digest("SHA-256", utf8Encode(verifier))));
5748
6098
  params.code_challenge_method = "S256";
5749
6099
  }
5750
6100
  for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
@@ -6319,7 +6669,7 @@ var signer = {
6319
6669
  return {
6320
6670
  method: "sig",
6321
6671
  vm: check.key.id,
6322
- sig: toBase64Url$1(check.signature)
6672
+ sig: toBase64Url(check.signature)
6323
6673
  };
6324
6674
  }
6325
6675
  };
@@ -7571,7 +7921,7 @@ var exactKeys = (raw, keys) => !!raw && typeof raw === "object" && !Array.isArra
7571
7921
  /** The key behind the fake in-app signer. Tests may replace it. */
7572
7922
  var fakeKeyring = { seed: ed25519.utils.randomSecretKey() };
7573
7923
  var fakeKeySubject = (seed = fakeKeyring.seed) => hex$3(ed25519.getPublicKey(seed));
7574
- var fakeKeySign = (statement, seed = fakeKeyring.seed) => ({ sig: toBase64Url$1(ed25519.sign(statement.bytes, seed)) });
7924
+ var fakeKeySign = (statement, seed = fakeKeyring.seed) => ({ sig: toBase64Url(ed25519.sign(statement.bytes, seed)) });
7575
7925
  var fakeKey = {
7576
7926
  id: "fake-key",
7577
7927
  label: "Test key",
@@ -7654,13 +8004,13 @@ var ISSUER_SEED = unhex("7a".repeat(32));
7654
8004
  var FAKE_ISSUER = "https://issuer.ghostly.test";
7655
8005
  /** What the fake login page signs: iss, sub, nonce (= statement id), iat. */
7656
8006
  function fakeAccountToken(statement, account = "alice", seed = ISSUER_SEED, iat = statement.binding.issuedAt) {
7657
- const payload = toBase64Url$1(utf8Encode(JSON.stringify({
8007
+ const payload = toBase64Url(utf8Encode(JSON.stringify({
7658
8008
  iss: FAKE_ISSUER,
7659
8009
  sub: account,
7660
8010
  nonce: statement.id,
7661
8011
  iat
7662
8012
  })));
7663
- return { token: `${payload}.${toBase64Url$1(ed25519.sign(utf8Encode(payload), seed))}` };
8013
+ return { token: `${payload}.${toBase64Url(ed25519.sign(utf8Encode(payload), seed))}` };
7664
8014
  }
7665
8015
  var fakeAccount = {
7666
8016
  id: "fake-account",
@@ -8623,12 +8973,6 @@ var SERVICE_METHODS = {
8623
8973
  };
8624
8974
  //#endregion
8625
8975
  //#region ../browser/src/backup/codec.ts
8626
- /** Base64url without padding, in chunks: bundles can be many megabytes. */
8627
- function toBase64Url(bytes) {
8628
- let binary = "";
8629
- for (let i = 0; i < bytes.length; i += 32768) binary += String.fromCharCode(...bytes.subarray(i, i + 32768));
8630
- return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
8631
- }
8632
8976
  function fromBase64Url(text) {
8633
8977
  if (!/^[A-Za-z0-9_-]*$/.test(text)) throw new Error("Invalid base64url");
8634
8978
  const binary = atob(text.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat((4 - text.length % 4) % 4));
@@ -12324,7 +12668,7 @@ var METHODS = {
12324
12668
  async "settings.set"(ctx, params) {
12325
12669
  const patch = {};
12326
12670
  for (const [key, value] of Object.entries(params)) {
12327
- const kind = SETTABLE[key];
12671
+ const kind = Object.hasOwn(SETTABLE, key) ? SETTABLE[key] : void 0;
12328
12672
  if (!kind) throw new CliError("bad_request", `${key} is not a setting the CLI changes (${Object.keys(SETTABLE).join(", ")})`);
12329
12673
  if (kind === "strings" && !(Array.isArray(value) && value.every((v) => typeof v === "string"))) throw new CliError("bad_request", `${key} must be a list of strings`);
12330
12674
  if (kind === "string" && typeof value !== "string") throw new CliError("bad_request", `${key} must be a string`);
@@ -12343,14 +12687,19 @@ var METHODS = {
12343
12687
  label
12344
12688
  });
12345
12689
  node(ctx).setActiveLink({ linkId });
12346
- await waitForState(ctx, (s) => {
12347
- const stage = s.links.find((l) => l.id === linkId)?.pairingProgress?.stage;
12348
- return stage && stage !== "publishing" ? true : void 0;
12349
- }, 2e4, "the invite to be published").catch(() => {});
12690
+ const progressOf = (s) => s.links.find((l) => l.id === linkId)?.pairingProgress;
12691
+ const progress = await waitForState(ctx, (s) => {
12692
+ const p = progressOf(s);
12693
+ return p && (p.stage !== "publishing" || ctx.mode === "daemon" && p.relayWaitUntil) ? p : void 0;
12694
+ }, 2e4, "the invite to be published").catch(() => progressOf(state(ctx)));
12695
+ const published = !!progress && progress.stage !== "publishing" && progress.reason !== "publish";
12696
+ const waitUntil = published ? void 0 : progress?.relayWaitUntil;
12350
12697
  return {
12351
12698
  chat: linkId,
12352
12699
  invite: inviteCode,
12353
- link: inviteLink(inviteCode)
12700
+ link: inviteLink(inviteCode),
12701
+ published,
12702
+ ...waitUntil !== void 0 && { retryInMs: Math.max(0, waitUntil - Date.now()) }
12354
12703
  };
12355
12704
  },
12356
12705
  async "invite.join"(ctx, params) {
@@ -12412,7 +12761,7 @@ var METHODS = {
12412
12761
  });
12413
12762
  if (result.error) throw new CliError(result.refused ? "refused" : "unavailable", result.error);
12414
12763
  if (!result.messageId) throw new CliError("bad_request", "Nothing to send");
12415
- let message = (await node(ctx).getMessages(link.id)).find((m) => m.id === result.messageId);
12764
+ let message = await node(ctx).getMessage(link.id, result.messageId);
12416
12765
  if (wait !== "none") message = await waitForMessage(ctx, link.id, result.messageId, wait, num(params, "timeout", 30, {
12417
12766
  min: 1,
12418
12767
  max: 3600
@@ -12448,7 +12797,7 @@ var METHODS = {
12448
12797
  });
12449
12798
  if (result.error) throw editRefused(await node(ctx).getMessages(link.id), messageId, "chat", result.error, result.refused);
12450
12799
  const id = result.messageId ?? messageId;
12451
- let message = (await node(ctx).getMessages(link.id)).find((m) => m.id === id);
12800
+ let message = await node(ctx).getMessage(link.id, id);
12452
12801
  if (wait === "confirmed" && message?.edit?.pending) message = await waitForEdit(ctx, link.id, id, num(params, "timeout", 30, {
12453
12802
  min: 1,
12454
12803
  max: 3600
@@ -12641,7 +12990,8 @@ var METHODS = {
12641
12990
  },
12642
12991
  async "chat.transport"(ctx, params) {
12643
12992
  const link = chatOf(ctx, params);
12644
- const transport = TRANSPORT_NAMES[str(params, "transport", true)];
12993
+ const name = str(params, "transport", true);
12994
+ const transport = Object.hasOwn(TRANSPORT_NAMES, name) ? TRANSPORT_NAMES[name] : void 0;
12645
12995
  if (!transport) throw new CliError("bad_request", `transport must be one of auto, dht, webrtc, iroh, hyperdht`);
12646
12996
  await node(ctx).setChatTransport({
12647
12997
  linkId: link.id,
@@ -13024,12 +13374,12 @@ function history(messages, params, json = messageJson) {
13024
13374
  };
13025
13375
  }
13026
13376
  Object.keys(METHODS);
13027
- /** Runs one method of the API. */
13377
+ /** Runs one method of the API: one the table lists itself (`constructor`, which every object has, is none). */
13028
13378
  async function callApi(ctx, method, params = {}) {
13029
- const run = METHODS[method];
13379
+ const run = Object.hasOwn(METHODS, method) ? METHODS[method] : void 0;
13030
13380
  if (!run) throw new CliError("not_found", `No method ${method}`);
13031
13381
  if (params === null || typeof params !== "object" || Array.isArray(params)) throw new CliError("bad_request", "params must be an object");
13032
- return run(ctx, params);
13382
+ return run(ctx, await withStoredIds(ctx, params));
13033
13383
  }
13034
13384
  //#endregion
13035
13385
  //#region src/privateFolder.ts
@@ -13149,6 +13499,229 @@ function connectDaemon(path, timeoutMs = 2e3) {
13149
13499
  });
13150
13500
  }
13151
13501
  //#endregion
13502
+ //#region src/appTemplate.ts
13503
+ var html = (text) => text.replace(/[&<>"]/g, (c) => ({
13504
+ "&": "&amp;",
13505
+ "<": "&lt;",
13506
+ ">": "&gt;",
13507
+ "\"": "&quot;"
13508
+ })[c]);
13509
+ /** `ghostly-app.json`: the manifest without `publisher`, `sequence` and `files`, which `app publish` writes. */
13510
+ function templateManifest({ name, title }) {
13511
+ return {
13512
+ name,
13513
+ version: "0.1.0",
13514
+ kind: "mini-app",
13515
+ title,
13516
+ tagline: "Say hello to a contact",
13517
+ description: "Open it in a chat and your contact gets a card to join. Each side can then say hello to the other.",
13518
+ entry: "index.html",
13519
+ permissions: ["chat"],
13520
+ view: "chat",
13521
+ runtime: {
13522
+ host: ">=1.2",
13523
+ clients: ["web", "desktop"]
13524
+ },
13525
+ license: "MIT"
13526
+ };
13527
+ }
13528
+ /**
13529
+ * `index.html`: the whole app in one file, as the sandbox needs it (no network, so nothing is loaded from elsewhere).
13530
+ * Its script is plain JavaScript typed with JSDoc against `@ghostlytools/sdk/app`, so it runs as written and still type
13531
+ * checks (test/appInit.test.ts does). The title is the only thing put into it, as text. Its type imports are in single
13532
+ * quotes: they are text of the page, not imports of the CLI's own build (test/packageDeps.test.ts reads those).
13533
+ */
13534
+ function templatePage({ title }) {
13535
+ return `<!doctype html>
13536
+ <html lang="en">
13537
+ <head>
13538
+ <meta charset="utf-8">
13539
+ <meta name="viewport" content="width=device-width, initial-scale=1">
13540
+ <title>${html(title)}</title>
13541
+ <style>
13542
+ :root { color-scheme: light dark; --bg: #ffffff; --fg: #16181d; --muted: #5b6270; --line: #d9dde3; --accent: #2f6fed; }
13543
+ :root[data-theme="dark"] { --bg: #16181d; --fg: #f2f4f7; --muted: #a3abb8; --line: #343a45; --accent: #7aa5ff; }
13544
+ body { margin: 0; padding: 20px; background: var(--bg); color: var(--fg); font: 16px/1.4 system-ui, sans-serif; }
13545
+ h1 { margin: 0 0 4px; font-size: 20px; }
13546
+ #status { margin: 0 0 16px; color: var(--muted); }
13547
+ button { font: inherit; padding: 8px 16px; border: 1px solid var(--accent); border-radius: 8px; background: var(--accent); color: var(--bg); }
13548
+ button:disabled { opacity: 0.5; }
13549
+ ul { margin: 16px 0 0; padding: 0; list-style: none; }
13550
+ li { padding: 8px 0; border-top: 1px solid var(--line); }
13551
+ </style>
13552
+ </head>
13553
+ <body>
13554
+ <h1>${html(title)}</h1>
13555
+ <p id="status" role="status">Starting</p>
13556
+ <button id="hello" type="button" disabled>Say hello</button>
13557
+ <ul id="log" aria-label="Messages"></ul>
13558
+ <script type="module">
13559
+ // The types of the API come from the SDK (npm install --save-dev @ghostlytools/sdk); nothing of it runs here.
13560
+ /** @typedef {import('@ghostlytools/sdk/app').MiniAppApi} MiniAppApi */
13561
+ /** @typedef {import('@ghostlytools/sdk/app').MiniAppJson} MiniAppJson */
13562
+
13563
+ /** The longest text shown from a contact's frame. */
13564
+ const MAX_TEXT = 200;
13565
+
13566
+ const status = /** @type {HTMLElement} */ (document.getElementById("status"));
13567
+ const button = /** @type {HTMLButtonElement} */ (document.getElementById("hello"));
13568
+ const log = /** @type {HTMLElement} */ (document.getElementById("log"));
13569
+
13570
+ /** @param {string} text */
13571
+ function show(text) {
13572
+ const line = document.createElement("li");
13573
+ line.textContent = text;
13574
+ log.append(line);
13575
+ }
13576
+
13577
+ /**
13578
+ * What the contact's app sent, when it is a hello. A frame comes from another device: read only what this app sends,
13579
+ * and never put it in the page as HTML.
13580
+ * @param {MiniAppJson} data
13581
+ * @returns {string | null}
13582
+ */
13583
+ function helloOf(data) {
13584
+ if (!data || typeof data !== "object" || Array.isArray(data) || data.type !== "hello") return null;
13585
+ return typeof data.text === "string" && data.text.length <= MAX_TEXT ? data.text : null;
13586
+ }
13587
+
13588
+ /**
13589
+ * Why a send was refused, in words. The code is the rejection's message (MINI_APP_ERROR_CODES in the SDK); a later
13590
+ * client may add one.
13591
+ * @param {unknown} error
13592
+ */
13593
+ function refusal(error) {
13594
+ switch (error instanceof Error ? error.message : "") {
13595
+ case "offline": return "Not sent: you are not both online";
13596
+ case "peer-closed": case "not-open": return "Not sent: your contact has not opened this app";
13597
+ case "not-allowed": return "Open this app in a chat to say hello";
13598
+ default: return "Not sent";
13599
+ }
13600
+ }
13601
+
13602
+ /** @param {MiniAppApi} ghostly */
13603
+ async function start(ghostly) {
13604
+ // Listen before anything is awaited, so no frame is missed.
13605
+ ghostly.chat.on("message", (data) => {
13606
+ const text = helloOf(data);
13607
+ if (text !== null) show("Your contact: " + text);
13608
+ });
13609
+ ghostly.chat.on("peer", (peer) => {
13610
+ status.textContent = peer.open ? "Your contact has this app open" : "Waiting for your contact to open this app";
13611
+ });
13612
+
13613
+ const context = await ghostly.context();
13614
+ const dark = context.theme ? context.theme === "dark" : matchMedia("(prefers-color-scheme: dark)").matches;
13615
+ document.documentElement.dataset.theme = dark ? "dark" : "light";
13616
+ status.textContent = !context.inChat ? "Open this app in a chat to say hello"
13617
+ : context.peer ? "Your contact has this app open" : "Waiting for your contact to open this app";
13618
+
13619
+ button.disabled = false;
13620
+ button.addEventListener("click", async () => {
13621
+ try {
13622
+ await ghostly.chat.send({ type: "hello", text: "Hello!" });
13623
+ show("You: Hello!");
13624
+ } catch (error) {
13625
+ show(refusal(error));
13626
+ }
13627
+ });
13628
+ }
13629
+
13630
+ /** Ghostly's runner gives the app \`window.ghostly\`; a browser tab has none. */
13631
+ const ghostly = /** @type {{ ghostly?: MiniAppApi }} */ (window).ghostly;
13632
+ if (ghostly) void start(ghostly);
13633
+ else status.textContent = "This page is a Ghostly app: publish it and open it in Ghostly";
13634
+ <\/script>
13635
+ </body>
13636
+ </html>
13637
+ `;
13638
+ }
13639
+ /** `README.md`: what the folder holds and the way from it to a store. */
13640
+ function templateReadme({ name, title }) {
13641
+ return `# ${title}
13642
+
13643
+ A [Ghostly](https://github.com/MiguelMedeiros/ghostly) app, started with \`ghostly app init\`. Opened in a 1:1 chat, it
13644
+ gives your contact a card to join, and each side can say hello to the other.
13645
+
13646
+ A Ghostly app ([WISP 1200](https://github.com/MiguelMedeiros/ghostly/blob/dev/docs/wisps/1200-marketplace.md)) is a
13647
+ page that runs in the client's sandbox. It has no network, and talks to Ghostly only through \`window.ghostly\`:
13648
+ frames of at most 32 KiB to the same app on the contact's side (\`chat.send\`, \`chat.on\`), storage per app and per
13649
+ chat, and the files of its own bundle. Apps run in Ghostly from release 1.2.
13650
+
13651
+ | File | What it is |
13652
+ |---|---|
13653
+ | \`ghostly-app.json\` | The manifest, without \`publisher\`, \`sequence\` and \`files\`: \`ghostly app publish\` writes those |
13654
+ | \`index.html\` | The app, whole: styles and script inline |
13655
+ | \`README.md\` | This file |
13656
+
13657
+ ## Write it
13658
+
13659
+ \`index.html\` is plain JavaScript, typed with JSDoc against \`@ghostlytools/sdk/app\`: \`MiniAppApi\` is
13660
+ \`window.ghostly\`, and the same module has the limits (\`MINI_APP_LIMITS\`) and the codes a refused call rejects with
13661
+ (\`MINI_APP_ERROR_CODES\`). For the types in your editor:
13662
+
13663
+ \`\`\`sh
13664
+ npm install --save-dev @ghostlytools/sdk
13665
+ \`\`\`
13666
+
13667
+ - What a contact's app sends is untrusted: check its shape, bound its size, and show it as text, never as HTML.
13668
+ - \`"view": "chat"\` runs the app inside a chat with one contact. \`"full"\` is a full-screen app opened from the Apps page.
13669
+ - \`permissions\` lists what the person is asked for: \`chat\` (frames to the contact's app), \`name\` (the person's
13670
+ display name) and \`internet\`. Adding one later makes every install ask before it updates.
13671
+
13672
+ ## Build
13673
+
13674
+ There is nothing to build yet: the page is the app. Once it grows into several source files, or TypeScript, build it
13675
+ into one self-contained \`index.html\` (scripts, styles and images inline) and put that file and \`ghostly-app.json\`
13676
+ alone in a folder of their own, which is the folder you publish.
13677
+ [ghostly-chess](https://github.com/MiguelMedeiros/ghostly-chess) does it with Vite and a small
13678
+ \`scripts/stage.mjs\`. \`ghostly app publish\` takes every file of the folder it is given but dot files, this README
13679
+ included, so do not run it on a repository with sources, \`node_modules\` or anything private in it.
13680
+
13681
+ ## Publish
13682
+
13683
+ \`\`\`sh
13684
+ ghostly app publish . --key ~/ghostly-keys/${name}-publisher.key
13685
+ ghostly app verify app.ghostlyapp
13686
+ \`\`\`
13687
+
13688
+ \`publish\` signs the folder into \`app.ghostlyapp\`. The first run makes the publisher key at the path you name:
13689
+
13690
+ - Keep it **outside this folder** and its repository (\`publish\` refuses a key inside it), and never commit it.
13691
+ - Back it up. An app is updated only with the key that signed it; a lost key means a new app.
13692
+
13693
+ \`verify\` checks the bundle as Ghostly will and prints its \`ref\` (\`<publisher key>/${name}\`), \`sequence\` and
13694
+ \`digest\`. For the next version, raise \`version\` in \`ghostly-app.json\` and publish again: the sequence goes up
13695
+ by one by itself.
13696
+
13697
+ ## Try it
13698
+
13699
+ Commit \`app.ghostlyapp\` to a public GitHub repository, then in Ghostly open Apps, choose Add, and paste its URL
13700
+ (\`https://raw.githubusercontent.com/<owner>/<repo>/HEAD/app.ghostlyapp\`). Set \`sources\` in \`ghostly-app.json\`
13701
+ to that URL, in a list, so installs made from it find your updates. Open the app from a chat's + menu; your contact
13702
+ gets a card to install and open it.
13703
+
13704
+ ## List it in a store
13705
+
13706
+ A store is a signed list of apps. The official one is a Git repository,
13707
+ [ghostly-store](https://github.com/MiguelMedeiros/ghostly-store), and apps enter it by pull request:
13708
+
13709
+ 1. Commit \`app.ghostlyapp\` to your public repository and note that commit.
13710
+ 2. Add \`apps/${name}.<first 16 characters of your publisher key>/listing.json\` with the \`ref\`, \`sequence\`
13711
+ and \`digest\` that \`ghostly app verify\` printed. Its \`urls\` start with a jsDelivr URL pinned to the full commit
13712
+ (\`https://cdn.jsdelivr.net/gh/<owner>/<repo>@<40-character commit>/app.ghostlyapp\`).
13713
+ 3. Open the pull request. Its check verifies the bundle; a maintainer reviews the app and the owner signs the index.
13714
+
13715
+ The store's [CONTRIBUTING](https://github.com/MiguelMedeiros/ghostly-store/blob/main/CONTRIBUTING.md) has the rules.
13716
+ An app installed from a store updates only to the version that store lists, so each new version is a pull request too.
13717
+
13718
+ ## If something goes wrong
13719
+
13720
+ If a version is harmful, \`ghostly app revoke . --key <your key> --up-to <sequence>\` writes \`ghostly-revoke.json\`;
13721
+ publish it beside \`app.ghostlyapp\` and Ghostly stops those versions.
13722
+ `;
13723
+ }
13724
+ //#endregion
13152
13725
  //#region src/apps.ts
13153
13726
  var APP_SOURCE = "ghostly-app.json";
13154
13727
  var APP_REVOKE_FILE = "ghostly-revoke.json";
@@ -13316,7 +13889,7 @@ function described(bundle, bytes) {
13316
13889
  sequence: m.sequence,
13317
13890
  digest: bundle.digest,
13318
13891
  bytes: bytes.length,
13319
- sha256: toBase64Url$1(createHash("sha256").update(bytes).digest())
13892
+ sha256: toBase64Url(createHash("sha256").update(bytes).digest())
13320
13893
  };
13321
13894
  }
13322
13895
  /** `app verify <bundle|url>`: every check a client makes before it stores a bundle (WISP 1200 · Reading a bundle). */
@@ -13330,6 +13903,70 @@ async function verifyApp(source) {
13330
13903
  manifest: read.bundle.manifest
13331
13904
  };
13332
13905
  }
13906
+ /** A folder's name as an app's: lower case, anything else a hyphen (`My App` is `my-app`). */
13907
+ var nameFrom = (folder) => folder.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
13908
+ /** An app's name as a title: `my-app` is `My app`. */
13909
+ var titleFrom = (name) => (name[0].toUpperCase() + name.slice(1)).replace(/-/g, " ");
13910
+ /**
13911
+ * `app init <dir> [--name <name>] [--title <title>] [--force]`: a small working app in `<dir>` (made when missing),
13912
+ * which `app publish <dir>` signs as it is: `ghostly-app.json`, an `index.html` that says hello to the contact, and a
13913
+ * README with the way to a store (./appTemplate.ts). The name is `--name`, else the folder's; the title `--title`, else
13914
+ * the name's. Nothing is written when one of the three files is there, unless `--force`, which replaces those three only.
13915
+ */
13916
+ async function initApp(dirArg, what = {}) {
13917
+ const usage = "ghostly app init <dir> [--name <name>] [--title <title>] [--force]";
13918
+ const dir = resolve(dirArg);
13919
+ const name = what.name ?? nameFrom(basename(dir));
13920
+ const title = what.title ?? (name ? titleFrom(name) : "");
13921
+ const page = templatePage({
13922
+ name,
13923
+ title
13924
+ });
13925
+ const pageBytes = new TextEncoder().encode(page);
13926
+ const checked = checkAppManifest({
13927
+ ...templateManifest({
13928
+ name,
13929
+ title
13930
+ }),
13931
+ ghostlyApp: 1,
13932
+ publisher: toZ32(/* @__PURE__ */ new Uint8Array(32)),
13933
+ sequence: 1,
13934
+ files: [{
13935
+ path: "index.html",
13936
+ size: pageBytes.length,
13937
+ sha256: toBase64Url(createHash("sha256").update(pageBytes).digest())
13938
+ }]
13939
+ }, { strict: true });
13940
+ if (!checked.ok) {
13941
+ if (checked.detail === "name is not valid") throw new CliError("usage", what.name === void 0 ? `The folder's name gives no app name (${JSON.stringify(basename(dir))}): give --name, a lowercase letter then up to 31 lowercase letters, digits or hyphens: ${usage}` : `--name is a lowercase letter then up to 31 lowercase letters, digits or hyphens, not ${JSON.stringify(what.name)}`);
13942
+ if (checked.detail === "title is not valid") throw new CliError("usage", `--title is one line of 1 to ${APP_BUNDLE_LIMITS.title} characters, not ${JSON.stringify(title)}`);
13943
+ throw refused("The template makes no valid manifest", checked);
13944
+ }
13945
+ if (existsSync(dir) && !statSync(dir).isDirectory()) throw new CliError("bad_request", `${dir} is a file: name a folder for the app`);
13946
+ const files = [
13947
+ [APP_SOURCE, `${JSON.stringify(templateManifest({
13948
+ name,
13949
+ title
13950
+ }), null, 2)}\n`],
13951
+ ["index.html", page],
13952
+ ["README.md", templateReadme({
13953
+ name,
13954
+ title
13955
+ })]
13956
+ ];
13957
+ const held = files.map(([path]) => path).filter((path) => existsSync(join(dir, path)));
13958
+ if (held.length && !what.force) throw refused(`${dir} already holds ${held.join(", ")}: nothing was written (--force replaces ${held.length > 1 ? "them" : "it"})`, { reason: "exists" }, { files: held });
13959
+ mkdirSync(dir, { recursive: true });
13960
+ for (const [path, text] of files) writeWhole(join(dir, path), new TextEncoder().encode(text));
13961
+ return {
13962
+ dir,
13963
+ name,
13964
+ title,
13965
+ files: files.map(([path]) => path),
13966
+ ...held.length ? { replaced: held } : {},
13967
+ next: `ghostly app publish ${dirArg} --key <a key file outside ${dirArg}>`
13968
+ };
13969
+ }
13333
13970
  /** Every file of the folder, as the bundle names it; dot files and folders are left out and listed. */
13334
13971
  function collectFiles(dir, leaveOut) {
13335
13972
  const files = [];
@@ -13368,11 +14005,22 @@ function collectFiles(dir, leaveOut) {
13368
14005
  }
13369
14006
  };
13370
14007
  walk(dir);
14008
+ files.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
13371
14009
  return {
13372
14010
  files,
13373
14011
  skipped
13374
14012
  };
13375
14013
  }
14014
+ /**
14015
+ * A private key in a file: a PEM private key block with its body (PKCS #8, OpenSSH, RSA, EC, encrypted), or a Ghostly
14016
+ * key file's first line. A library that only names the block's header in its code does not match.
14017
+ */
14018
+ var PRIVATE_KEY = /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----\r?\n[A-Za-z0-9+/=\r\n:,-]{40,}-----END [A-Z0-9 ]*PRIVATE KEY-----|^Ghostly (publisher|store) key \(WISP 1200\)/;
14019
+ /** Whether `path` is `dir` or anything under it. */
14020
+ var isInside = (dir, path) => {
14021
+ const rel = relative(dir, path);
14022
+ return !rel.startsWith("..") && !isAbsolute(rel);
14023
+ };
13376
14024
  /** Fields of `ghostly-app.json` the command writes itself. */
13377
14025
  var WRITTEN = ["publisher", "files"];
13378
14026
  function readSource(path) {
@@ -13412,6 +14060,7 @@ async function publishApp(dirArg, keyArg, outArg, sequenceArg) {
13412
14060
  const dir = resolve(dirArg);
13413
14061
  if (!statSync(dir).isDirectory()) throw new CliError("bad_request", `${dir} is not a folder`);
13414
14062
  const keyPath = resolve(keyArg);
14063
+ if (isInside(dir, keyPath)) throw refused(`${keyPath} is inside ${dir}, which is published: keep the publisher key outside the app's folder (and its repository)`, { reason: "private-key" });
13415
14064
  const out = resolve(outArg ?? join(dir, "app.ghostlyapp"));
13416
14065
  const sourcePath = join(dir, APP_SOURCE);
13417
14066
  const draft = readSource(sourcePath);
@@ -13438,6 +14087,11 @@ async function publishApp(dirArg, keyArg, outArg, sequenceArg) {
13438
14087
  path: f.path,
13439
14088
  bytes: new Uint8Array(readFileSync(f.full))
13440
14089
  }));
14090
+ const keyFile = contents.find((f) => PRIVATE_KEY.test(Buffer.from(f.bytes).toString("latin1")));
14091
+ if (keyFile) throw refused(`${keyFile.path} in ${dir} holds a private key, and a bundle is public: move it out of the app's folder`, {
14092
+ reason: "private-key",
14093
+ detail: keyFile.path
14094
+ });
13441
14095
  const manifest = {
13442
14096
  ...draft,
13443
14097
  ghostlyApp: 1,
@@ -13446,10 +14100,10 @@ async function publishApp(dirArg, keyArg, outArg, sequenceArg) {
13446
14100
  files: contents.map((f) => ({
13447
14101
  path: f.path,
13448
14102
  size: f.bytes.length,
13449
- sha256: toBase64Url$1(createHash("sha256").update(f.bytes).digest())
14103
+ sha256: toBase64Url(createHash("sha256").update(f.bytes).digest())
13450
14104
  }))
13451
14105
  };
13452
- const checked = checkAppManifest(JSON.parse(JSON.stringify(manifest)));
14106
+ const checked = checkAppManifest(JSON.parse(JSON.stringify(manifest)), { strict: true });
13453
14107
  if (!checked.ok) throw refused(`${sourcePath} and the folder's files make no valid manifest`, checked);
13454
14108
  const { bytes: manifestBytes, signature } = await signAppObject(APP_PREFIXES.app, checked.manifest, key.signer);
13455
14109
  const bundle = assembleAppBundle(manifestBytes, canonicalJsonBytes(signature), contents.map((f) => f.bytes));
@@ -13564,7 +14218,7 @@ async function signStore(indexArg, keyArg, outArg, now = Math.floor(Date.now() /
13564
14218
  else key = makeKey(keyPath, "store");
13565
14219
  if (index.key !== void 0 && index.key !== key.key) throw refused(`${indexPath} names another store key than ${keyPath}'s (${appFingerprint(key.key)})`, { reason: "store-key" });
13566
14220
  index.key = key.key;
13567
- const checked = checkAppStoreIndex(index);
14221
+ const checked = checkAppStoreIndex(index, { strict: true });
13568
14222
  if (!checked.ok) throw refused(`${indexPath} is not a valid store index`, checked);
13569
14223
  const { indexBytes, sigBytes } = await signAppStore(index, key.signer);
13570
14224
  const read = readAppStore(indexBytes, sigBytes, now, key.key);
@@ -13627,6 +14281,31 @@ var key = {
13627
14281
  * per command, in alphabetical order (test/commands.test.ts checks).
13628
14282
  */
13629
14283
  var commands$8 = {
14284
+ "app init": {
14285
+ method: "app.init",
14286
+ usage: "app init <dir> [--name <name>] [--title <title>] [--force]",
14287
+ summary: "Start an app (WISP 1200): a small working one in <dir>, its ghostly-app.json, an index.html that says hello to the contact and a README, ready for app publish",
14288
+ args: ["dir"],
14289
+ options: {
14290
+ name: {
14291
+ type: "string",
14292
+ description: "The app's name, lowercase letters, digits and hyphens (default: the folder's)"
14293
+ },
14294
+ title: {
14295
+ type: "string",
14296
+ description: "The title people see, up to 40 characters (default: from the name)"
14297
+ },
14298
+ force: {
14299
+ type: "boolean",
14300
+ description: "Replace the three files when they are there"
14301
+ }
14302
+ },
14303
+ run: ({ options }, a) => initApp(a.dir, {
14304
+ name: options.name,
14305
+ title: options.title,
14306
+ force: options.force === true
14307
+ })
14308
+ },
13630
14309
  "app publish": {
13631
14310
  method: "app.publish",
13632
14311
  usage: "app publish <dir> --key <file> [--out <file>]",
@@ -13804,6 +14483,17 @@ var showSecret = { "show-secret": {
13804
14483
  description: "Print the group's entry link (anyone who has it can join)"
13805
14484
  } };
13806
14485
  var secret = (options) => options["show-secret"] === true ? { showSecret: true } : {};
14486
+ /**
14487
+ * `--for` of the typing commands: the seconds it is kept on, 1 to 600, and never with `--stop`. A usage error (exit 2)
14488
+ * that names the flag: before, the API's own check answered ("for must be a number from 1 to 600", exit 1).
14489
+ */
14490
+ function typingFor(options) {
14491
+ const seconds = options.for;
14492
+ if (seconds === void 0) return void 0;
14493
+ if (typeof seconds !== "number" || !(seconds >= 1 && seconds <= 600)) throw new CliError("usage", `--for takes a number of seconds from 1 to 600, not ${JSON.stringify(seconds)}`);
14494
+ if (options.stop === true) throw new CliError("usage", "--for keeps it on: not with --stop");
14495
+ return seconds;
14496
+ }
13807
14497
  /** A history cursor: a timestamp when it is all digits, else a message id. */
13808
14498
  function cursor(value) {
13809
14499
  if (value === void 0) return void 0;
@@ -13845,6 +14535,10 @@ var cardOptions = {
13845
14535
  text: {
13846
14536
  type: "string",
13847
14537
  description: "The text older apps show instead (default: written from the card)"
14538
+ },
14539
+ force: {
14540
+ type: "boolean",
14541
+ description: "Send even if the card's text looks like a seed, a key or ecash"
13848
14542
  }
13849
14543
  };
13850
14544
  /** `--link label=https://…` or a bare link, as a card's links. */
@@ -13878,7 +14572,26 @@ function itemOf(value) {
13878
14572
  text: value.slice(colon + 1).trim()
13879
14573
  };
13880
14574
  }
13881
- /** A task's fields from its flags; what was not given is left out, so an update changes only what it names. */
14575
+ /** `--progress`, `--left`: a percent, or a usage error that names the flag. */
14576
+ function percent(options, flag) {
14577
+ const value = options[flag];
14578
+ if (value !== void 0 && !(typeof value === "number" && value >= 0 && value <= 100)) throw new CliError("usage", `--${flag} takes a percent from 0 to 100, not ${JSON.stringify(value)}`);
14579
+ }
14580
+ /** The flags of a task's pull request, and the field of `pr` each one gives. */
14581
+ var PR_FLAGS = [
14582
+ ["pr-url", "url"],
14583
+ ["pr-number", "number"],
14584
+ ["additions", "additions"],
14585
+ ["deletions", "deletions"],
14586
+ ["files", "files"],
14587
+ ["pr-state", "state"],
14588
+ ["pr-checks", "checks"]
14589
+ ];
14590
+ /**
14591
+ * A task's fields from its flags; what was not given is left out, so an update changes only what it names. What a flag
14592
+ * got wrong is a usage error (exit 2) that names the flag: before, it reached the card's own check and failed as
14593
+ * `bad_request` (exit 1) in the card's words ("Status card: done is at most total").
14594
+ */
13882
14595
  function taskFields(options) {
13883
14596
  const fields = { ...cardJson(options.json) };
13884
14597
  const set = (key, value) => {
@@ -13887,6 +14600,7 @@ function taskFields(options) {
13887
14600
  set("id", options.id);
13888
14601
  set("title", options.title);
13889
14602
  set("status", options.status);
14603
+ percent(options, "progress");
13890
14604
  set("progress", options.progress);
13891
14605
  set("step", options.step);
13892
14606
  set("branch", options.branch);
@@ -13895,22 +14609,18 @@ function taskFields(options) {
13895
14609
  if (!match) throw new CliError("usage", `--steps takes done/total, like 2/5, not ${JSON.stringify(options.steps)}`);
13896
14610
  fields.done = Number(match[1]);
13897
14611
  fields.total = Number(match[2]);
14612
+ if (Number(match[2]) < 1 || Number(match[1]) > Number(match[2])) throw new CliError("usage", `--steps takes done/total with done at most total, like 2/5, not ${JSON.stringify(options.steps)}`);
13898
14613
  }
14614
+ if (options["pr-url"] !== void 0 && !cardUrl(options["pr-url"])) throw new CliError("usage", `--pr-url takes an https link of at most ${STATUS_CARD_LIMITS.url} characters, not ${JSON.stringify(options["pr-url"])}`);
14615
+ for (const [flag, words] of [["pr-state", PR_STATES], ["pr-checks", PR_CHECKS]]) if (options[flag] !== void 0 && !words.includes(options[flag])) throw new CliError("usage", `--${flag} takes ${listed(words)}, not ${JSON.stringify(options[flag])}`);
13899
14616
  const pr = {};
13900
- for (const [flag, key] of [
13901
- ["pr-url", "url"],
13902
- ["pr-number", "number"],
13903
- ["additions", "additions"],
13904
- ["deletions", "deletions"],
13905
- ["files", "files"],
13906
- ["pr-state", "state"],
13907
- ["pr-checks", "checks"]
13908
- ]) if (options[flag] !== void 0) pr[key] = options[flag];
14617
+ for (const [flag, key] of PR_FLAGS) if (options[flag] !== void 0) pr[key] = options[flag];
13909
14618
  if (Object.keys(pr).length) fields.pr = {
13910
14619
  ...fields.pr && typeof fields.pr === "object" ? fields.pr : {},
13911
14620
  ...pr
13912
14621
  };
13913
14622
  if (Array.isArray(options.item) && options.item.length) fields.items = options.item.map(itemOf);
14623
+ if (Array.isArray(options.tag) && options.tag.length > STATUS_CARD_LIMITS.tags) throw new CliError("usage", `--tag is given at most ${STATUS_CARD_LIMITS.tags} times, not ${options.tag.length}`);
13914
14624
  if (Array.isArray(options.tag) && options.tag.length) fields.tags = options.tag;
13915
14625
  set("parent", options.parent);
13916
14626
  const links = cardLinks(options.link);
@@ -13927,6 +14637,25 @@ function needs(card, fields, usage) {
13927
14637
  if (missing.length) throw new CliError("usage", `${missing.join(" and ")} ${missing.length > 1 ? "are" : "is"} needed: ghostly ${usage}`);
13928
14638
  return card;
13929
14639
  }
14640
+ /**
14641
+ * A new task's pull request goes by its link: `--pr-state` and the like without `--pr-url` (or `pr.url` in `--json`)
14642
+ * are a usage error that names them. An update's card may have the link already, so only a send asks.
14643
+ */
14644
+ function prLinked(card, options, usage) {
14645
+ const given = PR_FLAGS.filter(([flag]) => options[flag] !== void 0).map(([flag]) => `--${flag}`);
14646
+ if (!given.length || card.pr.url !== void 0) return card;
14647
+ throw new CliError("usage", `${given.join(" and ")} ${given.length > 1 ? "go" : "goes"} with --pr-url: ghostly ${usage}`);
14648
+ }
14649
+ /**
14650
+ * A usage card says how much is left: `--left`, or `--used` with `--limit`, as flags or in `--json`. The one that is
14651
+ * missing is a usage error that names the flag.
14652
+ */
14653
+ function quota(card, usage) {
14654
+ const used = card.used !== void 0;
14655
+ if (used !== (card.limit !== void 0)) throw new CliError("usage", `--${used ? "used" : "limit"} goes with --${used ? "limit" : "used"}: ghostly ${usage}`);
14656
+ if (card.left === void 0 && !used) throw new CliError("usage", `--left is needed (or --used with --limit): ghostly ${usage}`);
14657
+ return card;
14658
+ }
13930
14659
  /** A time a command names: milliseconds, or a date the JavaScript Date reads (`2026-09-30T01:00:00Z`). */
13931
14660
  function cardTime(value, flag) {
13932
14661
  if (value === void 0) return void 0;
@@ -14075,6 +14804,7 @@ function usageWindowOf(value) {
14075
14804
  /** A usage card's fields from its flags (WISP 405 § Usage); `--json` gives any of them, the flags win over it. */
14076
14805
  function usageFields(options) {
14077
14806
  const fields = { ...cardJson(options.json) };
14807
+ percent(options, "left");
14078
14808
  for (const key of [
14079
14809
  "id",
14080
14810
  "left",
@@ -14132,7 +14862,8 @@ var usageOptions = {
14132
14862
  description: "Every 1:1 chat with a contact, instead of one chat"
14133
14863
  },
14134
14864
  json: cardOptions.json,
14135
- text: cardOptions.text
14865
+ text: cardOptions.text,
14866
+ force: cardOptions.force
14136
14867
  };
14137
14868
  //#endregion
14138
14869
  //#region src/commands/calls.ts
@@ -14142,7 +14873,7 @@ var commands$7 = {
14142
14873
  method: "call.answer",
14143
14874
  usage: "call answer [<chat|call>] [--rate 48000]",
14144
14875
  summary: "Answer a call that rings (the only one, or the one named)",
14145
- args: ["call..."],
14876
+ args: ["call...?"],
14146
14877
  options: { rate },
14147
14878
  params: ({ options }, a) => ({
14148
14879
  call: a.call,
@@ -14153,7 +14884,7 @@ var commands$7 = {
14153
14884
  method: "call.auto",
14154
14885
  usage: "call auto [on|off] [--from <chat>]... [--rate 48000]",
14155
14886
  summary: "Answer calls by themselves: from anyone, or the chats named (kept in the profile)",
14156
- args: ["mode..."],
14887
+ args: ["mode...?"],
14157
14888
  options: {
14158
14889
  from: {
14159
14890
  type: "list",
@@ -14175,14 +14906,14 @@ var commands$7 = {
14175
14906
  method: "call.flush",
14176
14907
  usage: "call flush [<chat|call>]",
14177
14908
  summary: "Drop the audio queued and not played yet (barge-in)",
14178
- args: ["call..."],
14909
+ args: ["call...?"],
14179
14910
  params: (_, a) => ({ call: a.call })
14180
14911
  },
14181
14912
  "call hangup": {
14182
14913
  method: "call.hangup",
14183
14914
  usage: "call hangup [<chat|call>]",
14184
14915
  summary: "Hang up, or decline a call that rings",
14185
- args: ["call..."],
14916
+ args: ["call...?"],
14186
14917
  params: (_, a) => ({ call: a.call })
14187
14918
  },
14188
14919
  "call list": {
@@ -14204,6 +14935,7 @@ var commands$7 = {
14204
14935
  };
14205
14936
  //#endregion
14206
14937
  //#region src/commands/chats.ts
14938
+ var BUTTON_UPDATE = "button update <chat|group> <message> [--chosen <button>] [--close] [--text <text>] [--force] [--wait none|confirmed|sent] [--timeout s]";
14207
14939
  /** Invites, chats and messages: one entry per command, in alphabetical order (test/commands.test.ts checks). */
14208
14940
  var commands$6 = {
14209
14941
  "button press": {
@@ -14232,7 +14964,7 @@ var commands$6 = {
14232
14964
  },
14233
14965
  "button update": {
14234
14966
  method: "button.update",
14235
- usage: "button update <chat|group> <message> [--chosen <button>] [--close] [--text <text>] [--force] [--wait none|confirmed|sent] [--timeout s]",
14967
+ usage: BUTTON_UPDATE,
14236
14968
  summary: "Show the answer on a message's buttons (--chosen), close them, or change its text (the messageId send --button gave)",
14237
14969
  args: ["chat", "message"],
14238
14970
  options: {
@@ -14255,16 +14987,19 @@ var commands$6 = {
14255
14987
  },
14256
14988
  timeout: groupWait.timeout
14257
14989
  },
14258
- params: ({ options }, a) => ({
14259
- chat: a.chat,
14260
- message: a.message,
14261
- chosen: options.chosen,
14262
- close: options.close === true,
14263
- text: options.text,
14264
- force: options.force === true,
14265
- wait: options.wait,
14266
- timeout: options.timeout
14267
- })
14990
+ params: ({ options }, a) => {
14991
+ if (!options.chosen && options.close !== true && !options.text) throw new CliError("usage", `Give --chosen, --close or --text: ghostly ${BUTTON_UPDATE}`);
14992
+ return {
14993
+ chat: a.chat,
14994
+ message: a.message,
14995
+ chosen: options.chosen,
14996
+ close: options.close === true,
14997
+ text: options.text,
14998
+ force: options.force === true,
14999
+ wait: options.wait,
15000
+ timeout: options.timeout
15001
+ };
15002
+ }
14268
15003
  },
14269
15004
  "chat connect": {
14270
15005
  method: "chat.connect",
@@ -14336,7 +15071,7 @@ var commands$6 = {
14336
15071
  method: "chat.rename",
14337
15072
  usage: "chat rename <chat> <name...>",
14338
15073
  summary: "Name a chat on this side (empty: the contact's name)",
14339
- args: ["chat", "name..."],
15074
+ args: ["chat", "name...?"],
14340
15075
  params: (_, { chat, name }) => ({
14341
15076
  chat,
14342
15077
  name: name ?? ""
@@ -14368,10 +15103,13 @@ var commands$6 = {
14368
15103
  type: "string",
14369
15104
  description: "The code the contact reads out"
14370
15105
  } },
14371
- params: ({ options }, { chat }) => ({
14372
- chat,
14373
- code: options.code
14374
- })
15106
+ params: ({ options }, { chat }) => {
15107
+ if (typeof options.code !== "string" || !options.code.trim()) throw new CliError("usage", "--code is needed: ghostly chat verify <chat> --code <code>");
15108
+ return {
15109
+ chat,
15110
+ code: options.code
15111
+ };
15112
+ }
14375
15113
  },
14376
15114
  "chat wait": {
14377
15115
  method: "chat.wait",
@@ -14414,14 +15152,17 @@ var commands$6 = {
14414
15152
  },
14415
15153
  timeout: groupWait.timeout
14416
15154
  },
14417
- params: ({ options }, a) => ({
14418
- chat: a.chat,
14419
- messages: (a.message ?? "").split(" ").filter(Boolean),
14420
- to: options.to,
14421
- force: options.force === true,
14422
- wait: options.wait,
14423
- timeout: options.timeout
14424
- })
15155
+ params: ({ options }, a) => {
15156
+ if (!Array.isArray(options.to) || !options.to.length) throw new CliError("usage", "Missing --to: ghostly forward <chat|group> <message>... --to <chat|group>... [--force] [--wait none|sent] [--timeout s]");
15157
+ return {
15158
+ chat: a.chat,
15159
+ messages: (a.message ?? "").split(" ").filter(Boolean),
15160
+ to: options.to,
15161
+ force: options.force === true,
15162
+ wait: options.wait,
15163
+ timeout: options.timeout
15164
+ };
15165
+ }
14425
15166
  },
14426
15167
  "invite create": {
14427
15168
  method: "invite.create",
@@ -14479,18 +15220,22 @@ var commands$6 = {
14479
15220
  },
14480
15221
  "pin": {
14481
15222
  method: "chat.pin",
14482
- usage: "pin <chat|group> <message> [--remove]",
15223
+ usage: "pin <chat|group> <message> | --remove",
14483
15224
  summary: "Pin a message at the top of a chat or group (one per chat: it replaces the pinned one); --remove unpins",
14484
- args: ["chat", "message"],
15225
+ args: ["chat", "message...?"],
14485
15226
  options: { remove: {
14486
15227
  type: "boolean",
14487
- description: "Unpin the chat's pinned message"
15228
+ description: "Unpin the chat's pinned message (it names no message)"
14488
15229
  } },
14489
- params: ({ options }, a) => ({
14490
- chat: a.chat,
14491
- message: a.message,
14492
- remove: options.remove === true
14493
- })
15230
+ params: ({ options }, a) => {
15231
+ if (a.message === void 0 && options.remove !== true) throw new CliError("usage", "Missing <message>: ghostly pin <chat|group> <message> | --remove");
15232
+ if (a.message?.includes(" ")) throw new CliError("usage", "Too many arguments: ghostly pin <chat|group> <message> | --remove");
15233
+ return {
15234
+ chat: a.chat,
15235
+ message: a.message,
15236
+ remove: options.remove === true
15237
+ };
15238
+ }
14494
15239
  },
14495
15240
  "react": {
14496
15241
  method: "chat.react",
@@ -14499,7 +15244,7 @@ var commands$6 = {
14499
15244
  args: [
14500
15245
  "chat",
14501
15246
  "message",
14502
- "emoji..."
15247
+ "emoji...?"
14503
15248
  ],
14504
15249
  options: { remove: {
14505
15250
  type: "boolean",
@@ -14514,7 +15259,7 @@ var commands$6 = {
14514
15259
  },
14515
15260
  "routine send": {
14516
15261
  method: "routine.send",
14517
- usage: "routine send <chat|group> --name <name> --schedule \"every day 01:00\" [--cron \"0 1 * * *\"] [--state active|paused] [--next <time>] [--run ok|failed|skipped[:summary]] [--link url]... [--id id] [--json json|-|file] [--wait none|sent|delivered] [--timeout s]",
15262
+ usage: "routine send <chat|group> --name <name> --schedule \"every day 01:00\" [--cron \"0 1 * * *\"] [--state active|paused] [--next <time>] [--run ok|failed|skipped[:summary]] [--link url]... [--id id] [--json json|-|file] [--force] [--wait none|sent|delivered] [--timeout s]",
14518
15263
  summary: "Send a routine card (something a bot runs on a schedule: its last run and the next) to a chat or group",
14519
15264
  args: ["chat"],
14520
15265
  options: {
@@ -14531,13 +15276,14 @@ var commands$6 = {
14531
15276
  card: needs(routineFields(options), [["name", "name"], ["schedule", "schedule"]], "routine send <chat|group> --name <name> --schedule \"every day 01:00\""),
14532
15277
  run: runOf(options.run),
14533
15278
  text: options.text,
15279
+ force: options.force === true,
14534
15280
  wait: options.wait,
14535
15281
  timeout: options.timeout
14536
15282
  })
14537
15283
  },
14538
15284
  "routine update": {
14539
15285
  method: "routine.update",
14540
- usage: "routine update <chat|group> <routine> [--run ok|failed|skipped[:summary]] [--next <time>] [--state active|paused] [--schedule ...] [--json json|-|file] [--wait none|confirmed|sent] [--timeout s]",
15286
+ usage: "routine update <chat|group> <routine> [--run ok|failed|skipped[:summary]] [--next <time>] [--state active|paused] [--schedule ...] [--json json|-|file] [--force] [--wait none|confirmed|sent] [--timeout s]",
14541
15287
  summary: "Update a routine card you sent: record a run, the next one, a pause (at most one update per 2.5 s; sooner ones merge)",
14542
15288
  args: ["chat", "routine"],
14543
15289
  options: {
@@ -14554,13 +15300,14 @@ var commands$6 = {
14554
15300
  card: routineFields(options),
14555
15301
  run: runOf(options.run),
14556
15302
  text: options.text,
15303
+ force: options.force === true,
14557
15304
  wait: options.wait,
14558
15305
  timeout: options.timeout
14559
15306
  })
14560
15307
  },
14561
15308
  "task send": {
14562
15309
  method: "task.send",
14563
- usage: "task send <chat|group> --title <title> [--status s] [--progress n | --steps a/b] [--step \"...\"] [--item state:text]... [--pr-url url --pr-number n --additions n --deletions n --files n --pr-state s --pr-checks c] [--branch b] [--tag label]... [--parent task] [--link url]... [--id id] [--json json|-|file] [--wait none|sent|delivered] [--timeout s]",
15310
+ usage: "task send <chat|group> --title <title> [--status s] [--progress n | --steps a/b] [--step \"...\"] [--item state:text]... [--pr-url url --pr-number n --additions n --deletions n --files n --pr-state s --pr-checks c] [--branch b] [--tag label]... [--parent task] [--link url]... [--id id] [--json json|-|file] [--force] [--wait none|sent|delivered] [--timeout s]",
14564
15311
  summary: "Send a task card (a bot's work: status, progress, its PR) to a chat or group; prints its id and message",
14565
15312
  args: ["chat"],
14566
15313
  options: {
@@ -14574,15 +15321,16 @@ var commands$6 = {
14574
15321
  },
14575
15322
  params: ({ options }, { chat }) => ({
14576
15323
  chat,
14577
- card: needs(taskFields(options), [["title", "title"]], "task send <chat|group> --title <title>"),
15324
+ card: prLinked(needs(taskFields(options), [["title", "title"]], "task send <chat|group> --title <title>"), options, "task send <chat|group> --title <title> --pr-url <url>"),
14578
15325
  text: options.text,
15326
+ force: options.force === true,
14579
15327
  wait: options.wait,
14580
15328
  timeout: options.timeout
14581
15329
  })
14582
15330
  },
14583
15331
  "task update": {
14584
15332
  method: "task.update",
14585
- usage: "task update <chat|group> <task> [--status s] [--progress n | --steps a/b] [--step \"...\"] [--item state:text]... [--pr-url ...] [--pr-state s] [--pr-checks c] [--tag label]... [--parent task] [--json json|-|file] [--wait none|confirmed|sent] [--timeout s]",
15333
+ usage: "task update <chat|group> <task> [--status s] [--progress n | --steps a/b] [--step \"...\"] [--item state:text]... [--pr-url ...] [--pr-state s] [--pr-checks c] [--tag label]... [--parent task] [--json json|-|file] [--force] [--wait none|confirmed|sent] [--timeout s]",
14586
15334
  summary: "Update a task card you sent: the fields given, merged over it (at most one update per 2.5 s; sooner ones merge)",
14587
15335
  args: ["chat", "task"],
14588
15336
  options: {
@@ -14598,6 +15346,7 @@ var commands$6 = {
14598
15346
  task: a.task,
14599
15347
  card: taskFields(options),
14600
15348
  text: options.text,
15349
+ force: options.force === true,
14601
15350
  wait: options.wait,
14602
15351
  timeout: options.timeout
14603
15352
  })
@@ -14630,12 +15379,12 @@ var commands$6 = {
14630
15379
  stop: options.stop === true,
14631
15380
  kind: options.kind,
14632
15381
  status: options.status,
14633
- for: options.for
15382
+ for: typingFor(options)
14634
15383
  })
14635
15384
  },
14636
15385
  "usage send": {
14637
15386
  method: "usage.send",
14638
- usage: "usage send [<chat|group>] [--all] --left <percent> [--window \"5 h\"] [--resets <time>] [--label Claude] [--account <name>] [--also week=80@<time>]... [--used n --limit n] [--id id] [--json json|-|file] [--wait none|sent] [--timeout s]",
15387
+ usage: "usage send [<chat|group>] [--all] --left <percent> [--window \"5 h\"] [--resets <time>] [--label Claude] [--account <name>] [--also week=80@<time>]... [--used n --limit n] [--id id] [--json json|-|file] [--force] [--wait none|sent] [--timeout s]",
14639
15388
  summary: "Say how much of your quota is left (a bot's usage card, one per chat, updated in place); --all for every 1:1 chat",
14640
15389
  args: ["chat?"],
14641
15390
  options: {
@@ -14647,8 +15396,9 @@ var commands$6 = {
14647
15396
  return {
14648
15397
  chat,
14649
15398
  all: options.all === true,
14650
- card: usageFields(options),
15399
+ card: quota(usageFields(options), "usage send <chat|--all> --left <percent>"),
14651
15400
  text: options.text,
15401
+ force: options.force === true,
14652
15402
  wait: options.wait,
14653
15403
  timeout: options.timeout
14654
15404
  };
@@ -14849,15 +15599,18 @@ var commands$4 = {
14849
15599
  method: "profile.picture",
14850
15600
  usage: "profile picture <jpeg> | --clear",
14851
15601
  summary: "The picture contacts see (a square JPEG, 128 px is what the app sends)",
14852
- args: ["path..."],
15602
+ args: ["path...?"],
14853
15603
  options: { clear: {
14854
15604
  type: "boolean",
14855
15605
  description: "Remove the picture"
14856
15606
  } },
14857
- params: ({ options }, a) => ({
14858
- path: here(a.path),
14859
- clear: options.clear === true
14860
- })
15607
+ params: ({ options }, a) => {
15608
+ if (a.path === void 0 && options.clear !== true) throw new CliError("usage", "Missing <jpeg>: ghostly profile picture <jpeg> | --clear");
15609
+ return {
15610
+ path: here(a.path),
15611
+ clear: options.clear === true
15612
+ };
15613
+ }
14861
15614
  },
14862
15615
  "status": {
14863
15616
  method: "status",
@@ -15034,16 +15787,19 @@ var commands$3 = {
15034
15787
  method: "group.picture",
15035
15788
  usage: "group picture <group> <jpeg> | --clear",
15036
15789
  summary: "Set or clear the group's picture (admin; a square JPEG)",
15037
- args: ["group", "path..."],
15790
+ args: ["group", "path...?"],
15038
15791
  options: { clear: {
15039
15792
  type: "boolean",
15040
15793
  description: "Remove the picture"
15041
15794
  } },
15042
- params: ({ options }, a) => ({
15043
- group: a.group,
15044
- path: here(a.path),
15045
- clear: options.clear === true
15046
- })
15795
+ params: ({ options }, a) => {
15796
+ if (a.path === void 0 && options.clear !== true) throw new CliError("usage", "Missing <jpeg>: ghostly group picture <group> <jpeg> | --clear");
15797
+ return {
15798
+ group: a.group,
15799
+ path: here(a.path),
15800
+ clear: options.clear === true
15801
+ };
15802
+ }
15047
15803
  },
15048
15804
  "group react": {
15049
15805
  method: "group.react",
@@ -15052,7 +15808,7 @@ var commands$3 = {
15052
15808
  args: [
15053
15809
  "group",
15054
15810
  "message",
15055
- "emoji..."
15811
+ "emoji...?"
15056
15812
  ],
15057
15813
  options: { remove: {
15058
15814
  type: "boolean",
@@ -15139,7 +15895,7 @@ var commands$3 = {
15139
15895
  stop: options.stop === true,
15140
15896
  kind: options.kind,
15141
15897
  status: options.status,
15142
- for: options.for
15898
+ for: typingFor(options)
15143
15899
  })
15144
15900
  }
15145
15901
  };
@@ -15662,7 +16418,7 @@ var TEXT_COMMANDS = {
15662
16418
  "send": {
15663
16419
  method: "chat.send",
15664
16420
  target: "chat",
15665
- args: ["chat", "text..."],
16421
+ args: ["chat", "text...?"],
15666
16422
  usage: "send <chat> [text...] [--reply <message>] [--button id:Label]... [--style id=primary|neutral|danger]... [--once] [--id id] [--stdin] [--force] [--wait none|sent|delivered]",
15667
16423
  summary: "Send a message (text from arguments or stdin); --button puts buttons under it, each label its own (case aside)",
15668
16424
  options: {
@@ -15683,7 +16439,7 @@ var TEXT_COMMANDS = {
15683
16439
  args: [
15684
16440
  "chat",
15685
16441
  "message",
15686
- "text..."
16442
+ "text...?"
15687
16443
  ],
15688
16444
  usage: "edit <chat> <message> [text... | --text <text> | --stdin] [--force] [--wait none|confirmed]",
15689
16445
  summary: "Replace the text of a message you sent (the id send gave; in a group, group edit)",
@@ -15707,7 +16463,7 @@ var TEXT_COMMANDS = {
15707
16463
  "group send": {
15708
16464
  method: "group.send",
15709
16465
  target: "group",
15710
- args: ["group", "text..."],
16466
+ args: ["group", "text...?"],
15711
16467
  usage: "group send <group> [text...] [--mention <member>]... [--reply <message>] [--button id:Label]... [--style id=primary|neutral|danger]... [--once] [--id id] [--stdin] [--force] [--wait none|sent] [--timeout s]",
15712
16468
  summary: "Send to a group; mention members written as @name in the text; --button puts buttons under it",
15713
16469
  options: {
@@ -15732,7 +16488,7 @@ var TEXT_COMMANDS = {
15732
16488
  args: [
15733
16489
  "group",
15734
16490
  "message",
15735
- "text..."
16491
+ "text...?"
15736
16492
  ],
15737
16493
  usage: "group edit <group> <message> [text... | --text <text> | --stdin] [--mention <member>]... [--force] [--wait none|sent] [--timeout s]",
15738
16494
  summary: "Replace the text of a message you sent to a group (the id group send gave)",
@@ -15773,13 +16529,15 @@ var ID_ARGS = /* @__PURE__ */ new Set([
15773
16529
  "service",
15774
16530
  "call"
15775
16531
  ]);
16532
+ /** A leading positional that may be left out (`name?`); a trailing one that may (`text...?`) shifts nothing. */
16533
+ var leadingOptional = (name) => name.endsWith("?") && !name.endsWith("...?");
15776
16534
  /**
15777
16535
  * Which of a command's positionals take an id, by index (a trailing `...` one covers the rest). An optional `name?`
15778
16536
  * shifts the others, so it counts only where it and what may come in its place are all ids.
15779
16537
  */
15780
16538
  function idSlot(command) {
15781
16539
  const names = (command.args ?? []).map((name) => name.replace(/\?$/, ""));
15782
- const shift = (command.args ?? []).filter((name) => name.endsWith("?")).length;
16540
+ const shift = (command.args ?? []).filter(leadingOptional).length;
15783
16541
  const rest = names.findIndex((name) => name.endsWith("..."));
15784
16542
  const at = (index) => {
15785
16543
  const name = rest !== -1 && index >= rest ? names[rest].slice(0, -3) : names[index];
@@ -15789,29 +16547,36 @@ function idSlot(command) {
15789
16547
  }
15790
16548
  /**
15791
16549
  * The positionals of a command, by name; missing required ones are a usage error. A leading `name?` is optional: it
15792
- * is given only when every required one is there too (`file accept [<chat>] <file>`).
16550
+ * is given only when every required one is there too (`file accept [<chat>] <file>`). A trailing `name...` takes the
16551
+ * rest and needs at least one word; `name...?` may take none (text from stdin, `chat rename`'s name back to the
16552
+ * contact's).
15793
16553
  */
15794
16554
  function positionals(command, values) {
15795
16555
  let names = command.args ?? [];
15796
- const optional = names.filter((name) => name.endsWith("?"));
16556
+ const optional = names.filter(leadingOptional);
15797
16557
  if (optional.length) {
15798
16558
  const skip = Math.max(0, optional.length - Math.max(0, values.length - (names.length - optional.length)));
15799
- names = [...optional.slice(skip).map((name) => name.slice(0, -1)), ...names.filter((name) => !name.endsWith("?"))];
16559
+ names = [...optional.slice(skip).map((name) => name.slice(0, -1)), ...names.filter((name) => !leadingOptional(name))];
15800
16560
  }
15801
16561
  const out = {};
15802
16562
  for (let i = 0; i < names.length; i++) {
15803
- const name = names[i];
16563
+ const name = names[i].replace(/\?$/, "");
15804
16564
  if (name.endsWith("...")) {
15805
16565
  const rest = values.slice(i);
16566
+ if (!rest.length && !names[i].endsWith("?")) throw new CliError("usage", `Missing <${name.slice(0, -3)}>: ghostly ${command.usage}`);
15806
16567
  out[name.slice(0, -3)] = rest.length ? rest.join(" ") : void 0;
15807
16568
  return out;
15808
16569
  }
15809
16570
  if (values[i] === void 0) throw new CliError("usage", `Missing <${name}>: ghostly ${command.usage}`);
15810
16571
  out[name] = values[i];
15811
16572
  }
15812
- if (values.length > names.length) throw new CliError("usage", `Too many arguments: ghostly ${command.usage}`);
16573
+ noMoreThan(names.length, values, command.usage);
15813
16574
  return out;
15814
16575
  }
16576
+ /** Words past a usage's arguments are refused, never dropped: `profile use my bot` would select `my`. */
16577
+ function noMoreThan(count, values, usage) {
16578
+ if (values.length > count) throw new CliError("usage", `Too many arguments: ghostly ${usage}`);
16579
+ }
15815
16580
  //#endregion
15816
16581
  //#region src/announce.ts
15817
16582
  /**
@@ -15923,6 +16688,12 @@ var PlaybackQueue = class {
15923
16688
  this.length = 0;
15924
16689
  return ms;
15925
16690
  }
16691
+ /** Drops up to that much from the front, in whole frames (audio whose time went by); returns how much, in milliseconds. */
16692
+ skip(ms) {
16693
+ const frames = Math.min(Math.floor(ms / 20), Math.floor(this.length / this.frame));
16694
+ if (frames > 0) this.take(frames * this.frame);
16695
+ return Math.max(0, frames) * 20;
16696
+ }
15926
16697
  take(bytes) {
15927
16698
  const out = Buffer.allocUnsafe(bytes);
15928
16699
  let filled = 0;
@@ -15955,6 +16726,19 @@ function audioSocketPath(profileDir, callId) {
15955
16726
  return join("/tmp", `ghostly-calls-${createHash("sha256").update(profileDir).digest("hex").slice(0, 24)}`, `${callId}.sock`);
15956
16727
  }
15957
16728
  /**
16729
+ * Removes the call sockets a daemon that died during a call left in this profile's folder (`audioSocketPath`; call
16730
+ * ids are 12 hex digits): no call outlives its daemon. A folder that is a link or someone else's is left alone.
16731
+ */
16732
+ function clearAudioSockets(profileDir) {
16733
+ if (process.platform === "win32") return;
16734
+ const folder = dirname(audioSocketPath(profileDir, "0".repeat(12)));
16735
+ try {
16736
+ const info = lstatSync(folder);
16737
+ if (!info.isDirectory() || process.getuid && info.uid !== process.getuid()) return;
16738
+ for (const entry of readdirSync(folder, { withFileTypes: true })) if (entry.name.endsWith(".sock") && !entry.isDirectory()) rmSync(join(folder, entry.name), { force: true });
16739
+ } catch {}
16740
+ }
16741
+ /**
15958
16742
  * Serves one call's audio to one program at a time: what the program writes is handed to `onAudio` as it comes (raw
15959
16743
  * PCM, any amount); `write` gives it the caller's audio. A second connection replaces the first. `close` ends the
15960
16744
  * program's connection (it reads EOF) and removes the socket.
@@ -16139,6 +16923,8 @@ var ReorderBuffer = class {
16139
16923
  };
16140
16924
  //#endregion
16141
16925
  //#region src/calls/audio.ts
16926
+ /** The longest stall of the sender whose frames are still sent after it (see `catchUp`). */
16927
+ var MAX_CATCH_UP_MS = 1e3;
16142
16928
  /**
16143
16929
  * One call's audio: every 20 ms a frame from the queue (the program's audio, or silence) is encoded and sent; what
16144
16930
  * comes in is put back in order, decoded, and handed on in 20 ms frames as soon as it is. Nothing waits on a clock
@@ -16156,11 +16942,23 @@ var CallAudio = class {
16156
16942
  timer = null;
16157
16943
  startedAt = 0;
16158
16944
  ticks = 0;
16945
+ /** What was left in the queue after the last frame sent: the program's audio written ahead of the call. */
16946
+ ahead = 0;
16947
+ /** After a stall that was not replayed: how much of its audio is still to drop, down to what queue, and until when. */
16948
+ owed = 0;
16949
+ kept = 0;
16950
+ owedUntil = 0;
16159
16951
  talking = false;
16952
+ /** The last silent frame's packet, how many silent frames in a row gave it, and that packet once it is kept (see `settle`). */
16953
+ lastQuiet = null;
16954
+ same = 0;
16955
+ quiet = null;
16160
16956
  stopped = false;
16161
16957
  /** Frames sent and received, for `call list`. */
16162
16958
  sent = 0;
16163
16959
  received = 0;
16960
+ /** When the last packet, RTP or RTCP, came from the contact (Date.now(); 0: none yet), for the call's watch. */
16961
+ heardAt = 0;
16164
16962
  constructor(options) {
16165
16963
  this.options = options;
16166
16964
  const now = options.now ?? Date.now;
@@ -16189,20 +16987,46 @@ var CallAudio = class {
16189
16987
  tick() {
16190
16988
  if (this.stopped) return;
16191
16989
  const { frame, silent } = this.queue.next();
16990
+ const still = silent || frame.equals(this.silence);
16192
16991
  let payload;
16193
- try {
16194
- payload = this.options.encoder.encode(frame, this.samples);
16195
- } catch {
16196
- return;
16992
+ if (still && this.quiet) payload = this.quiet;
16993
+ else {
16994
+ try {
16995
+ payload = this.options.encoder.encode(frame, this.samples);
16996
+ } catch {
16997
+ return;
16998
+ }
16999
+ this.settle(still, payload);
16197
17000
  }
16198
17001
  const marker = !silent && !this.talking;
16199
17002
  this.talking = !silent;
16200
17003
  this.options.track.send(this.writer.packet(payload, marker));
16201
17004
  this.sent++;
17005
+ this.ahead = this.queue.queuedMs;
17006
+ }
17007
+ /**
17008
+ * Keeps the packet silence encodes to once the encoder gave it SETTLED_FRAMES times in a row: it is sent again for
17009
+ * the silent frames that follow, without the encoder, until the program's audio is back.
17010
+ */
17011
+ settle(still, payload) {
17012
+ if (!still) {
17013
+ this.lastQuiet = null;
17014
+ this.quiet = null;
17015
+ this.same = 0;
17016
+ return;
17017
+ }
17018
+ if (this.lastQuiet?.equals(payload)) {
17019
+ if (++this.same >= 25) this.quiet = this.lastQuiet;
17020
+ return;
17021
+ }
17022
+ this.lastQuiet = Buffer.from(payload);
17023
+ this.same = 1;
16202
17024
  }
16203
17025
  /** Handles one packet from the track. Exposed for tests. */
16204
17026
  receive(packet) {
16205
- if (this.stopped || isRtcp(packet)) return;
17027
+ if (this.stopped) return;
17028
+ this.heardAt = (this.options.now ?? Date.now)();
17029
+ if (isRtcp(packet)) return;
16206
17030
  const rtp = parseRtp(packet);
16207
17031
  if (!rtp || rtp.payloadType !== this.options.payloadType) return;
16208
17032
  for (const item of this.reorder.push(rtp)) {
@@ -16231,14 +17055,57 @@ var CallAudio = class {
16231
17055
  const due = this.startedAt + (this.ticks + 1) * 20;
16232
17056
  this.timer = setTimeout(() => {
16233
17057
  if (this.stopped) return;
16234
- if (now() - due > 100) {
16235
- this.startedAt = now();
16236
- this.ticks = 0;
16237
- } else this.ticks++;
16238
- this.tick();
16239
- this.schedule();
17058
+ if (now() - due < 20) {
17059
+ this.ticks++;
17060
+ this.drop();
17061
+ this.tick();
17062
+ this.schedule();
17063
+ return;
17064
+ }
17065
+ setImmediate(() => {
17066
+ if (this.stopped) return;
17067
+ this.catchUp(now() - due);
17068
+ this.schedule();
17069
+ });
16240
17070
  }, Math.max(0, due - now()));
16241
17071
  }
17072
+ /**
17073
+ * Meets the clock again after the loop was busy: every frame that is due goes out at once, so a loop that turns
17074
+ * slower than a frame for a while (every 40 ms under a large file) keeps up, and a program writing at real time
17075
+ * stays there. The contact's jitter buffer takes the short burst. A stall past MAX_CATCH_UP_MS is not replayed: the
17076
+ * clock starts again and what the program wrote during it is dropped, or it would be heard that much late for the
17077
+ * rest of the call.
17078
+ */
17079
+ catchUp(late) {
17080
+ const now = this.options.now ?? Date.now;
17081
+ if (late > 1e3) {
17082
+ this.owed = late;
17083
+ this.kept = this.ahead;
17084
+ this.owedUntil = now() + MAX_CATCH_UP_MS;
17085
+ this.startedAt = now();
17086
+ this.ticks = 0;
17087
+ this.drop();
17088
+ this.tick();
17089
+ return;
17090
+ }
17091
+ do {
17092
+ this.ticks++;
17093
+ this.drop();
17094
+ this.tick();
17095
+ } while (this.startedAt + (this.ticks + 1) * 20 <= now());
17096
+ }
17097
+ /**
17098
+ * Drops the audio of a stall that was not replayed, as it is read: a long one does not fit the program's socket, so
17099
+ * the rest of it comes over the next turns. What was queued before the stall (a clip written ahead) is kept.
17100
+ */
17101
+ drop() {
17102
+ if (this.owed <= 0) return;
17103
+ if ((this.options.now ?? Date.now)() > this.owedUntil) {
17104
+ this.owed = 0;
17105
+ return;
17106
+ }
17107
+ this.owed -= this.queue.skip(Math.min(this.owed, this.queue.queuedMs - this.kept));
17108
+ }
16242
17109
  };
16243
17110
  //#endregion
16244
17111
  //#region src/calls/media.ts
@@ -16524,6 +17391,7 @@ var CallManager = class {
16524
17391
  this.host = host;
16525
17392
  this.configPath = join(host.profileDir, "calls.json");
16526
17393
  this.auto = readAuto(this.configPath);
17394
+ if (host.answers !== false) clearAudioSockets(host.profileDir);
16527
17395
  }
16528
17396
  get now() {
16529
17397
  return (this.host.now ?? Date.now)();
@@ -16710,6 +17578,8 @@ var CallManager = class {
16710
17578
  this.callable(call.chat);
16711
17579
  call.rate = rateOf(options.rate, call.rate);
16712
17580
  const stack = await this.stack();
17581
+ if (call.ended) throw new CliError("unavailable", "The call ended before it was answered");
17582
+ if (call.state !== "ringing") throw new CliError("refused", "That call is not ringing here");
16713
17583
  call.state = "connecting";
16714
17584
  this.disarm(call);
16715
17585
  try {
@@ -16808,15 +17678,26 @@ var CallManager = class {
16808
17678
  startedAt: this.now,
16809
17679
  connectedAt: null,
16810
17680
  timer: null,
17681
+ watcher: null,
17682
+ stalledFrom: null,
17683
+ stalls: 0,
16811
17684
  ended: false
16812
17685
  };
16813
17686
  this.calls.set(call.id, call);
16814
17687
  return call;
16815
17688
  }
16816
- /** The call's socket (open from now on: a program may connect before the media is up) and its media. */
17689
+ /**
17690
+ * The call's socket (open from now on: a program may connect before the media is up) and its media. A call that
17691
+ * ended while its socket opened closes it here: `end` found none to close, and nothing lists the call any more.
17692
+ */
16817
17693
  async attach(call, create) {
16818
17694
  const queue = call.queue = new PlaybackQueue(call.rate);
16819
- call.socket = await AudioSocket.open(audioSocketPath(this.host.profileDir, call.id), call.rate, { onAudio: (chunk) => queue.push(chunk) });
17695
+ const socket = await AudioSocket.open(audioSocketPath(this.host.profileDir, call.id), call.rate, { onAudio: (chunk) => queue.push(chunk) });
17696
+ if (call.ended) {
17697
+ await socket.close();
17698
+ return;
17699
+ }
17700
+ call.socket = socket;
16820
17701
  await this.connect(call, create);
16821
17702
  }
16822
17703
  /**
@@ -16824,6 +17705,7 @@ var CallManager = class {
16824
17705
  * the call ended, or asked for a newer connection, while this one was made: then it is closed.
16825
17706
  */
16826
17707
  async connect(call, create) {
17708
+ if (call.ended) return false;
16827
17709
  const attempt = ++call.attempt;
16828
17710
  call.media?.close();
16829
17711
  call.media = null;
@@ -16867,12 +17749,58 @@ var CallManager = class {
16867
17749
  direction: call.direction,
16868
17750
  audio: this.audioInfo(call)
16869
17751
  });
17752
+ this.watch(call);
17753
+ }
17754
+ /**
17755
+ * libjuice says nothing of a contact whose side went away (a crash, a closed lid, a lost network) until its consent
17756
+ * checks fail, about 30 s later: a connected call watches the contact's packets instead. An app sends them all along
17757
+ * (silence while muted, and RTCP), and so does the CLI. Nothing for STALL_MS: `call.stalled`; packets again:
17758
+ * `call.resumed`; nothing for STALL_END_MS: the call ends as `failed`, and the contact's app is told.
17759
+ */
17760
+ watch(call) {
17761
+ const { afterMs, endMs } = this.host.stall ?? {
17762
+ afterMs: 5e3,
17763
+ endMs: 15e3
17764
+ };
17765
+ const since = Date.now();
17766
+ call.watcher = setInterval(() => {
17767
+ if (call.ended) return;
17768
+ const heardAt = Math.max(since, call.media?.audio.heardAt ?? 0);
17769
+ const silentMs = Date.now() - heardAt;
17770
+ const fields = {
17771
+ call: call.id,
17772
+ chat: call.chat,
17773
+ direction: call.direction
17774
+ };
17775
+ if (silentMs >= endMs) {
17776
+ process.stderr.write(`ghostly: call ${call.id}: nothing heard from the contact for ${Math.round(silentMs / 1e3)} s\n`);
17777
+ this.end(call, "failed", true);
17778
+ } else if (silentMs >= afterMs && call.stalledFrom === null) {
17779
+ call.stalledFrom = heardAt;
17780
+ call.stalls++;
17781
+ process.stderr.write(`ghostly: call ${call.id}: stalled, nothing heard from the contact for ${silentMs} ms\n`);
17782
+ this.host.emit("call.stalled", `call.stalled:${call.id}:${call.stalls}`, {
17783
+ ...fields,
17784
+ silentMs
17785
+ });
17786
+ } else if (silentMs < afterMs && call.stalledFrom !== null) {
17787
+ const gapMs = heardAt - call.stalledFrom;
17788
+ call.stalledFrom = null;
17789
+ process.stderr.write(`ghostly: call ${call.id}: resumed after ${gapMs} ms\n`);
17790
+ this.host.emit("call.resumed", `call.resumed:${call.id}:${call.stalls}`, {
17791
+ ...fields,
17792
+ silentMs: gapMs
17793
+ });
17794
+ }
17795
+ }, Math.min(1e3, afterMs / 5));
17796
+ call.watcher.unref?.();
16870
17797
  }
16871
17798
  async end(call, reason, tell, report = true) {
16872
17799
  if (call.ended) return;
16873
17800
  call.ended = true;
16874
17801
  call.reason = reason;
16875
17802
  this.disarm(call);
17803
+ if (call.watcher) clearInterval(call.watcher);
16876
17804
  this.calls.delete(call.id);
16877
17805
  if (report) process.stderr.write(`ghostly: call ${call.id}: ended (${reason})${call.media ? `, ice ${call.media.ice.state}` : ""}\n`);
16878
17806
  call.media?.close();
@@ -16943,6 +17871,7 @@ var CallManager = class {
16943
17871
  chat: call.chat,
16944
17872
  direction: call.direction,
16945
17873
  state: call.state,
17874
+ stalled: call.stalledFrom !== null,
16946
17875
  video: call.video,
16947
17876
  startedAt: call.startedAt,
16948
17877
  connectedAt: call.connectedAt,
@@ -18428,10 +19357,11 @@ async function openPersistentIndexedDb(dir) {
18428
19357
  const pending = /* @__PURE__ */ new WeakMap();
18429
19358
  const snapshotPath = join(dir, SNAPSHOT);
18430
19359
  const journalPath = join(dir, JOURNAL);
18431
- if (existsSync(snapshotPath)) restore(classes, raw._databases, deserialize(readFileSync(snapshotPath)));
18432
- if (existsSync(journalPath)) replay(raw._databases, readFileSync(journalPath));
19360
+ const snapshotFound = existsSync(snapshotPath), journalFound = existsSync(journalPath);
19361
+ if (snapshotFound) restore(classes, raw._databases, deserialize(readFileSync(snapshotPath)), (a, b) => factory.cmp(a, b));
19362
+ if (journalFound) replay(raw._databases, readFileSync(journalPath));
18433
19363
  restoring = false;
18434
- await writeSnapshot();
19364
+ if (journalFound || !snapshotFound) await writeSnapshot();
18435
19365
  const storeProto = classes.ObjectStore.prototype;
18436
19366
  const storeRecord = storeProto.storeRecord;
18437
19367
  const deleteRecord = storeProto.deleteRecord;
@@ -18614,7 +19544,12 @@ async function probeClasses(factory) {
18614
19544
  });
18615
19545
  return classes;
18616
19546
  }
18617
- function restore(classes, databases, snapshot) {
19547
+ /**
19548
+ * The snapshot's records come in key order (as `writeSnapshot` walks them), so each store's tree and each index's are
19549
+ * built whole from sorted arrays: storing record by record compared every key down the tree, a 70,000-message profile
19550
+ * took seconds. A snapshot not in order (never written so) is stored record by record.
19551
+ */
19552
+ function restore(classes, databases, snapshot, cmp) {
18618
19553
  if (snapshot?.format !== 1) throw new Error("This profile was last used by a newer version of ghostly (its store has a format this version does not read). Update ghostly to open it.");
18619
19554
  for (const saved of snapshot.databases) {
18620
19555
  const db = new classes.Database(saved.name, saved.version);
@@ -18627,16 +19562,103 @@ function restore(classes, databases, snapshot) {
18627
19562
  store.rawIndexes.set(i.name, index);
18628
19563
  }
18629
19564
  db.rawObjectStores.set(s.name, store);
18630
- for (const [key, value] of s.records) store.storeRecord({
19565
+ const records = s.records.map(([key, value]) => ({
18631
19566
  key,
18632
19567
  value: blobsFromDisk(value)
18633
- }, false);
19568
+ }));
19569
+ if (records.some((record, at) => at > 0 && cmp(records[at - 1].key, record.key) >= 0)) {
19570
+ for (const record of records) store.storeRecord(record, false);
19571
+ continue;
19572
+ }
19573
+ fill(store.records, records);
19574
+ for (const index of store.rawIndexes.values()) fill(index.records, indexEntries(index, records, cmp));
18634
19575
  }
18635
19576
  databases.set(saved.name, db);
18636
19577
  }
18637
19578
  }
18638
- /** Replays whole entries; a torn last entry (the process died mid-write) was never acknowledged and is dropped. */
19579
+ /**
19580
+ * An index's entries for records in key order, sorted by the index's key: the index derives them itself (key path,
19581
+ * multi-entry, invalid keys skipped) into a list instead of its tree. A stable sort keeps equal index keys in the
19582
+ * records' key order, the tree's own tie-break.
19583
+ */
19584
+ function indexEntries(index, records, cmp) {
19585
+ const entries = [];
19586
+ const tree = index.records;
19587
+ index.records = {
19588
+ get: () => void 0,
19589
+ put: (entry) => {
19590
+ entries.push(entry);
19591
+ }
19592
+ };
19593
+ try {
19594
+ for (const record of records) if (!absent(index.keyPath, record.value)) index.storeRecord(record);
19595
+ } finally {
19596
+ index.records = tree;
19597
+ }
19598
+ return entries.sort((a, b) => cmp(a.key, b.key));
19599
+ }
19600
+ /**
19601
+ * Whether a key path surely finds nothing in a value: a plain object along it lacks the step (most messages have no
19602
+ * `card` for the card index). The index would throw and catch a DataError for each of those; anything else it judges.
19603
+ */
19604
+ function absent(keyPath, value) {
19605
+ if (typeof keyPath !== "string" || keyPath === "") return false;
19606
+ let object = value;
19607
+ for (const step of keyPath.split(".")) {
19608
+ if (object === null || typeof object !== "object" || Array.isArray(object) || object instanceof Blob) return false;
19609
+ if (!Object.hasOwn(object, step)) return true;
19610
+ object = object[step];
19611
+ }
19612
+ return false;
19613
+ }
19614
+ /**
19615
+ * fake-indexeddb's red-black tree, built balanced from sorted records: every level full but the last, whose nodes
19616
+ * are red, so each path holds the same number of black nodes and later inserts rebalance as usual.
19617
+ */
19618
+ function fill(store, sorted) {
19619
+ const deepest = sorted.length ? Math.floor(Math.log2(sorted.length)) : 0;
19620
+ const build = (low, high, parent, depth) => {
19621
+ if (low > high) return void 0;
19622
+ const middle = low + high >>> 1;
19623
+ const node = {
19624
+ record: sorted[middle],
19625
+ left: void 0,
19626
+ right: void 0,
19627
+ parent,
19628
+ deleted: false,
19629
+ red: depth > 0 && depth === deepest
19630
+ };
19631
+ node.left = build(low, middle - 1, node, depth + 1);
19632
+ node.right = build(middle + 1, high, node, depth + 1);
19633
+ return node;
19634
+ };
19635
+ store.records._root = build(0, sorted.length - 1, void 0, 0);
19636
+ store.records._numNodes = sorted.length;
19637
+ store.records._numTombstones = 0;
19638
+ }
19639
+ /**
19640
+ * Replays whole entries; a torn last entry (the process died mid-write) was never acknowledged and is dropped.
19641
+ *
19642
+ * A store's indexes are set aside while its ops replay and rebuilt once at the end: fake-indexeddb drops an
19643
+ * overwritten or deleted record from an index by walking every record of it, so a crash after a busy chat took
19644
+ * tens of seconds to minutes to start, one write at a time against the whole profile.
19645
+ */
18639
19646
  function replay(databases, journal) {
19647
+ const touched = /* @__PURE__ */ new Map();
19648
+ try {
19649
+ replayEntries(databases, journal, touched);
19650
+ } finally {
19651
+ for (const [store, indexes] of touched) {
19652
+ store.rawIndexes = indexes;
19653
+ for (const index of indexes.values()) {
19654
+ index.records.clear();
19655
+ if (!index.initialized) continue;
19656
+ for (const record of store.records.values()) index.storeRecord(record);
19657
+ }
19658
+ }
19659
+ }
19660
+ }
19661
+ function replayEntries(databases, journal, touched) {
18640
19662
  let offset = 0;
18641
19663
  while (offset + 4 <= journal.length) {
18642
19664
  const length = journal.readUInt32BE(offset);
@@ -18651,6 +19673,10 @@ function replay(databases, journal) {
18651
19673
  for (const op of ops) {
18652
19674
  const store = databases.get(op.db)?.rawObjectStores.get(op.store);
18653
19675
  if (!store) continue;
19676
+ if (!touched.has(store)) {
19677
+ touched.set(store, store.rawIndexes);
19678
+ store.rawIndexes = /* @__PURE__ */ new Map();
19679
+ }
18654
19680
  if (op.kind === "put") store.storeRecord({
18655
19681
  key: op.key,
18656
19682
  value: blobsFromDisk(op.value)
@@ -18892,7 +19918,7 @@ async function startRuntime(paths, options = {}) {
18892
19918
  const restored = join(paths.dir, RESTORED_MARK);
18893
19919
  if (existsSync(restored)) {
18894
19920
  try {
18895
- const rows = await import("./assets/restoredRows-lhTKGmCO.js");
19921
+ const rows = await import("./assets/restoredRows-C_84q0Pd.js");
18896
19922
  await rows.markRestoredWallet();
18897
19923
  await rows.markLeftOutFiles(leftOutIn(restored));
18898
19924
  await store.compact();
@@ -18911,16 +19937,16 @@ async function startRuntime(paths, options = {}) {
18911
19937
  await installEventSource();
18912
19938
  await preloadWalletModules();
18913
19939
  (await import("./assets/ghostly_iroh_web-Dvzh3Y6E.js")).initSync({ module: irohWasmBytes() });
18914
- const [{ registerFileBytes }, { NodeFileBytes }] = await Promise.all([import("./assets/fileBytes-3AQ2tJzW.js").then((n) => n.o), import("./assets/fileBytes-CAZDydIe.js")]);
19940
+ const [{ registerFileBytes }, { NodeFileBytes }] = await Promise.all([import("./assets/fileBytes-BcYZhNgF.js").then((n) => n.o), import("./assets/fileBytes-Dbifu5I5.js")]);
18915
19941
  registerFileBytes("native", async () => new NodeFileBytes(paths.files), true);
18916
- const [{ EngineServer }, { createHyperEndpoint }] = await Promise.all([import("./assets/server-GJW_5wGI.js").then((n) => n.t), import("./assets/endpoint-D_3bYafG.js")]);
19942
+ const [{ EngineServer }, { createHyperEndpoint }] = await Promise.all([import("./assets/server-D5FBZYW8.js").then((n) => n.t), import("./assets/endpoint-pgFkR2AS.js")]);
18917
19943
  const network = hyperdhtNetwork();
18918
19944
  const dht = mainlineNetwork();
18919
- const mainline = dht.off ? null : new (await (import("./assets/mainline-B7wHcLaW.js"))).Mainline({
19945
+ const mainline = dht.off ? null : new (await (import("./assets/mainline-DVYdrus6.js"))).Mainline({
18920
19946
  bootstrap: dht.bootstrap,
18921
19947
  host: dht.host
18922
19948
  });
18923
- const transport = mainline || pinned ? new (await (import("./assets/mainline-B7wHcLaW.js"))).RelaysAndDht(mainline, pinned ? { relays: pinned } : {}, !!pinned) : null;
19949
+ const transport = mainline || pinned ? new (await (import("./assets/mainline-DVYdrus6.js"))).RelaysAndDht(mainline, pinned ? { relays: pinned } : {}, !!pinned) : null;
18924
19950
  const server = new EngineServer({
18925
19951
  ...transport ? { transport } : {},
18926
19952
  irohWeb: true,
@@ -18930,6 +19956,7 @@ async function startRuntime(paths, options = {}) {
18930
19956
  automaticWallets: false,
18931
19957
  fedimintSdk: nodeFedimintSdk(join(paths.dir, "fedimint")),
18932
19958
  servicesSupport: true,
19959
+ apps: false,
18933
19960
  staysOnline: process.env.GHOSTLY_HUB !== "0",
18934
19961
  localFetch: nodeLocalFetch,
18935
19962
  pushSend: (request) => nodePushSend(request),
@@ -18979,6 +20006,7 @@ async function openHost(paths, mode, version, options = {}) {
18979
20006
  await hub.open();
18980
20007
  const node = runtime.server.node;
18981
20008
  const now = node.getState();
20009
+ const mark = runtime.server.historyMark;
18982
20010
  const histories = /* @__PURE__ */ new Map();
18983
20011
  for (const link of now.links) histories.set(link.id, await node.getMessages(link.id));
18984
20012
  for (const group of now.groups) histories.set(`group:${group.id}`, await node.groupMessages({ groupId: group.id }));
@@ -18992,7 +20020,10 @@ async function openHost(paths, mode, version, options = {}) {
18992
20020
  });
18993
20021
  const manager = calls;
18994
20022
  hub.onCallSignal((chat, signal) => manager.onSignal(chat, signal));
18995
- runtime.server.attach(hub.sink);
20023
+ runtime.server.attach(hub.sink, {
20024
+ mark,
20025
+ ids: histories.keys()
20026
+ });
18996
20027
  } catch (error) {
18997
20028
  await runtime.close().catch(() => {});
18998
20029
  release();
@@ -19226,19 +20257,21 @@ function toTurn(event) {
19226
20257
  }
19227
20258
  function turn(event, source, where, message) {
19228
20259
  const file = message.file;
20260
+ const place = where.chat ?? where.group ?? "", messageId = turnRef(place, message.id);
19229
20261
  return {
19230
20262
  seq: event.seq,
19231
- id: `agent.turn:${event.id}`,
20263
+ id: `agent.turn:${source}:${place}:${messageId}`,
19232
20264
  type: "agent.turn",
19233
20265
  at: event.at,
19234
20266
  source,
19235
20267
  ...where,
19236
- messageId: message.id,
20268
+ messageId,
19237
20269
  timestamp: message.timestamp,
19238
20270
  ...message.press ? { press: message.press } : {},
19239
20271
  untrusted: {
19240
20272
  text: message.text,
19241
20273
  name: message.nick ?? null,
20274
+ messageId: message.id,
19242
20275
  ...message.replyTo ? { replyTo: {
19243
20276
  id: message.replyTo.id,
19244
20277
  snippet: message.replyTo.snippet
@@ -19320,6 +20353,7 @@ async function post(url, event) {
19320
20353
  try {
19321
20354
  if ((await fetch(url, {
19322
20355
  method: "POST",
20356
+ redirect: "manual",
19323
20357
  headers: {
19324
20358
  "content-type": "application/json",
19325
20359
  "x-ghostly-event": event.type,
@@ -19426,7 +20460,14 @@ function requireProfile(g) {
19426
20460
  createProfile(g.home, g.profile);
19427
20461
  return;
19428
20462
  }
19429
- throw new CliError("not_found", `No profile ${g.profile}: make it with \`ghostly profile create ${g.profile}\``);
20463
+ throw noProfile(g);
20464
+ }
20465
+ function noProfile(g) {
20466
+ return new CliError("not_found", `No profile ${g.profile}: make it with \`ghostly profile create ${g.profile}\``);
20467
+ }
20468
+ /** For a command that only reads: a mistyped profile is named as one, and `default` is not made by asking about it. */
20469
+ function knownProfile(g) {
20470
+ if (g.profile !== "default" && !profileExists(g.home, g.profile)) throw noProfile(g);
19430
20471
  }
19431
20472
  /** Said on stderr when the daemon runs another release than this command (it started before an upgrade). */
19432
20473
  function versionWarning(daemon, cli) {
@@ -19508,7 +20549,10 @@ var SPECIAL = [
19508
20549
  {
19509
20550
  out: o("string", "The backup file to write"),
19510
20551
  "passphrase-file": o("string", "The passphrase, from this file (else GHOSTLY_BACKUP_PASSPHRASE)"),
19511
- passphrase: o("boolean", "--no-passphrase: do not encrypt. The file then holds the profile's keys and wallet secrets in the clear"),
20552
+ passphrase: {
20553
+ ...o("boolean", "Do not encrypt. The file then holds the profile's keys and wallet secrets in the clear"),
20554
+ negatedOnly: true
20555
+ },
19512
20556
  light: o("boolean", "Leave out files over 1 MB (voice messages over 4 MB); their messages stay")
19513
20557
  }
19514
20558
  ],
@@ -19579,7 +20623,8 @@ var SPECIAL = [
19579
20623
  days: o("number", "How long the proof holds"),
19580
20624
  timeout: o("number", "Seconds to wait for an approval")
19581
20625
  }
19582
- ]
20626
+ ],
20627
+ ["version", "This CLI's version (also ghostly --version)"]
19583
20628
  ];
19584
20629
  function help() {
19585
20630
  const rows = [
@@ -19645,7 +20690,9 @@ function commandHelp(words) {
19645
20690
  const options = Object.entries(row.options ?? {}).filter(([, o]) => o.description);
19646
20691
  if (options.length) {
19647
20692
  lines.push("", "Options:");
19648
- for (const [name, o] of options) lines.push(` --${name}${o.type === "boolean" ? "" : ` <${o.type === "number" ? "n" : "value"}>`}`.padEnd(26) + o.description);
20693
+ const labels = options.map(([name, o]) => ` --${o.negatedOnly ? "no-" : ""}${name}${o.type === "boolean" ? "" : ` <${o.type === "number" ? "n" : "value"}>`}`);
20694
+ const width = Math.max(26, ...labels.map((label) => label.length + 2));
20695
+ options.forEach(([, o], i) => lines.push(labels[i].padEnd(width) + o.description));
19649
20696
  }
19650
20697
  if (Object.values(TEXT_COMMANDS).includes(row)) lines.push("", " -- ends the options: everything after it is the text, options included (\"-- -1 --wait sent\" sends", " all of it). Put options before --.");
19651
20698
  lines.push("");
@@ -19733,6 +20780,7 @@ async function profileCommand(sub, argv) {
19733
20780
  return;
19734
20781
  }
19735
20782
  case "list": {
20783
+ noMoreThan(0, parsed.positionals, "profile list");
19736
20784
  const current = currentProfile(g.home);
19737
20785
  print({
19738
20786
  profiles: listProfiles(g.home).map((name) => ({
@@ -19747,14 +20795,17 @@ async function profileCommand(sub, argv) {
19747
20795
  case "use": {
19748
20796
  const [name] = parsed.positionals;
19749
20797
  if (!name) throw new CliError("usage", "ghostly profile use <name>");
20798
+ noMoreThan(1, parsed.positionals, "profile use <name>");
19750
20799
  selectProfile(g.home, name);
19751
20800
  print({ current: name });
19752
20801
  return;
19753
20802
  }
19754
20803
  case "show":
20804
+ noMoreThan(0, parsed.positionals, "profile show");
19755
20805
  print(await withSession(g, (s) => s.call("profile.get")));
19756
20806
  return;
19757
20807
  case "set": {
20808
+ noMoreThan(0, parsed.positionals, "profile set [--name <name>] [--share-profile | --no-share-profile]");
19758
20809
  const params = {};
19759
20810
  if (parsed.options.name !== void 0) params.name = parsed.options.name;
19760
20811
  if (parsed.options["share-profile"] !== void 0) params.shareProfile = parsed.options["share-profile"];
@@ -19763,6 +20814,7 @@ async function profileCommand(sub, argv) {
19763
20814
  return;
19764
20815
  }
19765
20816
  case "backup": {
20817
+ noMoreThan(0, parsed.positionals, "profile backup --out <file> [--passphrase-file f | --no-passphrase] [--light]");
19766
20818
  const out = parsed.options.out;
19767
20819
  if (typeof out !== "string") throw new CliError("usage", "ghostly profile backup --out <file> [--passphrase-file f | --no-passphrase] [--light] (or GHOSTLY_BACKUP_PASSPHRASE)");
19768
20820
  const unsealed = parsed.options.passphrase === false;
@@ -19785,6 +20837,7 @@ async function profileCommand(sub, argv) {
19785
20837
  case "restore": {
19786
20838
  const [file, name] = parsed.positionals;
19787
20839
  if (!file || !name) throw new CliError("usage", "ghostly profile restore <file> <new profile> [--passphrase-file f] [--use]");
20840
+ noMoreThan(2, parsed.positionals, "profile restore <file> <new profile> [--passphrase-file f] [--use]");
19788
20841
  const protection = await backupFileProtection(file);
19789
20842
  const paths = await restoreProfile(g.home, checkProfileName(name), file, protection === "none" ? void 0 : backupPassphrase(parsed.options["passphrase-file"]));
19790
20843
  if (parsed.options.use) selectProfile(g.home, name);
@@ -19872,7 +20925,13 @@ async function daemonCommand(argv) {
19872
20925
  });
19873
20926
  const g = globals(parsed);
19874
20927
  pretty = g.pretty;
20928
+ if (!sub || [
20929
+ "status",
20930
+ "stop",
20931
+ "restart"
20932
+ ].includes(sub)) noMoreThan(0, parsed.positionals, sub ? `daemon ${sub}` : "daemon [--detach]");
19875
20933
  if (sub === "status") {
20934
+ knownProfile(g);
19876
20935
  const client = await connectDaemon(g.paths.socket);
19877
20936
  if (!client) {
19878
20937
  print({
@@ -19894,6 +20953,7 @@ async function daemonCommand(argv) {
19894
20953
  return;
19895
20954
  }
19896
20955
  if (sub === "stop") {
20956
+ knownProfile(g);
19897
20957
  const client = await connectDaemon(g.paths.socket);
19898
20958
  if (!client) throw new CliError("not_found", `No daemon runs profile ${g.profile}`);
19899
20959
  print({
@@ -20143,6 +21203,7 @@ async function engineCommand(argv) {
20143
21203
  const g = globals(parsed);
20144
21204
  pretty = g.pretty;
20145
21205
  if (parsed.options.list) {
21206
+ noMoreThan(0, parsed.positionals, "engine --list");
20146
21207
  print({ methods: [...ENGINE_METHODS, ...ENGINE_READS].sort() });
20147
21208
  return;
20148
21209
  }
@@ -20186,6 +21247,7 @@ async function settingsCommand(sub, argv) {
20186
21247
  const g = globals(parsed);
20187
21248
  pretty = g.pretty;
20188
21249
  if (sub === "get") {
21250
+ noMoreThan(0, parsed.positionals, "settings get [--show-secret]");
20189
21251
  print(await withSession(g, (s) => s.call("settings.get", { showSecret: parsed.options["show-secret"] === true })));
20190
21252
  return;
20191
21253
  }
@@ -20198,6 +21260,7 @@ async function settingsCommand(sub, argv) {
20198
21260
  } catch {
20199
21261
  parsedValue = value;
20200
21262
  }
21263
+ if (SETTABLE[key] === "string" && typeof parsedValue !== "string") parsedValue = value;
20201
21264
  print(await withSession(g, (s) => s.call("settings.set", { [key]: parsedValue })));
20202
21265
  return;
20203
21266
  }
@@ -20358,7 +21421,7 @@ async function tableCommand(name, argv) {
20358
21421
  * contact's, raw PCM); what the command says goes to stderr. It ends when the call does.
20359
21422
  */
20360
21423
  async function callPipeCommand(argv) {
20361
- const parsed = parseArgs(argv, {}, idSlot({ args: ["call..."] }));
21424
+ const parsed = parseArgs(argv, {}, idSlot({ args: ["call...?"] }));
20362
21425
  const g = globals(parsed);
20363
21426
  const ref = parsed.positionals.join(" ") || void 0;
20364
21427
  const client = await connectDaemon(g.paths.socket);
@@ -20400,7 +21463,7 @@ function helpAsked(argv) {
20400
21463
  }
20401
21464
  async function main(input) {
20402
21465
  const argv = liftGlobals(input);
20403
- const helpFor = argv.length === 0 ? [] : helpAsked(argv);
21466
+ const helpFor = argv.length === 0 || globalOption(argv[0]) ? [] : helpAsked(argv);
20404
21467
  if (helpFor) {
20405
21468
  process.stdout.write((helpFor.length ? commandHelp(helpFor) : help()) + "\n");
20406
21469
  return 0;
@@ -20411,7 +21474,8 @@ async function main(input) {
20411
21474
  }
20412
21475
  const [first, second] = argv;
20413
21476
  const two = second && !second.startsWith("-") ? `${first} ${second}` : "";
20414
- if (first === "profile" && !COMMANDS[two]) {
21477
+ const text = (name) => Object.hasOwn(TEXT_COMMANDS, name), table = (name) => Object.hasOwn(COMMANDS, name);
21478
+ if (first === "profile" && !table(two)) {
20415
21479
  await profileCommand(second, argv.slice(2));
20416
21480
  return 0;
20417
21481
  }
@@ -20439,19 +21503,19 @@ async function main(input) {
20439
21503
  await callPipeCommand(argv.slice(2));
20440
21504
  return -1;
20441
21505
  }
20442
- if (two && TEXT_COMMANDS[two]) {
21506
+ if (two && text(two)) {
20443
21507
  await textCommand(two, argv.slice(2));
20444
21508
  return 0;
20445
21509
  }
20446
- if (two && COMMANDS[two]) {
21510
+ if (two && table(two)) {
20447
21511
  await tableCommand(two, argv.slice(2));
20448
21512
  return 0;
20449
21513
  }
20450
- if (TEXT_COMMANDS[first]) {
21514
+ if (text(first)) {
20451
21515
  await textCommand(first, argv.slice(1));
20452
21516
  return 0;
20453
21517
  }
20454
- if (COMMANDS[first]) {
21518
+ if (table(first)) {
20455
21519
  await tableCommand(first, argv.slice(1));
20456
21520
  return 0;
20457
21521
  }