@ghostlytools/cli 1.1.7 → 1.2.0

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 +16 -11
  2. package/SKILL.md +2 -2
  3. package/dist/assets/arkade-3szZ1IpP.js +362 -0
  4. package/dist/assets/arkade-3szZ1IpP.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-DmTrpmba.js} +3 -3
  16. package/dist/assets/{hyperdhtRelay-DvXgebV7.js.map → hyperdhtRelay-DmTrpmba.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-BueFJx3f.js} +2 -2
  20. package/dist/assets/{mainline-B7wHcLaW.js.map → mainline-BueFJx3f.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-BGXmSYx_.js} +1124 -1036
  24. package/dist/assets/server-BGXmSYx_.js.map +1 -0
  25. package/dist/assets/{src-Cp3o_yHN.js → src-BMUcYQ7b.js} +750 -242
  26. package/dist/assets/src-BMUcYQ7b.js.map +1 -0
  27. package/dist/assets/usdt-DklGBSeq.js +410 -0
  28. package/dist/assets/usdt-DklGBSeq.js.map +1 -0
  29. package/dist/ghostly.mjs +858 -137
  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 readAppRevocations, $n as engineError, An as encodeDnsQuery, Ao as signalHasVideo, At as didJwsAlg, Bn as resolveLightningDestination, Bt as parseDidSignaturePaste, Cn as homeserverWebEndpoint, Co as RTC_CONFIG, Ct as DID_DOCUMENT_MAX_BYTES$1, Dn as dnsProofName, Do as callSignalHeardAt, Dt as checkDidJws, Ec as setLinkTraceSink, En as decodeDnsResponse, Eo as callRtcConfig, Et as DidError, Fa as RUN_RESULTS, Fc as openRelayPayload, Fn as recordsFromWellKnown, Ft as didWebFileUrl, Gc as fromBase64Url$1, Hc as MAX_AVATAR_LENGTH, Ht as staticDidDocument, Ia as STATUS_CARD_LIMITS, Ii as identityStatement, In as recordsName, It as didWebHost, Ja as appFingerprint, Jc as toZ32, Jt as resolveDidDht, Ka as APP_PREFIXES, Kc as randomBytes$1, Ki as irohDescriptor, Kr as sanitizeTypingStatus, La as buttonLabelClash, Ln as txtValue, Lt as normalizeDid, Ma as replySnippet, Mc as RELAY_PAYLOAD_MAX_BYTES, Mn as nip05RootKey, Mt as didWebDocumentUrl, Ni as boundVerifiedIdentity, Nn as normalizeDomain, Nt as didWebFile, On as domainTxtRecord, Oo as extractParamsFromSdp, Ot as checkDidSignature, Pa as ITEM_STATES, Pn as recordsFromTxt, Pt as didWebFileNames, Ra as checkStatusCard, Rn as wellKnownUrl, Rt as parseDid, Sn as pubkyProofPath, So as CALL_SIGNAL_MAX_AGE_MS, St as verifyAtprotoRecordProof, Tn as DOMAIN_FILE_MAX_BYTES, To as buildSdpFromSignal, Uc as sanitizeAvatar, Ur as TYPING_KINDS, Ut as strictBase64Url, Vn as decodeBolt11, Vr as transferEnded, Vt as signingKeys, Wr as TYPING_REFRESH_MS, Xa as checkAppRevokeStatement, Xc as utf8Encode, Ya as appRef, Yc as utf8Decode, Ys as inviteLink, Z as groupName, _ as readAppBundle, _n as isPubkyProofFolder, _t as isAtprotoDid, aa as PLAYABLE_AUDIO, ar as fetchesByItself, at as verifySshSignature, bn as pubkyProofCapability, bt as shortAtprotoDid, c as signAppStore, ca as fileMessageText, ci as checkPushEndpoint, ct as decodeAnyBitcoinAddress, dr as readGroupWantNo, dt as ATPROTO_PROOF_COLLECTION, ea as readImageMeta, eo as signAppObject, 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, ho as GhostlyHttpError, ht as atprotoProofRkey, ia as sanitizeMime, in as WALLET_NETWORKS, ir as GROUP_WANT_NO_FRAME, it as sshSignCommand, ji as IdentityCheckUnavailable, jn as isPublicAddress, jt as didSignedMessages, kn as domainWellKnownFile, ko as parseCallSignal, 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, no as canonicalJson, nr as GROUP_HAVE_FRAME, nt as parseSshSignature, o as checkAppStoreIndex, oa as VOICE_LIMITS, oc as seedSigner, or as groupFileFallback, ot as verifyBitcoinMessage, pi as BUTTONS_CAPABILITY, pt as atprotoDidMethod, qa as APP_STATEMENT_LIMITS, qc as toBase64Url, qi as irohRelayUrl, ra as sanitizeFileName, ro as canonicalJsonBytes, rt as sameSshKey, s as readAppStore, sa as baseMime, sr as isGroupMessageId, st as BitcoinAddressError, to as signAppRevocation, 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, wo as answersOffer, wt as DID_FILE_MAX_BYTES, xn as pubkyProofFile, xt as AtprotoProofError, yn as normalizePubkyKey, yo as LIMITS, yt as parseAtprotoDidDocument, zn as requestLnurlInvoice, zr as ChatFiles, zt as parseDidDocument } from "./assets/src-BMUcYQ7b.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,8 +12,8 @@ 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
17
  import { decode, npubEncode } from "nostr-tools/nip19";
18
18
  import { BunkerSigner } from "nostr-tools/nip46";
19
19
  import { SimplePool } from "nostr-tools/pool";
@@ -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.0",
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
  };
@@ -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,6 +283,12 @@ 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
294
  * parser reads them. Stops at `--` and at the first word that is not a global option.
@@ -292,8 +299,7 @@ function liftGlobals(argv) {
292
299
  while (i < argv.length) {
293
300
  const arg = argv[i];
294
301
  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;
302
+ const option = globalOption(arg);
297
303
  if (!option || arg === "--") break;
298
304
  const takesValue = option.type !== "boolean" && eq === -1;
299
305
  lifted.push(...argv.slice(i, i + (takesValue ? 2 : 1)));
@@ -675,6 +681,7 @@ var ENGINE_METHODS = [
675
681
  "joinGroupByLink",
676
682
  "sendGroupMessage",
677
683
  "sendGroupFile",
684
+ "groupFileCheck",
678
685
  "pressButton",
679
686
  "groupMessages",
680
687
  "messagePage",
@@ -1043,7 +1050,7 @@ function groupReply(reply, myKey) {
1043
1050
  //#endregion
1044
1051
  //#region ../browser/src/engine/groupFiles.ts
1045
1052
  /** 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}`;
1053
+ var groupFileId = (groupId, direction, random = toBase64Url(randomBytes$1(12))) => `group-${groupId}-${direction}-${random}`;
1047
1054
  /** The group a local file id names, among `groupIds`. */
1048
1055
  var groupOfFile = (fileId, groupIds) => [...groupIds].find((g) => fileId.startsWith(`group-${g}-in-`) || fileId.startsWith(`group-${g}-out-`));
1049
1056
  /** 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 +1058,28 @@ var groupFileText = (text, meta) => text === groupFileFallback(meta) ? fileMessa
1051
1058
  /** The profile's files store and file storage, as a 1:1 chat's files use them. */
1052
1059
  var storedGroupFiles = {
1053
1060
  list: (groupId) => fileStore.listForLink(`group:${groupId}`),
1061
+ groups: () => fileStore.groupIds(),
1054
1062
  get: (id) => fileStore.get(id),
1055
1063
  put: (file) => fileStore.put(file),
1056
1064
  patch: (id, fields) => fileStore.patch(id, fields),
1057
1065
  remove: (id) => removeStored(id),
1058
- async write(id) {
1066
+ async write(id, resume = false) {
1059
1067
  const stored = await fileStore.get(id);
1060
1068
  const bytes = stored?.bytes && await fileBytesOf(stored.bytes) || await fileBytes();
1061
- if (await bytes.size(id) !== null) await bytes.truncate(id, 0);
1069
+ const size = await bytes.size(id);
1070
+ const offset = resume && size !== null ? size : 0;
1071
+ if (size !== null && size !== offset) await bytes.truncate(id, offset);
1062
1072
  if (stored && stored.bytes !== bytes.kind) await fileStore.patch(id, { bytes: bytes.kind });
1063
- const appender = new FileAppender(bytes, id, 0);
1073
+ const appender = new FileAppender(bytes, id, offset);
1064
1074
  return {
1075
+ offset,
1065
1076
  append: (chunk) => appender.append(chunk),
1066
1077
  flush: () => appender.flush(),
1067
1078
  digest: async () => {
1068
1079
  await appender.close();
1069
1080
  return bytes.digest(id);
1070
1081
  },
1082
+ close: () => appender.close(),
1071
1083
  discard: async () => {
1072
1084
  await appender.close().catch(() => {});
1073
1085
  await bytes.remove(id);
@@ -1077,8 +1089,14 @@ var storedGroupFiles = {
1077
1089
  read: (file, offset, length) => readStored(file, offset, length),
1078
1090
  room: async () => (await fileBytes()).room()
1079
1091
  };
1080
- /** Why a want waits, as the person reads it. */
1081
- var NOBODY_CONNECTED = "Nobody you are connected to has this file yet";
1092
+ /** Why a want waits, as the person reads it (the app shows its own words for each `wait`). */
1093
+ var WAITING_NOTES = {
1094
+ nobody: "Nobody you are connected to has this file yet",
1095
+ busy: "Its holders are busy; asking again soon",
1096
+ damaged: "It arrived damaged; ask again"
1097
+ };
1098
+ /** Why a want stopped: every holder served bytes that are not the author's file. */
1099
+ var DAMAGED$1 = "Every copy of this file arrived damaged and was deleted. Ask for it again later.";
1082
1100
  var GroupFileDesk = class {
1083
1101
  deps;
1084
1102
  kept = /* @__PURE__ */ new Map();
@@ -1090,6 +1108,7 @@ var GroupFileDesk = class {
1090
1108
  own = /* @__PURE__ */ new Map();
1091
1109
  /** Announcements sent per group, and taken per member of a group (WISP 503 § Rules for both, Pace). */
1092
1110
  sentPace = /* @__PURE__ */ new Map();
1111
+ sentTaken = /* @__PURE__ */ new Map();
1093
1112
  takenPace = /* @__PURE__ */ new Map();
1094
1113
  /** `<group>\n<message id>` a `group-have` named before its message came → its holders, newest first. */
1095
1114
  ahead = /* @__PURE__ */ new Map();
@@ -1126,7 +1145,8 @@ var GroupFileDesk = class {
1126
1145
  auto: !!file.group.auto,
1127
1146
  asked: !!file.group.asked,
1128
1147
  held: file.transfer?.state === "done",
1129
- holders: []
1148
+ holders: [],
1149
+ damaged: /* @__PURE__ */ new Set()
1130
1150
  };
1131
1151
  this.add(kept);
1132
1152
  if (kept.held) this.show(kept, {
@@ -1135,6 +1155,14 @@ var GroupFileDesk = class {
1135
1155
  size,
1136
1156
  direction: kept.mine ? "out" : "in"
1137
1157
  });
1158
+ else if (file.transfer?.state === "failed") this.show(kept, {
1159
+ state: "failed",
1160
+ transferred: 0,
1161
+ size,
1162
+ direction: "in",
1163
+ retry: true,
1164
+ error: file.transfer.error ?? DAMAGED$1
1165
+ });
1138
1166
  else if (kept.auto || kept.asked) this.wants.set(kept.id, this.newWant());
1139
1167
  else this.show(kept, {
1140
1168
  state: "transferring",
@@ -1147,9 +1175,27 @@ var GroupFileDesk = class {
1147
1175
  for (const [id] of this.wants) this.ask(this.kept.get(id));
1148
1176
  this.deps.changed();
1149
1177
  }
1150
- /** An error when this group took as many announcements of mine as it takes in a minute; null when one may go now. */
1178
+ /**
1179
+ * At start, once every group was read: the files of a group this device no longer has (`groupIds` are the groups it
1180
+ * has, a tombstone or a join under way among them) go, with their bytes. A group left before Leave took its files
1181
+ * left them behind, where nothing lists them.
1182
+ */
1183
+ async sweep(groupIds) {
1184
+ const here = new Set(groupIds);
1185
+ for (const groupId of await this.deps.store.groups().catch(() => [])) {
1186
+ if (here.has(groupId)) continue;
1187
+ for (const file of await this.deps.store.list(groupId).catch(() => [])) await this.deps.store.remove(file.id).catch(() => {});
1188
+ }
1189
+ }
1190
+ /**
1191
+ * An error when this group took as many announcements of mine as it takes in a minute, or as its members take from
1192
+ * one member in `announceWindowMs` (past that they would get the text, not the file); null when one may go now.
1193
+ */
1151
1194
  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;
1195
+ const wait = Math.max(...this.sentPaces(groupId).map((pace) => pace.wait()));
1196
+ if (!wait) return null;
1197
+ const minutes = Math.ceil(wait / 6e4);
1198
+ return `You sent many files to this group just now. Wait ${minutes > 1 ? `${minutes} minutes` : "a minute"}.`;
1153
1199
  }
1154
1200
  /** My file, stored under `fileId` with the author's digest, is about to be announced in the group: its message takes it. */
1155
1201
  outgoing(groupId, fileId, meta) {
@@ -1170,7 +1216,7 @@ var GroupFileDesk = class {
1170
1216
  const ownId = m.sender === me ? this.own.get(`${groupId}\n${m.file.d}`) : void 0;
1171
1217
  if (ownId) {
1172
1218
  this.own.delete(`${groupId}\n${m.file.d}`);
1173
- this.pace(this.sentPace, groupId, GROUP_FILE_LIMITS.announcePerMinute, 6e4).take();
1219
+ for (const pace of this.sentPaces(groupId)) pace.take();
1174
1220
  const kept = {
1175
1221
  id: ownId,
1176
1222
  groupId,
@@ -1181,7 +1227,8 @@ var GroupFileDesk = class {
1181
1227
  auto: false,
1182
1228
  asked: false,
1183
1229
  held: true,
1184
- holders: []
1230
+ holders: [],
1231
+ damaged: /* @__PURE__ */ new Set()
1185
1232
  };
1186
1233
  this.add(kept);
1187
1234
  const transfer = {
@@ -1218,7 +1265,8 @@ var GroupFileDesk = class {
1218
1265
  auto,
1219
1266
  asked: false,
1220
1267
  held: false,
1221
- holders
1268
+ holders,
1269
+ damaged: /* @__PURE__ */ new Set()
1222
1270
  };
1223
1271
  const { name, size, mime, voice, video, image } = m.file;
1224
1272
  this.add(kept);
@@ -1271,9 +1319,18 @@ var GroupFileDesk = class {
1271
1319
  kept.asked = true;
1272
1320
  await this.deps.store.patch(kept.id, { group: this.groupField(kept) }).catch(() => {});
1273
1321
  }
1322
+ kept.damaged.clear();
1323
+ if (this.deps.transfers.get(kept.id)?.state === "failed") await this.deps.store.patch(kept.id, { transfer: {
1324
+ state: "transferring",
1325
+ transferred: 0,
1326
+ size: kept.meta.size
1327
+ } }).catch(() => {});
1274
1328
  const want = this.wants.get(kept.id);
1275
1329
  if (!want) this.wants.set(kept.id, this.newWant());
1276
- else if (!want.wire) want.tried.clear();
1330
+ else if (!want.wire) {
1331
+ want.tried.clear();
1332
+ want.why = void 0;
1333
+ }
1277
1334
  this.ask(kept);
1278
1335
  }
1279
1336
  /** The group is gone from this device: its files go too. */
@@ -1348,6 +1405,7 @@ var GroupFileDesk = class {
1348
1405
  }
1349
1406
  edge.taking.set(frame.id, kept.id);
1350
1407
  want.wire = frame.id;
1408
+ want.why = void 0;
1351
1409
  want.movedAt = this.deps.now();
1352
1410
  }
1353
1411
  return edge.files.handle(frame);
@@ -1371,9 +1429,13 @@ var GroupFileDesk = class {
1371
1429
  want.wire = void 0;
1372
1430
  }
1373
1431
  if (want.holder) want.tried.add(want.holder);
1374
- else want.tried.clear();
1432
+ else {
1433
+ want.tried.clear();
1434
+ want.why = void 0;
1435
+ }
1375
1436
  this.ask(kept);
1376
1437
  }
1438
+ this.letGo();
1377
1439
  for (const edge of this.edgesById.values()) {
1378
1440
  if (!edge.have.size || now - edge.haveAt < GROUP_FILE_LIMITS.haveEveryMs || !this.deps.ready(edge.linkId)) continue;
1379
1441
  this.sendHave(edge, [...edge.have]);
@@ -1415,10 +1477,20 @@ var GroupFileDesk = class {
1415
1477
  if (!want || kept.held || want.wire) return;
1416
1478
  const membership = this.deps.membership(kept.groupId);
1417
1479
  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));
1480
+ const known = [kept.author, ...kept.holders.filter((key) => key !== kept.author)].filter((key) => membership && key !== membership.me && membership.inRoster(key));
1481
+ if (known.length && known.every((key) => kept.damaged.has(key))) {
1482
+ this.stopDamaged(kept);
1483
+ return;
1484
+ }
1485
+ const order = known.filter((key) => !want.tried.has(key) && !kept.damaged.has(key));
1486
+ let behind = false;
1419
1487
  for (const key of order) {
1420
1488
  const linkId = edges.get(key);
1421
1489
  if (!linkId || !this.deps.ready(linkId) || !this.edgesById.get(linkId)?.files.live) continue;
1490
+ if (this.askedOf(linkId, want)) {
1491
+ behind = true;
1492
+ continue;
1493
+ }
1422
1494
  if (!this.deps.send(linkId, {
1423
1495
  t: "group-want",
1424
1496
  g: kept.groupId,
@@ -1435,7 +1507,7 @@ var GroupFileDesk = class {
1435
1507
  this.show(kept, {
1436
1508
  state: "transferring",
1437
1509
  stage: "queued",
1438
- transferred: 0,
1510
+ transferred: kept.stored ?? 0,
1439
1511
  size: kept.meta.size,
1440
1512
  direction: "in"
1441
1513
  });
@@ -1446,13 +1518,50 @@ var GroupFileDesk = class {
1446
1518
  linkId: void 0,
1447
1519
  at: this.deps.now()
1448
1520
  });
1521
+ if (behind) {
1522
+ this.show(kept, {
1523
+ state: "transferring",
1524
+ stage: "queued",
1525
+ transferred: kept.stored ?? 0,
1526
+ size: kept.meta.size,
1527
+ direction: "in"
1528
+ });
1529
+ return;
1530
+ }
1531
+ const wait = want.why ?? "nobody";
1449
1532
  this.show(kept, {
1450
1533
  state: "transferring",
1451
1534
  stage: "waiting",
1452
- transferred: 0,
1535
+ transferred: kept.stored ?? 0,
1453
1536
  size: kept.meta.size,
1454
1537
  direction: "in",
1455
- note: NOBODY_CONNECTED
1538
+ wait,
1539
+ note: WAITING_NOTES[wait],
1540
+ ...wait === "damaged" && { stalled: true }
1541
+ });
1542
+ }
1543
+ /** Another file is asked of, or coming from, the member at the other end of this edge. */
1544
+ askedOf(linkId, except) {
1545
+ for (const want of this.wants.values()) if (want !== except && want.linkId === linkId) return true;
1546
+ return false;
1547
+ }
1548
+ /** No holder serves the author's bytes: the want stops, and stays stopped after a restart, until Download. */
1549
+ stopDamaged(kept) {
1550
+ this.wants.delete(kept.id);
1551
+ const { size } = kept.meta;
1552
+ this.deps.store.patch(kept.id, { transfer: {
1553
+ state: "failed",
1554
+ transferred: 0,
1555
+ size,
1556
+ error: DAMAGED$1
1557
+ } }).catch(() => {});
1558
+ this.show(kept, {
1559
+ state: "failed",
1560
+ transferred: 0,
1561
+ size,
1562
+ direction: "in",
1563
+ retry: true,
1564
+ error: DAMAGED$1
1456
1565
  });
1457
1566
  }
1458
1567
  /** A holder's no: the next one is asked. One that deleted the file is no holder any more. */
@@ -1461,9 +1570,11 @@ var GroupFileDesk = class {
1461
1570
  const want = kept && this.wants.get(kept.id);
1462
1571
  if (!kept || !want || want.linkId !== linkId || want.wire) return;
1463
1572
  if (why === "gone") kept.holders = kept.holders.filter((key) => key !== peer);
1573
+ if (why === "busy") want.why = "busy";
1464
1574
  want.tried.add(peer);
1465
1575
  want.holder = want.linkId = void 0;
1466
1576
  this.ask(kept);
1577
+ this.retry(groupId);
1467
1578
  }
1468
1579
  /** A member says it holds these files: remembered as a holder of each (newest first), and asked when a want waits. */
1469
1580
  heard(groupId, from, ids) {
@@ -1499,8 +1610,12 @@ var GroupFileDesk = class {
1499
1610
  });
1500
1611
  return;
1501
1612
  }
1502
- const wire = toBase64Url$1(randomBytes$1(12));
1613
+ const wire = toBase64Url(randomBytes$1(12));
1503
1614
  edge.serving.set(wire, kept.id);
1615
+ edge.servedMoved.set(wire, {
1616
+ confirmed: 0,
1617
+ at: this.deps.now()
1618
+ });
1504
1619
  const { name, mime, size, voice, video, image } = kept.meta;
1505
1620
  const file = {
1506
1621
  id: wire,
@@ -1524,8 +1639,13 @@ var GroupFileDesk = class {
1524
1639
  if (!membership || !membership.couldRead(edge.peer, messageId)) return "refused";
1525
1640
  if (!kept) return "gone";
1526
1641
  if (!this.deps.settings().serveFiles || !edge.files.live) return "busy";
1642
+ this.letGo();
1527
1643
  const active = this.servingNow();
1528
- if (active.length >= GROUP_FILE_LIMITS.serveAtOnce || active.some((a) => a.peer === edge.peer)) return "busy";
1644
+ if (active.length >= GROUP_FILE_LIMITS.serveAtOnce) {
1645
+ edge.busy.add(messageId);
1646
+ return "busy";
1647
+ }
1648
+ if (active.some((a) => a.peer === edge.peer && a.sending)) return "busy";
1529
1649
  const today = Math.floor(this.deps.now() / 864e5);
1530
1650
  if (this.served.day !== today) this.served = {
1531
1651
  day: today,
@@ -1534,18 +1654,37 @@ var GroupFileDesk = class {
1534
1654
  const reserved = active.reduce((sum, a) => sum + a.size, 0);
1535
1655
  return this.served.bytes + reserved + kept.meta.size > GROUP_FILE_LIMITS.serveBytesPerDay ? "busy" : null;
1536
1656
  }
1537
- /** Transfers served now, all groups together: to whom, and their size. */
1657
+ /** Transfers served now, all groups together: to whom, their size, and whether bytes are still to go. */
1538
1658
  servingNow() {
1539
1659
  const active = [];
1540
1660
  for (const edge of this.edgesById.values()) for (const wire of edge.serving.keys()) {
1541
1661
  const record = edge.files.get("out", wire);
1542
1662
  if (record && !transferEnded(record)) active.push({
1543
1663
  peer: edge.peer,
1544
- size: record.file.size
1664
+ size: record.file.size,
1665
+ sending: record.confirmed < record.file.size
1545
1666
  });
1546
1667
  }
1547
1668
  return active;
1548
1669
  }
1670
+ /**
1671
+ * Served transfers that no longer count end, their places free for others (WISP 503 § Serving limits): the asker's
1672
+ * session closed, it is not in the roster any more, or its bytes have not moved for `wantWaitMs` (the asker has moved
1673
+ * on by then).
1674
+ */
1675
+ letGo() {
1676
+ const now = this.deps.now();
1677
+ for (const edge of this.edgesById.values()) for (const wire of [...edge.serving.keys()]) {
1678
+ const record = edge.files.get("out", wire);
1679
+ if (!record || transferEnded(record)) continue;
1680
+ let moved = edge.servedMoved.get(wire);
1681
+ if (!moved || moved.confirmed !== record.confirmed) edge.servedMoved.set(wire, moved = {
1682
+ confirmed: record.confirmed,
1683
+ at: now
1684
+ });
1685
+ if (!edge.files.live || !this.deps.membership(edge.groupId)?.inRoster(edge.peer) || now - moved.at >= GROUP_FILE_LIMITS.wantWaitMs) edge.files.cancel("out", wire);
1686
+ }
1687
+ }
1549
1688
  /** Says on an edge which files this device holds. */
1550
1689
  sendHave(edge, ids) {
1551
1690
  for (let i = 0; i < ids.length; i += GROUP_FILE_LIMITS.haveIds) if (!this.deps.send(edge.linkId, {
@@ -1579,9 +1718,11 @@ var GroupFileDesk = class {
1579
1718
  peer,
1580
1719
  files: void 0,
1581
1720
  serving: /* @__PURE__ */ new Map(),
1721
+ servedMoved: /* @__PURE__ */ new Map(),
1582
1722
  taking: /* @__PURE__ */ new Map(),
1583
1723
  have: /* @__PURE__ */ new Set(),
1584
- haveAt: 0
1724
+ haveAt: 0,
1725
+ busy: /* @__PURE__ */ new Set()
1585
1726
  };
1586
1727
  edge.files = new ChatFiles({
1587
1728
  send: (frame) => {
@@ -1615,13 +1756,28 @@ var GroupFileDesk = class {
1615
1756
  const kept = this.kept.get(edge.taking.get(record.id) ?? "");
1616
1757
  if (!kept) throw new Error("No such file");
1617
1758
  await kept.idle;
1618
- const writer = await this.deps.store.write(kept.id);
1759
+ const writer = await this.deps.store.write(kept.id, !kept.fresh);
1760
+ kept.fresh = false;
1761
+ const resumed = writer.offset > 0;
1762
+ let damaged = false;
1619
1763
  return {
1620
- offset: 0,
1764
+ offset: writer.offset,
1621
1765
  append: (bytes) => writer.append(bytes),
1622
1766
  flush: () => writer.flush(),
1623
- verify: async () => await writer.digest() === kept.meta.d,
1624
- discard: () => kept.idle = writer.discard().catch(() => {})
1767
+ verify: async () => {
1768
+ damaged = await writer.digest() !== kept.meta.d;
1769
+ const want = this.wants.get(kept.id);
1770
+ if (damaged && want) want.why = "damaged";
1771
+ return !damaged;
1772
+ },
1773
+ discard: () => {
1774
+ if (damaged) {
1775
+ kept.stored = 0;
1776
+ if (resumed) kept.fresh = kept.unclear = true;
1777
+ else kept.damaged.add(edge.peer);
1778
+ }
1779
+ return kept.idle = (damaged ? writer.discard() : writer.close()).catch(() => {});
1780
+ }
1625
1781
  };
1626
1782
  }
1627
1783
  async openSource(edge, record) {
@@ -1643,6 +1799,12 @@ var GroupFileDesk = class {
1643
1799
  };
1644
1800
  this.served.bytes += record.confirmed;
1645
1801
  edge.serving.delete(record.id);
1802
+ edge.servedMoved.delete(record.id);
1803
+ const serving = this.deps.settings().serveFiles;
1804
+ for (const other of this.edgesById.values()) {
1805
+ if (serving) for (const id of other.busy) other.have.add(id);
1806
+ other.busy.clear();
1807
+ }
1646
1808
  }
1647
1809
  return;
1648
1810
  }
@@ -1652,6 +1814,7 @@ var GroupFileDesk = class {
1652
1814
  want.moved = transferred;
1653
1815
  want.movedAt = this.deps.now();
1654
1816
  }
1817
+ if (record.state !== "failed" && record.state !== "declined" && record.state !== "cancelled") kept.stored = transferred;
1655
1818
  const { size } = kept.meta;
1656
1819
  switch (record.state) {
1657
1820
  case "done":
@@ -1670,12 +1833,14 @@ var GroupFileDesk = class {
1670
1833
  direction: "in"
1671
1834
  });
1672
1835
  this.queueHave(kept);
1836
+ this.retry(kept.groupId);
1673
1837
  return;
1674
1838
  case "failed":
1675
1839
  case "declined":
1676
1840
  case "cancelled":
1677
1841
  edge.taking.delete(record.id);
1678
- if (want.holder) want.tried.add(want.holder);
1842
+ if (want.holder && !kept.unclear) want.tried.add(want.holder);
1843
+ kept.unclear = false;
1679
1844
  Object.assign(want, {
1680
1845
  wire: void 0,
1681
1846
  holder: void 0,
@@ -1683,6 +1848,7 @@ var GroupFileDesk = class {
1683
1848
  moved: 0
1684
1849
  });
1685
1850
  this.ask(kept);
1851
+ this.retry(kept.groupId);
1686
1852
  return;
1687
1853
  case "verifying":
1688
1854
  this.show(kept, {
@@ -1724,6 +1890,10 @@ var GroupFileDesk = class {
1724
1890
  if (!window) windows.set(key, window = new RateWindow(limit, ms, () => this.deps.now()));
1725
1891
  return window;
1726
1892
  }
1893
+ /** My announcements in a group, in a minute and in the window the members count them over. */
1894
+ sentPaces(groupId) {
1895
+ return [this.pace(this.sentPace, groupId, GROUP_FILE_LIMITS.announcePerMinute, 6e4), this.pace(this.sentTaken, groupId, GROUP_FILE_LIMITS.announceTaken, GROUP_FILE_LIMITS.announceWindowMs)];
1896
+ }
1727
1897
  show(kept, view) {
1728
1898
  this.deps.transfers.set(kept.id, view);
1729
1899
  this.deps.changed();
@@ -2283,6 +2453,8 @@ async function sendToGroup(ctx, params, group, path, size) {
2283
2453
  if (replyTo) {
2284
2454
  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
2455
  }
2456
+ const check = await node(ctx).groupFileCheck({ groupId: group.id });
2457
+ if (check.error) throw check.refused ? new CliError("refused", check.error) : groupSendRefused(check.error);
2286
2458
  const { voice, warning } = await voiceOf(params, path, mime);
2287
2459
  const source = await openAsBlob(path, { type: mime });
2288
2460
  const image = voice ? void 0 : await readImageMeta(source, mime);
@@ -2343,6 +2515,20 @@ async function sendToGroup(ctx, params, group, path, size) {
2343
2515
  ...warning ? { warning } : {}
2344
2516
  };
2345
2517
  }
2518
+ /** Bytes a file system takes in one name: 255 on ext4, btrfs, APFS (NTFS counts 255 UTF-16 units, within this too). */
2519
+ var NAME_BYTES = 255;
2520
+ /**
2521
+ * `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
2522
+ * its stem goes until it fits, its extension stays (as `sanitizeFileName` shortens it), so "文…文.txt" is saved as a text.
2523
+ */
2524
+ function fitName(name) {
2525
+ const fits = (candidate) => Buffer.byteLength(candidate) <= NAME_BYTES;
2526
+ if (fits(name)) return name;
2527
+ const extension = /\.[^.\s]{1,15}$/u.exec(name)?.[0] ?? "";
2528
+ const stem = [...name.slice(0, name.length - extension.length)];
2529
+ while (stem.length && !fits(stem.join("").trimEnd() + extension)) stem.pop();
2530
+ return (stem.join("").trimEnd() || "file") + extension;
2531
+ }
2346
2532
  var ACTIONS = [
2347
2533
  "accept",
2348
2534
  "decline",
@@ -2363,6 +2549,7 @@ var FILE_METHODS = {
2363
2549
  throw new CliError("not_found", `No file ${path}`);
2364
2550
  }
2365
2551
  if (!info.isFile()) throw new CliError("bad_request", `${path} is not a file`);
2552
+ await access(path, constants.R_OK);
2366
2553
  if ("group" in target) return sendToGroup(ctx, params, target.group, path, info.size);
2367
2554
  const { link } = target;
2368
2555
  if (link.profile && link.dataLink === "open" && !link.capabilities?.files) throw new CliError("unavailable", "The contact's app takes no files");
@@ -2376,7 +2563,7 @@ var FILE_METHODS = {
2376
2563
  const { voice, warning } = await voiceOf(params, path, mime);
2377
2564
  const source = await openAsBlob(path, { type: mime });
2378
2565
  const image = voice ? void 0 : await readImageMeta(source, mime);
2379
- const wireId = toBase64Url$1(randomBytes$1(12));
2566
+ const wireId = toBase64Url(randomBytes$1(12));
2380
2567
  const file = {
2381
2568
  id: `${link.id}-out-${wireId}`,
2382
2569
  name: sanitizeFileName(str(params, "name") ?? basename(path)),
@@ -2543,7 +2730,7 @@ var FILE_METHODS = {
2543
2730
  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
2731
  const transfer = state(ctx).transfers[fileId] ?? stored.transfer;
2545
2732
  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));
2733
+ const target = str(params, "path") ? resolve(str(params, "path", true)) : join(resolve(str(params, "dir") ?? "."), fitName(sanitizeFileName(stored.metadata.name)));
2547
2734
  const flags = bool(params, "force") ? "w" : "wx";
2548
2735
  let out;
2549
2736
  try {
@@ -2596,7 +2783,7 @@ var DELIVERY_RANK = {
2596
2783
  async function waitForEdit(ctx, chat, messageId, ms) {
2597
2784
  const until = Date.now() + ms;
2598
2785
  for (;;) {
2599
- const message = (await node(ctx).getMessages(chat)).find((m) => m.id === messageId);
2786
+ const message = await node(ctx).getMessage(chat, messageId);
2600
2787
  if (!message) throw new CliError("not_found", `No message ${messageId} in this chat`);
2601
2788
  if (!message.edit?.pending) return message;
2602
2789
  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 +2835,7 @@ async function waitForMessage(ctx, chat, messageId, target, ms) {
2648
2835
  if (error) reject(error);
2649
2836
  else resolve(message);
2650
2837
  };
2651
- const check = (last = false) => void node(ctx).getMessages(chat).then((messages) => {
2652
- const message = messages.find((m) => m.id === messageId);
2838
+ const check = (last = false) => void node(ctx).getMessage(chat, messageId).then((message) => {
2653
2839
  try {
2654
2840
  const done = look(message);
2655
2841
  if (done) finish(null, done);
@@ -2770,7 +2956,7 @@ async function sendCard(ctx, params, kind) {
2770
2956
  const target = chatOrGroup(ctx, str(params, "chat", true));
2771
2957
  const fields = fieldsOf(params);
2772
2958
  const now = Date.now();
2773
- const id = typeof fields.id === "string" && fields.id ? fields.id : `${kind}-${toBase64Url$1(randomBytes$1(6))}`;
2959
+ const id = typeof fields.id === "string" && fields.id ? fields.id : `${kind}-${toBase64Url(randomBytes$1(6))}`;
2774
2960
  const card = checked$1(withRun({
2775
2961
  ...kind === "task" ? {
2776
2962
  status: "running",
@@ -2812,7 +2998,7 @@ async function sendCard(ctx, params, kind) {
2812
2998
  });
2813
2999
  if (result.error) throw new CliError(result.refused ? "refused" : "unavailable", result.error);
2814
3000
  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);
3001
+ let message = await node(ctx).getMessage(target.linkId, result.messageId);
2816
3002
  if (wait !== "none") message = await waitForMessage(ctx, target.linkId, result.messageId, wait, ms);
2817
3003
  return answer(target, kind, id, result.messageId, {
2818
3004
  card,
@@ -2879,7 +3065,7 @@ async function updateCard(ctx, params, kind) {
2879
3065
  pace.last = Date.now();
2880
3066
  await editCard(ctx, target, message.id, card, text);
2881
3067
  }
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);
3068
+ 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);
2883
3069
  if (wait === "confirmed" && edited?.edit?.pending) edited = await waitForEdit(ctx, target.linkId, message.id, ms);
2884
3070
  const edges = wait === "sent" && edited?.edit ? await waitForGroupFrame(ctx, target.id, message.id, edited.edit.seq, ms) : void 0;
2885
3071
  return answer(target, kind, id, message.id, {
@@ -3049,7 +3235,7 @@ function buttonsCard(params) {
3049
3235
  if (!Array.isArray(buttons)) throw new CliError("bad_request", "buttons must be a list of {id, label, style?, once?}");
3050
3236
  return checked({
3051
3237
  kind: "buttons",
3052
- id: str(params, "buttonsId") ?? `ask-${toBase64Url$1(randomBytes$1(6))}`,
3238
+ id: str(params, "buttonsId") ?? `ask-${toBase64Url(randomBytes$1(6))}`,
3053
3239
  buttons
3054
3240
  });
3055
3241
  }
@@ -3104,7 +3290,7 @@ async function updateButtons(ctx, params) {
3104
3290
  card
3105
3291
  });
3106
3292
  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);
3293
+ 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
3294
  if (wait === "confirmed" && edited?.edit?.pending) edited = await waitForEdit(ctx, target.linkId, message.id, ms);
3109
3295
  const edges = wait === "sent" && edited?.edit ? await waitForGroupFrame(ctx, target.id, message.id, edited.edit.seq, ms) : void 0;
3110
3296
  return {
@@ -3167,7 +3353,7 @@ async function pressButton(ctx, params) {
3167
3353
  edges
3168
3354
  };
3169
3355
  }
3170
- const reply = wait === "none" ? (await node(ctx).getMessages(target.linkId)).find((m) => m.id === replyId) : await waitForMessage(ctx, target.linkId, replyId, wait, ms);
3356
+ const reply = wait === "none" ? await node(ctx).getMessage(target.linkId, replyId) : await waitForMessage(ctx, target.linkId, replyId, wait, ms);
3171
3357
  return {
3172
3358
  ...answer,
3173
3359
  delivery: reply?.delivery ?? null
@@ -4135,7 +4321,7 @@ var ResolverUnavailable = class extends DomainCheckError {};
4135
4321
  async function askResolver(resolver, name, type, options) {
4136
4322
  let response;
4137
4323
  try {
4138
- response = await options.fetch(`${resolver.url}?dns=${toBase64Url$1(encodeDnsQuery(name, type))}`, {
4324
+ response = await options.fetch(`${resolver.url}?dns=${toBase64Url(encodeDnsQuery(name, type))}`, {
4139
4325
  headers: { accept: "application/dns-message" },
4140
4326
  maxBytes: DNS_MAX_BYTES,
4141
4327
  signal: options.signal,
@@ -4845,8 +5031,8 @@ async function preparePgpEvidence(input, options = {}) {
4845
5031
  return {
4846
5032
  evidence: {
4847
5033
  scheme: PGP_SCHEME,
4848
- key: toBase64Url$1(await minimalKey(pgp, key, signer, at, config(pgp))),
4849
- signature: toBase64Url$1(signature)
5034
+ key: toBase64Url(await minimalKey(pgp, key, signer, at, config(pgp))),
5035
+ signature: toBase64Url(signature)
4850
5036
  },
4851
5037
  verified: info
4852
5038
  };
@@ -4858,7 +5044,7 @@ function parsePgpEvidence(raw) {
4858
5044
  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
5045
  let canonical;
4860
5046
  try {
4861
- canonical = toBase64Url$1(fromBase64Url$1(e.key)) === e.key && toBase64Url$1(fromBase64Url$1(e.signature)) === e.signature;
5047
+ canonical = toBase64Url(fromBase64Url$1(e.key)) === e.key && toBase64Url(fromBase64Url$1(e.signature)) === e.signature;
4862
5048
  } catch {
4863
5049
  canonical = false;
4864
5050
  }
@@ -5580,7 +5766,7 @@ function segment(value) {
5580
5766
  } catch {
5581
5767
  return refuse("Malformed ID token");
5582
5768
  }
5583
- if (toBase64Url$1(bytes) !== value) refuse("Malformed ID token");
5769
+ if (toBase64Url(bytes) !== value) refuse("Malformed ID token");
5584
5770
  return bytes;
5585
5771
  }
5586
5772
  function json(value) {
@@ -5728,7 +5914,7 @@ async function authorizationRequest(options) {
5728
5914
  if (!clientId) throw new Error(`${provider.name} sign-in is not set up in this Ghostly build yet.`);
5729
5915
  const { nonce } = options;
5730
5916
  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))}`;
5917
+ const state = `${options.statePrefix ?? ""}${toBase64Url(randomBytes$1(32))}`;
5732
5918
  const url = new URL(provider.authorizationEndpoint);
5733
5919
  const { scope, claims } = scopeFor$1(provider, options.reveal);
5734
5920
  const params = {
@@ -5743,8 +5929,8 @@ async function authorizationRequest(options) {
5743
5929
  };
5744
5930
  let verifier;
5745
5931
  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))));
5932
+ verifier = toBase64Url(randomBytes$1(32));
5933
+ params.code_challenge = toBase64Url(new Uint8Array(await crypto.subtle.digest("SHA-256", utf8Encode(verifier))));
5748
5934
  params.code_challenge_method = "S256";
5749
5935
  }
5750
5936
  for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
@@ -6319,7 +6505,7 @@ var signer = {
6319
6505
  return {
6320
6506
  method: "sig",
6321
6507
  vm: check.key.id,
6322
- sig: toBase64Url$1(check.signature)
6508
+ sig: toBase64Url(check.signature)
6323
6509
  };
6324
6510
  }
6325
6511
  };
@@ -7571,7 +7757,7 @@ var exactKeys = (raw, keys) => !!raw && typeof raw === "object" && !Array.isArra
7571
7757
  /** The key behind the fake in-app signer. Tests may replace it. */
7572
7758
  var fakeKeyring = { seed: ed25519.utils.randomSecretKey() };
7573
7759
  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)) });
7760
+ var fakeKeySign = (statement, seed = fakeKeyring.seed) => ({ sig: toBase64Url(ed25519.sign(statement.bytes, seed)) });
7575
7761
  var fakeKey = {
7576
7762
  id: "fake-key",
7577
7763
  label: "Test key",
@@ -7654,13 +7840,13 @@ var ISSUER_SEED = unhex("7a".repeat(32));
7654
7840
  var FAKE_ISSUER = "https://issuer.ghostly.test";
7655
7841
  /** What the fake login page signs: iss, sub, nonce (= statement id), iat. */
7656
7842
  function fakeAccountToken(statement, account = "alice", seed = ISSUER_SEED, iat = statement.binding.issuedAt) {
7657
- const payload = toBase64Url$1(utf8Encode(JSON.stringify({
7843
+ const payload = toBase64Url(utf8Encode(JSON.stringify({
7658
7844
  iss: FAKE_ISSUER,
7659
7845
  sub: account,
7660
7846
  nonce: statement.id,
7661
7847
  iat
7662
7848
  })));
7663
- return { token: `${payload}.${toBase64Url$1(ed25519.sign(utf8Encode(payload), seed))}` };
7849
+ return { token: `${payload}.${toBase64Url(ed25519.sign(utf8Encode(payload), seed))}` };
7664
7850
  }
7665
7851
  var fakeAccount = {
7666
7852
  id: "fake-account",
@@ -8623,12 +8809,6 @@ var SERVICE_METHODS = {
8623
8809
  };
8624
8810
  //#endregion
8625
8811
  //#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
8812
  function fromBase64Url(text) {
8633
8813
  if (!/^[A-Za-z0-9_-]*$/.test(text)) throw new Error("Invalid base64url");
8634
8814
  const binary = atob(text.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat((4 - text.length % 4) % 4));
@@ -12343,14 +12523,19 @@ var METHODS = {
12343
12523
  label
12344
12524
  });
12345
12525
  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(() => {});
12526
+ const progressOf = (s) => s.links.find((l) => l.id === linkId)?.pairingProgress;
12527
+ const progress = await waitForState(ctx, (s) => {
12528
+ const p = progressOf(s);
12529
+ return p && (p.stage !== "publishing" || ctx.mode === "daemon" && p.relayWaitUntil) ? p : void 0;
12530
+ }, 2e4, "the invite to be published").catch(() => progressOf(state(ctx)));
12531
+ const published = !!progress && progress.stage !== "publishing" && progress.reason !== "publish";
12532
+ const waitUntil = published ? void 0 : progress?.relayWaitUntil;
12350
12533
  return {
12351
12534
  chat: linkId,
12352
12535
  invite: inviteCode,
12353
- link: inviteLink(inviteCode)
12536
+ link: inviteLink(inviteCode),
12537
+ published,
12538
+ ...waitUntil !== void 0 && { retryInMs: Math.max(0, waitUntil - Date.now()) }
12354
12539
  };
12355
12540
  },
12356
12541
  async "invite.join"(ctx, params) {
@@ -12412,7 +12597,7 @@ var METHODS = {
12412
12597
  });
12413
12598
  if (result.error) throw new CliError(result.refused ? "refused" : "unavailable", result.error);
12414
12599
  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);
12600
+ let message = await node(ctx).getMessage(link.id, result.messageId);
12416
12601
  if (wait !== "none") message = await waitForMessage(ctx, link.id, result.messageId, wait, num(params, "timeout", 30, {
12417
12602
  min: 1,
12418
12603
  max: 3600
@@ -12448,7 +12633,7 @@ var METHODS = {
12448
12633
  });
12449
12634
  if (result.error) throw editRefused(await node(ctx).getMessages(link.id), messageId, "chat", result.error, result.refused);
12450
12635
  const id = result.messageId ?? messageId;
12451
- let message = (await node(ctx).getMessages(link.id)).find((m) => m.id === id);
12636
+ let message = await node(ctx).getMessage(link.id, id);
12452
12637
  if (wait === "confirmed" && message?.edit?.pending) message = await waitForEdit(ctx, link.id, id, num(params, "timeout", 30, {
12453
12638
  min: 1,
12454
12639
  max: 3600
@@ -13149,6 +13334,229 @@ function connectDaemon(path, timeoutMs = 2e3) {
13149
13334
  });
13150
13335
  }
13151
13336
  //#endregion
13337
+ //#region src/appTemplate.ts
13338
+ var html = (text) => text.replace(/[&<>"]/g, (c) => ({
13339
+ "&": "&amp;",
13340
+ "<": "&lt;",
13341
+ ">": "&gt;",
13342
+ "\"": "&quot;"
13343
+ })[c]);
13344
+ /** `ghostly-app.json`: the manifest without `publisher`, `sequence` and `files`, which `app publish` writes. */
13345
+ function templateManifest({ name, title }) {
13346
+ return {
13347
+ name,
13348
+ version: "0.1.0",
13349
+ kind: "mini-app",
13350
+ title,
13351
+ tagline: "Say hello to a contact",
13352
+ description: "Open it in a chat and your contact gets a card to join. Each side can then say hello to the other.",
13353
+ entry: "index.html",
13354
+ permissions: ["chat"],
13355
+ view: "chat",
13356
+ runtime: {
13357
+ host: ">=1.2",
13358
+ clients: ["web", "desktop"]
13359
+ },
13360
+ license: "MIT"
13361
+ };
13362
+ }
13363
+ /**
13364
+ * `index.html`: the whole app in one file, as the sandbox needs it (no network, so nothing is loaded from elsewhere).
13365
+ * Its script is plain JavaScript typed with JSDoc against `@ghostlytools/sdk/app`, so it runs as written and still type
13366
+ * checks (test/appInit.test.ts does). The title is the only thing put into it, as text. Its type imports are in single
13367
+ * quotes: they are text of the page, not imports of the CLI's own build (test/packageDeps.test.ts reads those).
13368
+ */
13369
+ function templatePage({ title }) {
13370
+ return `<!doctype html>
13371
+ <html lang="en">
13372
+ <head>
13373
+ <meta charset="utf-8">
13374
+ <meta name="viewport" content="width=device-width, initial-scale=1">
13375
+ <title>${html(title)}</title>
13376
+ <style>
13377
+ :root { color-scheme: light dark; --bg: #ffffff; --fg: #16181d; --muted: #5b6270; --line: #d9dde3; --accent: #2f6fed; }
13378
+ :root[data-theme="dark"] { --bg: #16181d; --fg: #f2f4f7; --muted: #a3abb8; --line: #343a45; --accent: #7aa5ff; }
13379
+ body { margin: 0; padding: 20px; background: var(--bg); color: var(--fg); font: 16px/1.4 system-ui, sans-serif; }
13380
+ h1 { margin: 0 0 4px; font-size: 20px; }
13381
+ #status { margin: 0 0 16px; color: var(--muted); }
13382
+ button { font: inherit; padding: 8px 16px; border: 1px solid var(--accent); border-radius: 8px; background: var(--accent); color: var(--bg); }
13383
+ button:disabled { opacity: 0.5; }
13384
+ ul { margin: 16px 0 0; padding: 0; list-style: none; }
13385
+ li { padding: 8px 0; border-top: 1px solid var(--line); }
13386
+ </style>
13387
+ </head>
13388
+ <body>
13389
+ <h1>${html(title)}</h1>
13390
+ <p id="status" role="status">Starting</p>
13391
+ <button id="hello" type="button" disabled>Say hello</button>
13392
+ <ul id="log" aria-label="Messages"></ul>
13393
+ <script type="module">
13394
+ // The types of the API come from the SDK (npm install --save-dev @ghostlytools/sdk); nothing of it runs here.
13395
+ /** @typedef {import('@ghostlytools/sdk/app').MiniAppApi} MiniAppApi */
13396
+ /** @typedef {import('@ghostlytools/sdk/app').MiniAppJson} MiniAppJson */
13397
+
13398
+ /** The longest text shown from a contact's frame. */
13399
+ const MAX_TEXT = 200;
13400
+
13401
+ const status = /** @type {HTMLElement} */ (document.getElementById("status"));
13402
+ const button = /** @type {HTMLButtonElement} */ (document.getElementById("hello"));
13403
+ const log = /** @type {HTMLElement} */ (document.getElementById("log"));
13404
+
13405
+ /** @param {string} text */
13406
+ function show(text) {
13407
+ const line = document.createElement("li");
13408
+ line.textContent = text;
13409
+ log.append(line);
13410
+ }
13411
+
13412
+ /**
13413
+ * What the contact's app sent, when it is a hello. A frame comes from another device: read only what this app sends,
13414
+ * and never put it in the page as HTML.
13415
+ * @param {MiniAppJson} data
13416
+ * @returns {string | null}
13417
+ */
13418
+ function helloOf(data) {
13419
+ if (!data || typeof data !== "object" || Array.isArray(data) || data.type !== "hello") return null;
13420
+ return typeof data.text === "string" && data.text.length <= MAX_TEXT ? data.text : null;
13421
+ }
13422
+
13423
+ /**
13424
+ * Why a send was refused, in words. The code is the rejection's message (MINI_APP_ERROR_CODES in the SDK); a later
13425
+ * client may add one.
13426
+ * @param {unknown} error
13427
+ */
13428
+ function refusal(error) {
13429
+ switch (error instanceof Error ? error.message : "") {
13430
+ case "offline": return "Not sent: you are not both online";
13431
+ case "peer-closed": case "not-open": return "Not sent: your contact has not opened this app";
13432
+ case "not-allowed": return "Open this app in a chat to say hello";
13433
+ default: return "Not sent";
13434
+ }
13435
+ }
13436
+
13437
+ /** @param {MiniAppApi} ghostly */
13438
+ async function start(ghostly) {
13439
+ // Listen before anything is awaited, so no frame is missed.
13440
+ ghostly.chat.on("message", (data) => {
13441
+ const text = helloOf(data);
13442
+ if (text !== null) show("Your contact: " + text);
13443
+ });
13444
+ ghostly.chat.on("peer", (peer) => {
13445
+ status.textContent = peer.open ? "Your contact has this app open" : "Waiting for your contact to open this app";
13446
+ });
13447
+
13448
+ const context = await ghostly.context();
13449
+ const dark = context.theme ? context.theme === "dark" : matchMedia("(prefers-color-scheme: dark)").matches;
13450
+ document.documentElement.dataset.theme = dark ? "dark" : "light";
13451
+ status.textContent = !context.inChat ? "Open this app in a chat to say hello"
13452
+ : context.peer ? "Your contact has this app open" : "Waiting for your contact to open this app";
13453
+
13454
+ button.disabled = false;
13455
+ button.addEventListener("click", async () => {
13456
+ try {
13457
+ await ghostly.chat.send({ type: "hello", text: "Hello!" });
13458
+ show("You: Hello!");
13459
+ } catch (error) {
13460
+ show(refusal(error));
13461
+ }
13462
+ });
13463
+ }
13464
+
13465
+ /** Ghostly's runner gives the app \`window.ghostly\`; a browser tab has none. */
13466
+ const ghostly = /** @type {{ ghostly?: MiniAppApi }} */ (window).ghostly;
13467
+ if (ghostly) void start(ghostly);
13468
+ else status.textContent = "This page is a Ghostly app: publish it and open it in Ghostly";
13469
+ <\/script>
13470
+ </body>
13471
+ </html>
13472
+ `;
13473
+ }
13474
+ /** `README.md`: what the folder holds and the way from it to a store. */
13475
+ function templateReadme({ name, title }) {
13476
+ return `# ${title}
13477
+
13478
+ A [Ghostly](https://github.com/MiguelMedeiros/ghostly) app, started with \`ghostly app init\`. Opened in a 1:1 chat, it
13479
+ gives your contact a card to join, and each side can say hello to the other.
13480
+
13481
+ A Ghostly app ([WISP 1200](https://github.com/MiguelMedeiros/ghostly/blob/dev/docs/wisps/1200-marketplace.md)) is a
13482
+ page that runs in the client's sandbox. It has no network, and talks to Ghostly only through \`window.ghostly\`:
13483
+ frames of at most 32 KiB to the same app on the contact's side (\`chat.send\`, \`chat.on\`), storage per app and per
13484
+ chat, and the files of its own bundle. Apps run in Ghostly from release 1.2.
13485
+
13486
+ | File | What it is |
13487
+ |---|---|
13488
+ | \`ghostly-app.json\` | The manifest, without \`publisher\`, \`sequence\` and \`files\`: \`ghostly app publish\` writes those |
13489
+ | \`index.html\` | The app, whole: styles and script inline |
13490
+ | \`README.md\` | This file |
13491
+
13492
+ ## Write it
13493
+
13494
+ \`index.html\` is plain JavaScript, typed with JSDoc against \`@ghostlytools/sdk/app\`: \`MiniAppApi\` is
13495
+ \`window.ghostly\`, and the same module has the limits (\`MINI_APP_LIMITS\`) and the codes a refused call rejects with
13496
+ (\`MINI_APP_ERROR_CODES\`). For the types in your editor:
13497
+
13498
+ \`\`\`sh
13499
+ npm install --save-dev @ghostlytools/sdk
13500
+ \`\`\`
13501
+
13502
+ - What a contact's app sends is untrusted: check its shape, bound its size, and show it as text, never as HTML.
13503
+ - \`"view": "chat"\` runs the app inside a chat with one contact. \`"full"\` is a full-screen app opened from the Apps page.
13504
+ - \`permissions\` lists what the person is asked for: \`chat\` (frames to the contact's app), \`name\` (the person's
13505
+ display name) and \`internet\`. Adding one later makes every install ask before it updates.
13506
+
13507
+ ## Build
13508
+
13509
+ There is nothing to build yet: the page is the app. Once it grows into several source files, or TypeScript, build it
13510
+ into one self-contained \`index.html\` (scripts, styles and images inline) and put that file and \`ghostly-app.json\`
13511
+ alone in a folder of their own, which is the folder you publish.
13512
+ [ghostly-chess](https://github.com/MiguelMedeiros/ghostly-chess) does it with Vite and a small
13513
+ \`scripts/stage.mjs\`. \`ghostly app publish\` takes every file of the folder it is given but dot files, this README
13514
+ included, so do not run it on a repository with sources, \`node_modules\` or anything private in it.
13515
+
13516
+ ## Publish
13517
+
13518
+ \`\`\`sh
13519
+ ghostly app publish . --key ~/ghostly-keys/${name}-publisher.key
13520
+ ghostly app verify app.ghostlyapp
13521
+ \`\`\`
13522
+
13523
+ \`publish\` signs the folder into \`app.ghostlyapp\`. The first run makes the publisher key at the path you name:
13524
+
13525
+ - Keep it **outside this folder** and its repository (\`publish\` refuses a key inside it), and never commit it.
13526
+ - Back it up. An app is updated only with the key that signed it; a lost key means a new app.
13527
+
13528
+ \`verify\` checks the bundle as Ghostly will and prints its \`ref\` (\`<publisher key>/${name}\`), \`sequence\` and
13529
+ \`digest\`. For the next version, raise \`version\` in \`ghostly-app.json\` and publish again: the sequence goes up
13530
+ by one by itself.
13531
+
13532
+ ## Try it
13533
+
13534
+ Commit \`app.ghostlyapp\` to a public GitHub repository, then in Ghostly open Apps, choose Add, and paste its URL
13535
+ (\`https://raw.githubusercontent.com/<owner>/<repo>/HEAD/app.ghostlyapp\`). Set \`sources\` in \`ghostly-app.json\`
13536
+ to that URL, in a list, so installs made from it find your updates. Open the app from a chat's + menu; your contact
13537
+ gets a card to install and open it.
13538
+
13539
+ ## List it in a store
13540
+
13541
+ A store is a signed list of apps. The official one is a Git repository,
13542
+ [ghostly-store](https://github.com/MiguelMedeiros/ghostly-store), and apps enter it by pull request:
13543
+
13544
+ 1. Commit \`app.ghostlyapp\` to your public repository and note that commit.
13545
+ 2. Add \`apps/${name}.<first 16 characters of your publisher key>/listing.json\` with the \`ref\`, \`sequence\`
13546
+ and \`digest\` that \`ghostly app verify\` printed. Its \`urls\` start with a jsDelivr URL pinned to the full commit
13547
+ (\`https://cdn.jsdelivr.net/gh/<owner>/<repo>@<40-character commit>/app.ghostlyapp\`).
13548
+ 3. Open the pull request. Its check verifies the bundle; a maintainer reviews the app and the owner signs the index.
13549
+
13550
+ The store's [CONTRIBUTING](https://github.com/MiguelMedeiros/ghostly-store/blob/main/CONTRIBUTING.md) has the rules.
13551
+ An app installed from a store updates only to the version that store lists, so each new version is a pull request too.
13552
+
13553
+ ## If something goes wrong
13554
+
13555
+ If a version is harmful, \`ghostly app revoke . --key <your key> --up-to <sequence>\` writes \`ghostly-revoke.json\`;
13556
+ publish it beside \`app.ghostlyapp\` and Ghostly stops those versions.
13557
+ `;
13558
+ }
13559
+ //#endregion
13152
13560
  //#region src/apps.ts
13153
13561
  var APP_SOURCE = "ghostly-app.json";
13154
13562
  var APP_REVOKE_FILE = "ghostly-revoke.json";
@@ -13316,7 +13724,7 @@ function described(bundle, bytes) {
13316
13724
  sequence: m.sequence,
13317
13725
  digest: bundle.digest,
13318
13726
  bytes: bytes.length,
13319
- sha256: toBase64Url$1(createHash("sha256").update(bytes).digest())
13727
+ sha256: toBase64Url(createHash("sha256").update(bytes).digest())
13320
13728
  };
13321
13729
  }
13322
13730
  /** `app verify <bundle|url>`: every check a client makes before it stores a bundle (WISP 1200 · Reading a bundle). */
@@ -13330,6 +13738,70 @@ async function verifyApp(source) {
13330
13738
  manifest: read.bundle.manifest
13331
13739
  };
13332
13740
  }
13741
+ /** A folder's name as an app's: lower case, anything else a hyphen (`My App` is `my-app`). */
13742
+ var nameFrom = (folder) => folder.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
13743
+ /** An app's name as a title: `my-app` is `My app`. */
13744
+ var titleFrom = (name) => (name[0].toUpperCase() + name.slice(1)).replace(/-/g, " ");
13745
+ /**
13746
+ * `app init <dir> [--name <name>] [--title <title>] [--force]`: a small working app in `<dir>` (made when missing),
13747
+ * which `app publish <dir>` signs as it is: `ghostly-app.json`, an `index.html` that says hello to the contact, and a
13748
+ * README with the way to a store (./appTemplate.ts). The name is `--name`, else the folder's; the title `--title`, else
13749
+ * the name's. Nothing is written when one of the three files is there, unless `--force`, which replaces those three only.
13750
+ */
13751
+ async function initApp(dirArg, what = {}) {
13752
+ const usage = "ghostly app init <dir> [--name <name>] [--title <title>] [--force]";
13753
+ const dir = resolve(dirArg);
13754
+ const name = what.name ?? nameFrom(basename(dir));
13755
+ const title = what.title ?? (name ? titleFrom(name) : "");
13756
+ const page = templatePage({
13757
+ name,
13758
+ title
13759
+ });
13760
+ const pageBytes = new TextEncoder().encode(page);
13761
+ const checked = checkAppManifest({
13762
+ ...templateManifest({
13763
+ name,
13764
+ title
13765
+ }),
13766
+ ghostlyApp: 1,
13767
+ publisher: toZ32(/* @__PURE__ */ new Uint8Array(32)),
13768
+ sequence: 1,
13769
+ files: [{
13770
+ path: "index.html",
13771
+ size: pageBytes.length,
13772
+ sha256: toBase64Url(createHash("sha256").update(pageBytes).digest())
13773
+ }]
13774
+ }, { strict: true });
13775
+ if (!checked.ok) {
13776
+ 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)}`);
13777
+ 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)}`);
13778
+ throw refused("The template makes no valid manifest", checked);
13779
+ }
13780
+ if (existsSync(dir) && !statSync(dir).isDirectory()) throw new CliError("bad_request", `${dir} is a file: name a folder for the app`);
13781
+ const files = [
13782
+ [APP_SOURCE, `${JSON.stringify(templateManifest({
13783
+ name,
13784
+ title
13785
+ }), null, 2)}\n`],
13786
+ ["index.html", page],
13787
+ ["README.md", templateReadme({
13788
+ name,
13789
+ title
13790
+ })]
13791
+ ];
13792
+ const held = files.map(([path]) => path).filter((path) => existsSync(join(dir, path)));
13793
+ 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 });
13794
+ mkdirSync(dir, { recursive: true });
13795
+ for (const [path, text] of files) writeWhole(join(dir, path), new TextEncoder().encode(text));
13796
+ return {
13797
+ dir,
13798
+ name,
13799
+ title,
13800
+ files: files.map(([path]) => path),
13801
+ ...held.length ? { replaced: held } : {},
13802
+ next: `ghostly app publish ${dirArg} --key <a key file outside ${dirArg}>`
13803
+ };
13804
+ }
13333
13805
  /** Every file of the folder, as the bundle names it; dot files and folders are left out and listed. */
13334
13806
  function collectFiles(dir, leaveOut) {
13335
13807
  const files = [];
@@ -13368,11 +13840,22 @@ function collectFiles(dir, leaveOut) {
13368
13840
  }
13369
13841
  };
13370
13842
  walk(dir);
13843
+ files.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
13371
13844
  return {
13372
13845
  files,
13373
13846
  skipped
13374
13847
  };
13375
13848
  }
13849
+ /**
13850
+ * A private key in a file: a PEM private key block with its body (PKCS #8, OpenSSH, RSA, EC, encrypted), or a Ghostly
13851
+ * key file's first line. A library that only names the block's header in its code does not match.
13852
+ */
13853
+ 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\)/;
13854
+ /** Whether `path` is `dir` or anything under it. */
13855
+ var isInside = (dir, path) => {
13856
+ const rel = relative(dir, path);
13857
+ return !rel.startsWith("..") && !isAbsolute(rel);
13858
+ };
13376
13859
  /** Fields of `ghostly-app.json` the command writes itself. */
13377
13860
  var WRITTEN = ["publisher", "files"];
13378
13861
  function readSource(path) {
@@ -13412,6 +13895,7 @@ async function publishApp(dirArg, keyArg, outArg, sequenceArg) {
13412
13895
  const dir = resolve(dirArg);
13413
13896
  if (!statSync(dir).isDirectory()) throw new CliError("bad_request", `${dir} is not a folder`);
13414
13897
  const keyPath = resolve(keyArg);
13898
+ 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
13899
  const out = resolve(outArg ?? join(dir, "app.ghostlyapp"));
13416
13900
  const sourcePath = join(dir, APP_SOURCE);
13417
13901
  const draft = readSource(sourcePath);
@@ -13438,6 +13922,11 @@ async function publishApp(dirArg, keyArg, outArg, sequenceArg) {
13438
13922
  path: f.path,
13439
13923
  bytes: new Uint8Array(readFileSync(f.full))
13440
13924
  }));
13925
+ const keyFile = contents.find((f) => PRIVATE_KEY.test(Buffer.from(f.bytes).toString("latin1")));
13926
+ 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`, {
13927
+ reason: "private-key",
13928
+ detail: keyFile.path
13929
+ });
13441
13930
  const manifest = {
13442
13931
  ...draft,
13443
13932
  ghostlyApp: 1,
@@ -13446,10 +13935,10 @@ async function publishApp(dirArg, keyArg, outArg, sequenceArg) {
13446
13935
  files: contents.map((f) => ({
13447
13936
  path: f.path,
13448
13937
  size: f.bytes.length,
13449
- sha256: toBase64Url$1(createHash("sha256").update(f.bytes).digest())
13938
+ sha256: toBase64Url(createHash("sha256").update(f.bytes).digest())
13450
13939
  }))
13451
13940
  };
13452
- const checked = checkAppManifest(JSON.parse(JSON.stringify(manifest)));
13941
+ const checked = checkAppManifest(JSON.parse(JSON.stringify(manifest)), { strict: true });
13453
13942
  if (!checked.ok) throw refused(`${sourcePath} and the folder's files make no valid manifest`, checked);
13454
13943
  const { bytes: manifestBytes, signature } = await signAppObject(APP_PREFIXES.app, checked.manifest, key.signer);
13455
13944
  const bundle = assembleAppBundle(manifestBytes, canonicalJsonBytes(signature), contents.map((f) => f.bytes));
@@ -13564,7 +14053,7 @@ async function signStore(indexArg, keyArg, outArg, now = Math.floor(Date.now() /
13564
14053
  else key = makeKey(keyPath, "store");
13565
14054
  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
14055
  index.key = key.key;
13567
- const checked = checkAppStoreIndex(index);
14056
+ const checked = checkAppStoreIndex(index, { strict: true });
13568
14057
  if (!checked.ok) throw refused(`${indexPath} is not a valid store index`, checked);
13569
14058
  const { indexBytes, sigBytes } = await signAppStore(index, key.signer);
13570
14059
  const read = readAppStore(indexBytes, sigBytes, now, key.key);
@@ -13627,6 +14116,31 @@ var key = {
13627
14116
  * per command, in alphabetical order (test/commands.test.ts checks).
13628
14117
  */
13629
14118
  var commands$8 = {
14119
+ "app init": {
14120
+ method: "app.init",
14121
+ usage: "app init <dir> [--name <name>] [--title <title>] [--force]",
14122
+ 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",
14123
+ args: ["dir"],
14124
+ options: {
14125
+ name: {
14126
+ type: "string",
14127
+ description: "The app's name, lowercase letters, digits and hyphens (default: the folder's)"
14128
+ },
14129
+ title: {
14130
+ type: "string",
14131
+ description: "The title people see, up to 40 characters (default: from the name)"
14132
+ },
14133
+ force: {
14134
+ type: "boolean",
14135
+ description: "Replace the three files when they are there"
14136
+ }
14137
+ },
14138
+ run: ({ options }, a) => initApp(a.dir, {
14139
+ name: options.name,
14140
+ title: options.title,
14141
+ force: options.force === true
14142
+ })
14143
+ },
13630
14144
  "app publish": {
13631
14145
  method: "app.publish",
13632
14146
  usage: "app publish <dir> --key <file> [--out <file>]",
@@ -14142,7 +14656,7 @@ var commands$7 = {
14142
14656
  method: "call.answer",
14143
14657
  usage: "call answer [<chat|call>] [--rate 48000]",
14144
14658
  summary: "Answer a call that rings (the only one, or the one named)",
14145
- args: ["call..."],
14659
+ args: ["call...?"],
14146
14660
  options: { rate },
14147
14661
  params: ({ options }, a) => ({
14148
14662
  call: a.call,
@@ -14153,7 +14667,7 @@ var commands$7 = {
14153
14667
  method: "call.auto",
14154
14668
  usage: "call auto [on|off] [--from <chat>]... [--rate 48000]",
14155
14669
  summary: "Answer calls by themselves: from anyone, or the chats named (kept in the profile)",
14156
- args: ["mode..."],
14670
+ args: ["mode...?"],
14157
14671
  options: {
14158
14672
  from: {
14159
14673
  type: "list",
@@ -14175,14 +14689,14 @@ var commands$7 = {
14175
14689
  method: "call.flush",
14176
14690
  usage: "call flush [<chat|call>]",
14177
14691
  summary: "Drop the audio queued and not played yet (barge-in)",
14178
- args: ["call..."],
14692
+ args: ["call...?"],
14179
14693
  params: (_, a) => ({ call: a.call })
14180
14694
  },
14181
14695
  "call hangup": {
14182
14696
  method: "call.hangup",
14183
14697
  usage: "call hangup [<chat|call>]",
14184
14698
  summary: "Hang up, or decline a call that rings",
14185
- args: ["call..."],
14699
+ args: ["call...?"],
14186
14700
  params: (_, a) => ({ call: a.call })
14187
14701
  },
14188
14702
  "call list": {
@@ -14336,7 +14850,7 @@ var commands$6 = {
14336
14850
  method: "chat.rename",
14337
14851
  usage: "chat rename <chat> <name...>",
14338
14852
  summary: "Name a chat on this side (empty: the contact's name)",
14339
- args: ["chat", "name..."],
14853
+ args: ["chat", "name...?"],
14340
14854
  params: (_, { chat, name }) => ({
14341
14855
  chat,
14342
14856
  name: name ?? ""
@@ -14414,14 +14928,17 @@ var commands$6 = {
14414
14928
  },
14415
14929
  timeout: groupWait.timeout
14416
14930
  },
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
- })
14931
+ params: ({ options }, a) => {
14932
+ 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]");
14933
+ return {
14934
+ chat: a.chat,
14935
+ messages: (a.message ?? "").split(" ").filter(Boolean),
14936
+ to: options.to,
14937
+ force: options.force === true,
14938
+ wait: options.wait,
14939
+ timeout: options.timeout
14940
+ };
14941
+ }
14425
14942
  },
14426
14943
  "invite create": {
14427
14944
  method: "invite.create",
@@ -14499,7 +15016,7 @@ var commands$6 = {
14499
15016
  args: [
14500
15017
  "chat",
14501
15018
  "message",
14502
- "emoji..."
15019
+ "emoji...?"
14503
15020
  ],
14504
15021
  options: { remove: {
14505
15022
  type: "boolean",
@@ -14849,15 +15366,18 @@ var commands$4 = {
14849
15366
  method: "profile.picture",
14850
15367
  usage: "profile picture <jpeg> | --clear",
14851
15368
  summary: "The picture contacts see (a square JPEG, 128 px is what the app sends)",
14852
- args: ["path..."],
15369
+ args: ["path...?"],
14853
15370
  options: { clear: {
14854
15371
  type: "boolean",
14855
15372
  description: "Remove the picture"
14856
15373
  } },
14857
- params: ({ options }, a) => ({
14858
- path: here(a.path),
14859
- clear: options.clear === true
14860
- })
15374
+ params: ({ options }, a) => {
15375
+ if (a.path === void 0 && options.clear !== true) throw new CliError("usage", "Missing <jpeg>: ghostly profile picture <jpeg> | --clear");
15376
+ return {
15377
+ path: here(a.path),
15378
+ clear: options.clear === true
15379
+ };
15380
+ }
14861
15381
  },
14862
15382
  "status": {
14863
15383
  method: "status",
@@ -15034,16 +15554,19 @@ var commands$3 = {
15034
15554
  method: "group.picture",
15035
15555
  usage: "group picture <group> <jpeg> | --clear",
15036
15556
  summary: "Set or clear the group's picture (admin; a square JPEG)",
15037
- args: ["group", "path..."],
15557
+ args: ["group", "path...?"],
15038
15558
  options: { clear: {
15039
15559
  type: "boolean",
15040
15560
  description: "Remove the picture"
15041
15561
  } },
15042
- params: ({ options }, a) => ({
15043
- group: a.group,
15044
- path: here(a.path),
15045
- clear: options.clear === true
15046
- })
15562
+ params: ({ options }, a) => {
15563
+ if (a.path === void 0 && options.clear !== true) throw new CliError("usage", "Missing <jpeg>: ghostly group picture <group> <jpeg> | --clear");
15564
+ return {
15565
+ group: a.group,
15566
+ path: here(a.path),
15567
+ clear: options.clear === true
15568
+ };
15569
+ }
15047
15570
  },
15048
15571
  "group react": {
15049
15572
  method: "group.react",
@@ -15052,7 +15575,7 @@ var commands$3 = {
15052
15575
  args: [
15053
15576
  "group",
15054
15577
  "message",
15055
- "emoji..."
15578
+ "emoji...?"
15056
15579
  ],
15057
15580
  options: { remove: {
15058
15581
  type: "boolean",
@@ -15662,7 +16185,7 @@ var TEXT_COMMANDS = {
15662
16185
  "send": {
15663
16186
  method: "chat.send",
15664
16187
  target: "chat",
15665
- args: ["chat", "text..."],
16188
+ args: ["chat", "text...?"],
15666
16189
  usage: "send <chat> [text...] [--reply <message>] [--button id:Label]... [--style id=primary|neutral|danger]... [--once] [--id id] [--stdin] [--force] [--wait none|sent|delivered]",
15667
16190
  summary: "Send a message (text from arguments or stdin); --button puts buttons under it, each label its own (case aside)",
15668
16191
  options: {
@@ -15683,7 +16206,7 @@ var TEXT_COMMANDS = {
15683
16206
  args: [
15684
16207
  "chat",
15685
16208
  "message",
15686
- "text..."
16209
+ "text...?"
15687
16210
  ],
15688
16211
  usage: "edit <chat> <message> [text... | --text <text> | --stdin] [--force] [--wait none|confirmed]",
15689
16212
  summary: "Replace the text of a message you sent (the id send gave; in a group, group edit)",
@@ -15707,7 +16230,7 @@ var TEXT_COMMANDS = {
15707
16230
  "group send": {
15708
16231
  method: "group.send",
15709
16232
  target: "group",
15710
- args: ["group", "text..."],
16233
+ args: ["group", "text...?"],
15711
16234
  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
16235
  summary: "Send to a group; mention members written as @name in the text; --button puts buttons under it",
15713
16236
  options: {
@@ -15732,7 +16255,7 @@ var TEXT_COMMANDS = {
15732
16255
  args: [
15733
16256
  "group",
15734
16257
  "message",
15735
- "text..."
16258
+ "text...?"
15736
16259
  ],
15737
16260
  usage: "group edit <group> <message> [text... | --text <text> | --stdin] [--mention <member>]... [--force] [--wait none|sent] [--timeout s]",
15738
16261
  summary: "Replace the text of a message you sent to a group (the id group send gave)",
@@ -15773,13 +16296,15 @@ var ID_ARGS = /* @__PURE__ */ new Set([
15773
16296
  "service",
15774
16297
  "call"
15775
16298
  ]);
16299
+ /** A leading positional that may be left out (`name?`); a trailing one that may (`text...?`) shifts nothing. */
16300
+ var leadingOptional = (name) => name.endsWith("?") && !name.endsWith("...?");
15776
16301
  /**
15777
16302
  * Which of a command's positionals take an id, by index (a trailing `...` one covers the rest). An optional `name?`
15778
16303
  * shifts the others, so it counts only where it and what may come in its place are all ids.
15779
16304
  */
15780
16305
  function idSlot(command) {
15781
16306
  const names = (command.args ?? []).map((name) => name.replace(/\?$/, ""));
15782
- const shift = (command.args ?? []).filter((name) => name.endsWith("?")).length;
16307
+ const shift = (command.args ?? []).filter(leadingOptional).length;
15783
16308
  const rest = names.findIndex((name) => name.endsWith("..."));
15784
16309
  const at = (index) => {
15785
16310
  const name = rest !== -1 && index >= rest ? names[rest].slice(0, -3) : names[index];
@@ -15789,29 +16314,36 @@ function idSlot(command) {
15789
16314
  }
15790
16315
  /**
15791
16316
  * 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>`).
16317
+ * is given only when every required one is there too (`file accept [<chat>] <file>`). A trailing `name...` takes the
16318
+ * rest and needs at least one word; `name...?` may take none (text from stdin, `chat rename`'s name back to the
16319
+ * contact's).
15793
16320
  */
15794
16321
  function positionals(command, values) {
15795
16322
  let names = command.args ?? [];
15796
- const optional = names.filter((name) => name.endsWith("?"));
16323
+ const optional = names.filter(leadingOptional);
15797
16324
  if (optional.length) {
15798
16325
  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("?"))];
16326
+ names = [...optional.slice(skip).map((name) => name.slice(0, -1)), ...names.filter((name) => !leadingOptional(name))];
15800
16327
  }
15801
16328
  const out = {};
15802
16329
  for (let i = 0; i < names.length; i++) {
15803
- const name = names[i];
16330
+ const name = names[i].replace(/\?$/, "");
15804
16331
  if (name.endsWith("...")) {
15805
16332
  const rest = values.slice(i);
16333
+ if (!rest.length && !names[i].endsWith("?")) throw new CliError("usage", `Missing <${name.slice(0, -3)}>: ghostly ${command.usage}`);
15806
16334
  out[name.slice(0, -3)] = rest.length ? rest.join(" ") : void 0;
15807
16335
  return out;
15808
16336
  }
15809
16337
  if (values[i] === void 0) throw new CliError("usage", `Missing <${name}>: ghostly ${command.usage}`);
15810
16338
  out[name] = values[i];
15811
16339
  }
15812
- if (values.length > names.length) throw new CliError("usage", `Too many arguments: ghostly ${command.usage}`);
16340
+ noMoreThan(names.length, values, command.usage);
15813
16341
  return out;
15814
16342
  }
16343
+ /** Words past a usage's arguments are refused, never dropped: `profile use my bot` would select `my`. */
16344
+ function noMoreThan(count, values, usage) {
16345
+ if (values.length > count) throw new CliError("usage", `Too many arguments: ghostly ${usage}`);
16346
+ }
15815
16347
  //#endregion
15816
16348
  //#region src/announce.ts
15817
16349
  /**
@@ -15955,6 +16487,19 @@ function audioSocketPath(profileDir, callId) {
15955
16487
  return join("/tmp", `ghostly-calls-${createHash("sha256").update(profileDir).digest("hex").slice(0, 24)}`, `${callId}.sock`);
15956
16488
  }
15957
16489
  /**
16490
+ * Removes the call sockets a daemon that died during a call left in this profile's folder (`audioSocketPath`; call
16491
+ * ids are 12 hex digits): no call outlives its daemon. A folder that is a link or someone else's is left alone.
16492
+ */
16493
+ function clearAudioSockets(profileDir) {
16494
+ if (process.platform === "win32") return;
16495
+ const folder = dirname(audioSocketPath(profileDir, "0".repeat(12)));
16496
+ try {
16497
+ const info = lstatSync(folder);
16498
+ if (!info.isDirectory() || process.getuid && info.uid !== process.getuid()) return;
16499
+ for (const entry of readdirSync(folder, { withFileTypes: true })) if (entry.name.endsWith(".sock") && !entry.isDirectory()) rmSync(join(folder, entry.name), { force: true });
16500
+ } catch {}
16501
+ }
16502
+ /**
15958
16503
  * Serves one call's audio to one program at a time: what the program writes is handed to `onAudio` as it comes (raw
15959
16504
  * PCM, any amount); `write` gives it the caller's audio. A second connection replaces the first. `close` ends the
15960
16505
  * program's connection (it reads EOF) and removes the socket.
@@ -16137,8 +16682,6 @@ var ReorderBuffer = class {
16137
16682
  }
16138
16683
  }
16139
16684
  };
16140
- //#endregion
16141
- //#region src/calls/audio.ts
16142
16685
  /**
16143
16686
  * One call's audio: every 20 ms a frame from the queue (the program's audio, or silence) is encoded and sent; what
16144
16687
  * 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
@@ -16161,6 +16704,8 @@ var CallAudio = class {
16161
16704
  /** Frames sent and received, for `call list`. */
16162
16705
  sent = 0;
16163
16706
  received = 0;
16707
+ /** When the last packet, RTP or RTCP, came from the contact (Date.now(); 0: none yet), for the call's watch. */
16708
+ heardAt = 0;
16164
16709
  constructor(options) {
16165
16710
  this.options = options;
16166
16711
  const now = options.now ?? Date.now;
@@ -16202,7 +16747,9 @@ var CallAudio = class {
16202
16747
  }
16203
16748
  /** Handles one packet from the track. Exposed for tests. */
16204
16749
  receive(packet) {
16205
- if (this.stopped || isRtcp(packet)) return;
16750
+ if (this.stopped) return;
16751
+ this.heardAt = (this.options.now ?? Date.now)();
16752
+ if (isRtcp(packet)) return;
16206
16753
  const rtp = parseRtp(packet);
16207
16754
  if (!rtp || rtp.payloadType !== this.options.payloadType) return;
16208
16755
  for (const item of this.reorder.push(rtp)) {
@@ -16231,7 +16778,7 @@ var CallAudio = class {
16231
16778
  const due = this.startedAt + (this.ticks + 1) * 20;
16232
16779
  this.timer = setTimeout(() => {
16233
16780
  if (this.stopped) return;
16234
- if (now() - due > 100) {
16781
+ if (now() - due > 1e3) {
16235
16782
  this.startedAt = now();
16236
16783
  this.ticks = 0;
16237
16784
  } else this.ticks++;
@@ -16524,6 +17071,7 @@ var CallManager = class {
16524
17071
  this.host = host;
16525
17072
  this.configPath = join(host.profileDir, "calls.json");
16526
17073
  this.auto = readAuto(this.configPath);
17074
+ if (host.answers !== false) clearAudioSockets(host.profileDir);
16527
17075
  }
16528
17076
  get now() {
16529
17077
  return (this.host.now ?? Date.now)();
@@ -16808,6 +17356,9 @@ var CallManager = class {
16808
17356
  startedAt: this.now,
16809
17357
  connectedAt: null,
16810
17358
  timer: null,
17359
+ watcher: null,
17360
+ stalledFrom: null,
17361
+ stalls: 0,
16811
17362
  ended: false
16812
17363
  };
16813
17364
  this.calls.set(call.id, call);
@@ -16867,12 +17418,58 @@ var CallManager = class {
16867
17418
  direction: call.direction,
16868
17419
  audio: this.audioInfo(call)
16869
17420
  });
17421
+ this.watch(call);
17422
+ }
17423
+ /**
17424
+ * libjuice says nothing of a contact whose side went away (a crash, a closed lid, a lost network) until its consent
17425
+ * checks fail, about 30 s later: a connected call watches the contact's packets instead. An app sends them all along
17426
+ * (silence while muted, and RTCP), and so does the CLI. Nothing for STALL_MS: `call.stalled`; packets again:
17427
+ * `call.resumed`; nothing for STALL_END_MS: the call ends as `failed`, and the contact's app is told.
17428
+ */
17429
+ watch(call) {
17430
+ const { afterMs, endMs } = this.host.stall ?? {
17431
+ afterMs: 5e3,
17432
+ endMs: 15e3
17433
+ };
17434
+ const since = Date.now();
17435
+ call.watcher = setInterval(() => {
17436
+ if (call.ended) return;
17437
+ const heardAt = Math.max(since, call.media?.audio.heardAt ?? 0);
17438
+ const silentMs = Date.now() - heardAt;
17439
+ const fields = {
17440
+ call: call.id,
17441
+ chat: call.chat,
17442
+ direction: call.direction
17443
+ };
17444
+ if (silentMs >= endMs) {
17445
+ process.stderr.write(`ghostly: call ${call.id}: nothing heard from the contact for ${Math.round(silentMs / 1e3)} s\n`);
17446
+ this.end(call, "failed", true);
17447
+ } else if (silentMs >= afterMs && call.stalledFrom === null) {
17448
+ call.stalledFrom = heardAt;
17449
+ call.stalls++;
17450
+ process.stderr.write(`ghostly: call ${call.id}: stalled, nothing heard from the contact for ${silentMs} ms\n`);
17451
+ this.host.emit("call.stalled", `call.stalled:${call.id}:${call.stalls}`, {
17452
+ ...fields,
17453
+ silentMs
17454
+ });
17455
+ } else if (silentMs < afterMs && call.stalledFrom !== null) {
17456
+ const gapMs = heardAt - call.stalledFrom;
17457
+ call.stalledFrom = null;
17458
+ process.stderr.write(`ghostly: call ${call.id}: resumed after ${gapMs} ms\n`);
17459
+ this.host.emit("call.resumed", `call.resumed:${call.id}:${call.stalls}`, {
17460
+ ...fields,
17461
+ silentMs: gapMs
17462
+ });
17463
+ }
17464
+ }, Math.min(1e3, afterMs / 5));
17465
+ call.watcher.unref?.();
16870
17466
  }
16871
17467
  async end(call, reason, tell, report = true) {
16872
17468
  if (call.ended) return;
16873
17469
  call.ended = true;
16874
17470
  call.reason = reason;
16875
17471
  this.disarm(call);
17472
+ if (call.watcher) clearInterval(call.watcher);
16876
17473
  this.calls.delete(call.id);
16877
17474
  if (report) process.stderr.write(`ghostly: call ${call.id}: ended (${reason})${call.media ? `, ice ${call.media.ice.state}` : ""}\n`);
16878
17475
  call.media?.close();
@@ -16943,6 +17540,7 @@ var CallManager = class {
16943
17540
  chat: call.chat,
16944
17541
  direction: call.direction,
16945
17542
  state: call.state,
17543
+ stalled: call.stalledFrom !== null,
16946
17544
  video: call.video,
16947
17545
  startedAt: call.startedAt,
16948
17546
  connectedAt: call.connectedAt,
@@ -18428,10 +19026,11 @@ async function openPersistentIndexedDb(dir) {
18428
19026
  const pending = /* @__PURE__ */ new WeakMap();
18429
19027
  const snapshotPath = join(dir, SNAPSHOT);
18430
19028
  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));
19029
+ const snapshotFound = existsSync(snapshotPath), journalFound = existsSync(journalPath);
19030
+ if (snapshotFound) restore(classes, raw._databases, deserialize(readFileSync(snapshotPath)), (a, b) => factory.cmp(a, b));
19031
+ if (journalFound) replay(raw._databases, readFileSync(journalPath));
18433
19032
  restoring = false;
18434
- await writeSnapshot();
19033
+ if (journalFound || !snapshotFound) await writeSnapshot();
18435
19034
  const storeProto = classes.ObjectStore.prototype;
18436
19035
  const storeRecord = storeProto.storeRecord;
18437
19036
  const deleteRecord = storeProto.deleteRecord;
@@ -18614,7 +19213,12 @@ async function probeClasses(factory) {
18614
19213
  });
18615
19214
  return classes;
18616
19215
  }
18617
- function restore(classes, databases, snapshot) {
19216
+ /**
19217
+ * The snapshot's records come in key order (as `writeSnapshot` walks them), so each store's tree and each index's are
19218
+ * built whole from sorted arrays: storing record by record compared every key down the tree, a 70,000-message profile
19219
+ * took seconds. A snapshot not in order (never written so) is stored record by record.
19220
+ */
19221
+ function restore(classes, databases, snapshot, cmp) {
18618
19222
  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
19223
  for (const saved of snapshot.databases) {
18620
19224
  const db = new classes.Database(saved.name, saved.version);
@@ -18627,16 +19231,103 @@ function restore(classes, databases, snapshot) {
18627
19231
  store.rawIndexes.set(i.name, index);
18628
19232
  }
18629
19233
  db.rawObjectStores.set(s.name, store);
18630
- for (const [key, value] of s.records) store.storeRecord({
19234
+ const records = s.records.map(([key, value]) => ({
18631
19235
  key,
18632
19236
  value: blobsFromDisk(value)
18633
- }, false);
19237
+ }));
19238
+ if (records.some((record, at) => at > 0 && cmp(records[at - 1].key, record.key) >= 0)) {
19239
+ for (const record of records) store.storeRecord(record, false);
19240
+ continue;
19241
+ }
19242
+ fill(store.records, records);
19243
+ for (const index of store.rawIndexes.values()) fill(index.records, indexEntries(index, records, cmp));
18634
19244
  }
18635
19245
  databases.set(saved.name, db);
18636
19246
  }
18637
19247
  }
18638
- /** Replays whole entries; a torn last entry (the process died mid-write) was never acknowledged and is dropped. */
19248
+ /**
19249
+ * An index's entries for records in key order, sorted by the index's key: the index derives them itself (key path,
19250
+ * multi-entry, invalid keys skipped) into a list instead of its tree. A stable sort keeps equal index keys in the
19251
+ * records' key order, the tree's own tie-break.
19252
+ */
19253
+ function indexEntries(index, records, cmp) {
19254
+ const entries = [];
19255
+ const tree = index.records;
19256
+ index.records = {
19257
+ get: () => void 0,
19258
+ put: (entry) => {
19259
+ entries.push(entry);
19260
+ }
19261
+ };
19262
+ try {
19263
+ for (const record of records) if (!absent(index.keyPath, record.value)) index.storeRecord(record);
19264
+ } finally {
19265
+ index.records = tree;
19266
+ }
19267
+ return entries.sort((a, b) => cmp(a.key, b.key));
19268
+ }
19269
+ /**
19270
+ * Whether a key path surely finds nothing in a value: a plain object along it lacks the step (most messages have no
19271
+ * `card` for the card index). The index would throw and catch a DataError for each of those; anything else it judges.
19272
+ */
19273
+ function absent(keyPath, value) {
19274
+ if (typeof keyPath !== "string" || keyPath === "") return false;
19275
+ let object = value;
19276
+ for (const step of keyPath.split(".")) {
19277
+ if (object === null || typeof object !== "object" || Array.isArray(object) || object instanceof Blob) return false;
19278
+ if (!Object.hasOwn(object, step)) return true;
19279
+ object = object[step];
19280
+ }
19281
+ return false;
19282
+ }
19283
+ /**
19284
+ * fake-indexeddb's red-black tree, built balanced from sorted records: every level full but the last, whose nodes
19285
+ * are red, so each path holds the same number of black nodes and later inserts rebalance as usual.
19286
+ */
19287
+ function fill(store, sorted) {
19288
+ const deepest = sorted.length ? Math.floor(Math.log2(sorted.length)) : 0;
19289
+ const build = (low, high, parent, depth) => {
19290
+ if (low > high) return void 0;
19291
+ const middle = low + high >>> 1;
19292
+ const node = {
19293
+ record: sorted[middle],
19294
+ left: void 0,
19295
+ right: void 0,
19296
+ parent,
19297
+ deleted: false,
19298
+ red: depth > 0 && depth === deepest
19299
+ };
19300
+ node.left = build(low, middle - 1, node, depth + 1);
19301
+ node.right = build(middle + 1, high, node, depth + 1);
19302
+ return node;
19303
+ };
19304
+ store.records._root = build(0, sorted.length - 1, void 0, 0);
19305
+ store.records._numNodes = sorted.length;
19306
+ store.records._numTombstones = 0;
19307
+ }
19308
+ /**
19309
+ * Replays whole entries; a torn last entry (the process died mid-write) was never acknowledged and is dropped.
19310
+ *
19311
+ * A store's indexes are set aside while its ops replay and rebuilt once at the end: fake-indexeddb drops an
19312
+ * overwritten or deleted record from an index by walking every record of it, so a crash after a busy chat took
19313
+ * tens of seconds to minutes to start, one write at a time against the whole profile.
19314
+ */
18639
19315
  function replay(databases, journal) {
19316
+ const touched = /* @__PURE__ */ new Map();
19317
+ try {
19318
+ replayEntries(databases, journal, touched);
19319
+ } finally {
19320
+ for (const [store, indexes] of touched) {
19321
+ store.rawIndexes = indexes;
19322
+ for (const index of indexes.values()) {
19323
+ index.records.clear();
19324
+ if (!index.initialized) continue;
19325
+ for (const record of store.records.values()) index.storeRecord(record);
19326
+ }
19327
+ }
19328
+ }
19329
+ }
19330
+ function replayEntries(databases, journal, touched) {
18640
19331
  let offset = 0;
18641
19332
  while (offset + 4 <= journal.length) {
18642
19333
  const length = journal.readUInt32BE(offset);
@@ -18651,6 +19342,10 @@ function replay(databases, journal) {
18651
19342
  for (const op of ops) {
18652
19343
  const store = databases.get(op.db)?.rawObjectStores.get(op.store);
18653
19344
  if (!store) continue;
19345
+ if (!touched.has(store)) {
19346
+ touched.set(store, store.rawIndexes);
19347
+ store.rawIndexes = /* @__PURE__ */ new Map();
19348
+ }
18654
19349
  if (op.kind === "put") store.storeRecord({
18655
19350
  key: op.key,
18656
19351
  value: blobsFromDisk(op.value)
@@ -18892,7 +19587,7 @@ async function startRuntime(paths, options = {}) {
18892
19587
  const restored = join(paths.dir, RESTORED_MARK);
18893
19588
  if (existsSync(restored)) {
18894
19589
  try {
18895
- const rows = await import("./assets/restoredRows-lhTKGmCO.js");
19590
+ const rows = await import("./assets/restoredRows-C_84q0Pd.js");
18896
19591
  await rows.markRestoredWallet();
18897
19592
  await rows.markLeftOutFiles(leftOutIn(restored));
18898
19593
  await store.compact();
@@ -18911,16 +19606,16 @@ async function startRuntime(paths, options = {}) {
18911
19606
  await installEventSource();
18912
19607
  await preloadWalletModules();
18913
19608
  (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")]);
19609
+ const [{ registerFileBytes }, { NodeFileBytes }] = await Promise.all([import("./assets/fileBytes-BcYZhNgF.js").then((n) => n.o), import("./assets/fileBytes-Dbifu5I5.js")]);
18915
19610
  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")]);
19611
+ const [{ EngineServer }, { createHyperEndpoint }] = await Promise.all([import("./assets/server-BGXmSYx_.js").then((n) => n.t), import("./assets/endpoint-pgFkR2AS.js")]);
18917
19612
  const network = hyperdhtNetwork();
18918
19613
  const dht = mainlineNetwork();
18919
- const mainline = dht.off ? null : new (await (import("./assets/mainline-B7wHcLaW.js"))).Mainline({
19614
+ const mainline = dht.off ? null : new (await (import("./assets/mainline-BueFJx3f.js"))).Mainline({
18920
19615
  bootstrap: dht.bootstrap,
18921
19616
  host: dht.host
18922
19617
  });
18923
- const transport = mainline || pinned ? new (await (import("./assets/mainline-B7wHcLaW.js"))).RelaysAndDht(mainline, pinned ? { relays: pinned } : {}, !!pinned) : null;
19618
+ const transport = mainline || pinned ? new (await (import("./assets/mainline-BueFJx3f.js"))).RelaysAndDht(mainline, pinned ? { relays: pinned } : {}, !!pinned) : null;
18924
19619
  const server = new EngineServer({
18925
19620
  ...transport ? { transport } : {},
18926
19621
  irohWeb: true,
@@ -18930,6 +19625,7 @@ async function startRuntime(paths, options = {}) {
18930
19625
  automaticWallets: false,
18931
19626
  fedimintSdk: nodeFedimintSdk(join(paths.dir, "fedimint")),
18932
19627
  servicesSupport: true,
19628
+ apps: false,
18933
19629
  staysOnline: process.env.GHOSTLY_HUB !== "0",
18934
19630
  localFetch: nodeLocalFetch,
18935
19631
  pushSend: (request) => nodePushSend(request),
@@ -18979,6 +19675,7 @@ async function openHost(paths, mode, version, options = {}) {
18979
19675
  await hub.open();
18980
19676
  const node = runtime.server.node;
18981
19677
  const now = node.getState();
19678
+ const mark = runtime.server.historyMark;
18982
19679
  const histories = /* @__PURE__ */ new Map();
18983
19680
  for (const link of now.links) histories.set(link.id, await node.getMessages(link.id));
18984
19681
  for (const group of now.groups) histories.set(`group:${group.id}`, await node.groupMessages({ groupId: group.id }));
@@ -18992,7 +19689,10 @@ async function openHost(paths, mode, version, options = {}) {
18992
19689
  });
18993
19690
  const manager = calls;
18994
19691
  hub.onCallSignal((chat, signal) => manager.onSignal(chat, signal));
18995
- runtime.server.attach(hub.sink);
19692
+ runtime.server.attach(hub.sink, {
19693
+ mark,
19694
+ ids: histories.keys()
19695
+ });
18996
19696
  } catch (error) {
18997
19697
  await runtime.close().catch(() => {});
18998
19698
  release();
@@ -19320,6 +20020,7 @@ async function post(url, event) {
19320
20020
  try {
19321
20021
  if ((await fetch(url, {
19322
20022
  method: "POST",
20023
+ redirect: "manual",
19323
20024
  headers: {
19324
20025
  "content-type": "application/json",
19325
20026
  "x-ghostly-event": event.type,
@@ -19508,7 +20209,10 @@ var SPECIAL = [
19508
20209
  {
19509
20210
  out: o("string", "The backup file to write"),
19510
20211
  "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"),
20212
+ passphrase: {
20213
+ ...o("boolean", "Do not encrypt. The file then holds the profile's keys and wallet secrets in the clear"),
20214
+ negatedOnly: true
20215
+ },
19512
20216
  light: o("boolean", "Leave out files over 1 MB (voice messages over 4 MB); their messages stay")
19513
20217
  }
19514
20218
  ],
@@ -19579,7 +20283,8 @@ var SPECIAL = [
19579
20283
  days: o("number", "How long the proof holds"),
19580
20284
  timeout: o("number", "Seconds to wait for an approval")
19581
20285
  }
19582
- ]
20286
+ ],
20287
+ ["version", "This CLI's version (also ghostly --version)"]
19583
20288
  ];
19584
20289
  function help() {
19585
20290
  const rows = [
@@ -19645,7 +20350,9 @@ function commandHelp(words) {
19645
20350
  const options = Object.entries(row.options ?? {}).filter(([, o]) => o.description);
19646
20351
  if (options.length) {
19647
20352
  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);
20353
+ const labels = options.map(([name, o]) => ` --${o.negatedOnly ? "no-" : ""}${name}${o.type === "boolean" ? "" : ` <${o.type === "number" ? "n" : "value"}>`}`);
20354
+ const width = Math.max(26, ...labels.map((label) => label.length + 2));
20355
+ options.forEach(([, o], i) => lines.push(labels[i].padEnd(width) + o.description));
19649
20356
  }
19650
20357
  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
20358
  lines.push("");
@@ -19733,6 +20440,7 @@ async function profileCommand(sub, argv) {
19733
20440
  return;
19734
20441
  }
19735
20442
  case "list": {
20443
+ noMoreThan(0, parsed.positionals, "profile list");
19736
20444
  const current = currentProfile(g.home);
19737
20445
  print({
19738
20446
  profiles: listProfiles(g.home).map((name) => ({
@@ -19747,14 +20455,17 @@ async function profileCommand(sub, argv) {
19747
20455
  case "use": {
19748
20456
  const [name] = parsed.positionals;
19749
20457
  if (!name) throw new CliError("usage", "ghostly profile use <name>");
20458
+ noMoreThan(1, parsed.positionals, "profile use <name>");
19750
20459
  selectProfile(g.home, name);
19751
20460
  print({ current: name });
19752
20461
  return;
19753
20462
  }
19754
20463
  case "show":
20464
+ noMoreThan(0, parsed.positionals, "profile show");
19755
20465
  print(await withSession(g, (s) => s.call("profile.get")));
19756
20466
  return;
19757
20467
  case "set": {
20468
+ noMoreThan(0, parsed.positionals, "profile set [--name <name>] [--share-profile | --no-share-profile]");
19758
20469
  const params = {};
19759
20470
  if (parsed.options.name !== void 0) params.name = parsed.options.name;
19760
20471
  if (parsed.options["share-profile"] !== void 0) params.shareProfile = parsed.options["share-profile"];
@@ -19763,6 +20474,7 @@ async function profileCommand(sub, argv) {
19763
20474
  return;
19764
20475
  }
19765
20476
  case "backup": {
20477
+ noMoreThan(0, parsed.positionals, "profile backup --out <file> [--passphrase-file f | --no-passphrase] [--light]");
19766
20478
  const out = parsed.options.out;
19767
20479
  if (typeof out !== "string") throw new CliError("usage", "ghostly profile backup --out <file> [--passphrase-file f | --no-passphrase] [--light] (or GHOSTLY_BACKUP_PASSPHRASE)");
19768
20480
  const unsealed = parsed.options.passphrase === false;
@@ -19785,6 +20497,7 @@ async function profileCommand(sub, argv) {
19785
20497
  case "restore": {
19786
20498
  const [file, name] = parsed.positionals;
19787
20499
  if (!file || !name) throw new CliError("usage", "ghostly profile restore <file> <new profile> [--passphrase-file f] [--use]");
20500
+ noMoreThan(2, parsed.positionals, "profile restore <file> <new profile> [--passphrase-file f] [--use]");
19788
20501
  const protection = await backupFileProtection(file);
19789
20502
  const paths = await restoreProfile(g.home, checkProfileName(name), file, protection === "none" ? void 0 : backupPassphrase(parsed.options["passphrase-file"]));
19790
20503
  if (parsed.options.use) selectProfile(g.home, name);
@@ -19872,6 +20585,11 @@ async function daemonCommand(argv) {
19872
20585
  });
19873
20586
  const g = globals(parsed);
19874
20587
  pretty = g.pretty;
20588
+ if (!sub || [
20589
+ "status",
20590
+ "stop",
20591
+ "restart"
20592
+ ].includes(sub)) noMoreThan(0, parsed.positionals, sub ? `daemon ${sub}` : "daemon [--detach]");
19875
20593
  if (sub === "status") {
19876
20594
  const client = await connectDaemon(g.paths.socket);
19877
20595
  if (!client) {
@@ -20143,6 +20861,7 @@ async function engineCommand(argv) {
20143
20861
  const g = globals(parsed);
20144
20862
  pretty = g.pretty;
20145
20863
  if (parsed.options.list) {
20864
+ noMoreThan(0, parsed.positionals, "engine --list");
20146
20865
  print({ methods: [...ENGINE_METHODS, ...ENGINE_READS].sort() });
20147
20866
  return;
20148
20867
  }
@@ -20186,6 +20905,7 @@ async function settingsCommand(sub, argv) {
20186
20905
  const g = globals(parsed);
20187
20906
  pretty = g.pretty;
20188
20907
  if (sub === "get") {
20908
+ noMoreThan(0, parsed.positionals, "settings get [--show-secret]");
20189
20909
  print(await withSession(g, (s) => s.call("settings.get", { showSecret: parsed.options["show-secret"] === true })));
20190
20910
  return;
20191
20911
  }
@@ -20198,6 +20918,7 @@ async function settingsCommand(sub, argv) {
20198
20918
  } catch {
20199
20919
  parsedValue = value;
20200
20920
  }
20921
+ if (SETTABLE[key] === "string" && typeof parsedValue !== "string") parsedValue = value;
20201
20922
  print(await withSession(g, (s) => s.call("settings.set", { [key]: parsedValue })));
20202
20923
  return;
20203
20924
  }
@@ -20358,7 +21079,7 @@ async function tableCommand(name, argv) {
20358
21079
  * contact's, raw PCM); what the command says goes to stderr. It ends when the call does.
20359
21080
  */
20360
21081
  async function callPipeCommand(argv) {
20361
- const parsed = parseArgs(argv, {}, idSlot({ args: ["call..."] }));
21082
+ const parsed = parseArgs(argv, {}, idSlot({ args: ["call...?"] }));
20362
21083
  const g = globals(parsed);
20363
21084
  const ref = parsed.positionals.join(" ") || void 0;
20364
21085
  const client = await connectDaemon(g.paths.socket);
@@ -20400,7 +21121,7 @@ function helpAsked(argv) {
20400
21121
  }
20401
21122
  async function main(input) {
20402
21123
  const argv = liftGlobals(input);
20403
- const helpFor = argv.length === 0 ? [] : helpAsked(argv);
21124
+ const helpFor = argv.length === 0 || globalOption(argv[0]) ? [] : helpAsked(argv);
20404
21125
  if (helpFor) {
20405
21126
  process.stdout.write((helpFor.length ? commandHelp(helpFor) : help()) + "\n");
20406
21127
  return 0;