@ghostlytools/cli 1.2.4 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/README.md +25 -9
  2. package/dist/assets/api-Dq2QXQDk.js +4172 -0
  3. package/dist/assets/api-Dq2QXQDk.js.map +1 -0
  4. package/dist/assets/api-TiB5QSG9.js +3 -0
  5. package/dist/assets/apiKit-CA2x5GJF.js +200 -0
  6. package/dist/assets/apiKit-CA2x5GJF.js.map +1 -0
  7. package/dist/assets/apps-BxV9i_Y4.js +786 -0
  8. package/dist/assets/apps-BxV9i_Y4.js.map +1 -0
  9. package/dist/assets/apps-C-T8jeji.js +2 -0
  10. package/dist/assets/{arkade-CdnKGDbg.js → arkade-C_qDmHnm.js} +2 -2
  11. package/dist/assets/{arkade-CdnKGDbg.js.map → arkade-C_qDmHnm.js.map} +1 -1
  12. package/dist/assets/backup-Bugnn3Ue.js +2 -0
  13. package/dist/assets/backup-CnMN2oUK.js +731 -0
  14. package/dist/assets/backup-CnMN2oUK.js.map +1 -0
  15. package/dist/assets/bark_ffi_wasm_bg-EY2uc0Bx.js.map +1 -1
  16. package/dist/assets/bitcoind-CZchoh43.js +349 -0
  17. package/dist/assets/bitcoind-CZchoh43.js.map +1 -0
  18. package/dist/assets/bitcoindProvider-DGw3xBSa.js +97 -0
  19. package/dist/assets/bitcoindProvider-DGw3xBSa.js.map +1 -0
  20. package/dist/assets/bitcoindevkit_bg-B-iLWrmX.js.map +1 -1
  21. package/dist/assets/buttons-Bj7Vf7Pn.js +2 -0
  22. package/dist/assets/buttons-CvNBzh2S.js +496 -0
  23. package/dist/assets/buttons-CvNBzh2S.js.map +1 -0
  24. package/dist/assets/{endpoint-pgFkR2AS.js → endpoint-BOTFMpfS.js} +56 -18
  25. package/dist/assets/endpoint-BOTFMpfS.js.map +1 -0
  26. package/dist/assets/envelope-WjingYSc.js +90 -0
  27. package/dist/assets/envelope-WjingYSc.js.map +1 -0
  28. package/dist/assets/errors-DO5z6h_l.js +105 -0
  29. package/dist/assets/errors-DO5z6h_l.js.map +1 -0
  30. package/dist/assets/fedimint_client_wasm_bg-BtHHlFpp.js.map +1 -1
  31. package/dist/assets/{fileBytes-Dbifu5I5.js → fileBytes-CfSXjTFa.js} +2 -2
  32. package/dist/assets/{fileBytes-Dbifu5I5.js.map → fileBytes-CfSXjTFa.js.map} +1 -1
  33. package/dist/assets/fileBytes-CgbibpbV.js +2 -0
  34. package/dist/assets/{fileBytes-BcYZhNgF.js → fileBytes-Di7VZtmU.js} +6 -19
  35. package/dist/assets/fileBytes-Di7VZtmU.js.map +1 -0
  36. package/dist/assets/fileBytesIdb-CFP-DSkZ.js +2 -0
  37. package/dist/assets/{fileBytesIdb-DzSaqI0A.js → fileBytesIdb-D5u6H8X_.js} +4 -4
  38. package/dist/assets/{fileBytesIdb-DzSaqI0A.js.map → fileBytesIdb-D5u6H8X_.js.map} +1 -1
  39. package/dist/assets/{fileBytesOpfs-Dx8RZiV5.js → fileBytesOpfs-Dd1N4ERH.js} +3 -3
  40. package/dist/assets/{fileBytesOpfs-Dx8RZiV5.js.map → fileBytesOpfs-Dd1N4ERH.js.map} +1 -1
  41. package/dist/assets/groupFiles-IEUW3DQz.js +3202 -0
  42. package/dist/assets/groupFiles-IEUW3DQz.js.map +1 -0
  43. package/dist/assets/host-CWt-jtqz.js +4673 -0
  44. package/dist/assets/host-CWt-jtqz.js.map +1 -0
  45. package/dist/assets/hyperdhtRelay-BT_wRxab.js +21 -0
  46. package/dist/assets/hyperdhtRelay-BT_wRxab.js.map +1 -0
  47. package/dist/assets/{hyperdhtRelay-C1p0nkle.js → hyperdhtRelay-CPYwSLLS.js} +25 -10
  48. package/dist/assets/hyperdhtRelay-CPYwSLLS.js.map +1 -0
  49. package/dist/assets/{idb-CTF6LF5B.js → idb-B5L8wuOm.js} +7 -5
  50. package/dist/assets/idb-B5L8wuOm.js.map +1 -0
  51. package/dist/assets/irohWeb-4oChIOIp.js +544 -0
  52. package/dist/assets/irohWeb-4oChIOIp.js.map +1 -0
  53. package/dist/assets/{joinTrace-CO8mlhNO.js → joinTrace-CjRDFy85.js} +2 -7
  54. package/dist/assets/{joinTrace-CO8mlhNO.js.map → joinTrace-CjRDFy85.js.map} +1 -1
  55. package/dist/assets/joinTrace-D-fnt2p3.js +2 -0
  56. package/dist/assets/main-DbqcdShN.js +4409 -0
  57. package/dist/assets/main-DbqcdShN.js.map +1 -0
  58. package/dist/assets/{mainline-BH6s8HVi.js → mainline-ZkiOnevs.js} +11 -6
  59. package/dist/assets/mainline-ZkiOnevs.js.map +1 -0
  60. package/dist/assets/mints-BrlY773j.js +74 -0
  61. package/dist/assets/mints-BrlY773j.js.map +1 -0
  62. package/dist/assets/mints-CqTdA4Gk.js +2 -0
  63. package/dist/assets/networkReason-CetKkTPo.js +154 -0
  64. package/dist/assets/networkReason-CetKkTPo.js.map +1 -0
  65. package/dist/assets/nwc-CgkqyvsE.js +612 -0
  66. package/dist/assets/nwc-CgkqyvsE.js.map +1 -0
  67. package/dist/assets/nwcProvider-4eVz_8HP.js +77 -0
  68. package/dist/assets/nwcProvider-4eVz_8HP.js.map +1 -0
  69. package/dist/assets/privateFolder-Bk9C9oFv.js +38 -0
  70. package/dist/assets/privateFolder-Bk9C9oFv.js.map +1 -0
  71. package/dist/assets/profiles-BHc9pEYg.js +139 -0
  72. package/dist/assets/profiles-BHc9pEYg.js.map +1 -0
  73. package/dist/assets/registry-DlAKO0Fy.js +3793 -0
  74. package/dist/assets/registry-DlAKO0Fy.js.map +1 -0
  75. package/dist/assets/registry-OBJBnHVQ.js +2 -0
  76. package/dist/assets/resolve-CouHhCpJ.js +591 -0
  77. package/dist/assets/resolve-CouHhCpJ.js.map +1 -0
  78. package/dist/assets/{restoredRows-C_84q0Pd.js → restoredRows-Db3oUTko.js} +2 -2
  79. package/dist/assets/{restoredRows-C_84q0Pd.js.map → restoredRows-Db3oUTko.js.map} +1 -1
  80. package/dist/assets/{server-0zfNpABQ.js → server-Ss0gJ-nw.js} +1275 -1404
  81. package/dist/assets/server-Ss0gJ-nw.js.map +1 -0
  82. package/dist/assets/{src-CbLkHfz8.js → src-DDzSERyc.js} +882 -134
  83. package/dist/assets/src-DDzSERyc.js.map +1 -0
  84. package/dist/assets/turnRefs-B87dtrHM.js +323 -0
  85. package/dist/assets/turnRefs-B87dtrHM.js.map +1 -0
  86. package/dist/assets/types-C4_KafL_.js +122 -0
  87. package/dist/assets/types-C4_KafL_.js.map +1 -0
  88. package/dist/assets/{usdt-Qf4UuygS.js → usdt-Fj8CQO2z.js} +3 -3
  89. package/dist/assets/{usdt-Qf4UuygS.js.map → usdt-Fj8CQO2z.js.map} +1 -1
  90. package/dist/ghostly.mjs +4 -22077
  91. package/dist/ghostly.mjs.map +1 -1
  92. package/package.json +1 -1
  93. package/dist/assets/endpoint-pgFkR2AS.js.map +0 -1
  94. package/dist/assets/fileBytes-BcYZhNgF.js.map +0 -1
  95. package/dist/assets/hyperdhtRelay-C1p0nkle.js.map +0 -1
  96. package/dist/assets/idb-CTF6LF5B.js.map +0 -1
  97. package/dist/assets/mainline-BH6s8HVi.js.map +0 -1
  98. package/dist/assets/rolldown-runtime-8H4AJuhK.js +0 -14
  99. package/dist/assets/server-0zfNpABQ.js.map +0 -1
  100. package/dist/assets/src-CbLkHfz8.js.map +0 -1
@@ -0,0 +1,786 @@
1
+ import { Oc as seedSigner, _ as assembleAppBundle, _o as readAppRevocations, bo as canonicalJson, c as readAppStore, co as APP_PREFIXES, d as APP_BUNDLE_LIMITS, do as appRef, fo as checkAppRevokeStatement, gl as fromBase64Url, lo as APP_STATEMENT_LIMITS, n as appStoreDecision, s as checkAppStoreIndex, u as signAppStore, uo as appFingerprint, v as checkAppManifest, vl as toBase64Url, vo as signAppObject, x as readAppBundle, xo as canonicalJsonBytes, y as isAppUrl, yl as toZ32, yo as signAppRevocation } from "./src-DDzSERyc.js";
2
+ import { t as CliError } from "./errors-DO5z6h_l.js";
3
+ import { closeSync, existsSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, statSync, writeFileSync, writeSync } from "node:fs";
4
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
5
+ import { createHash, createPrivateKey, generateKeyPairSync } from "node:crypto";
6
+ //#region src/appTemplate.ts
7
+ var html = (text) => text.replace(/[&<>"]/g, (c) => ({
8
+ "&": "&amp;",
9
+ "<": "&lt;",
10
+ ">": "&gt;",
11
+ "\"": "&quot;"
12
+ })[c]);
13
+ /** `ghostly-app.json`: the manifest without `publisher`, `sequence` and `files`, which `app publish` writes. */
14
+ function templateManifest({ name, title }) {
15
+ return {
16
+ name,
17
+ version: "0.1.0",
18
+ kind: "mini-app",
19
+ title,
20
+ tagline: "Say hello to a contact",
21
+ description: "Open it in a chat and your contact gets a card to join. Each side can then say hello to the other.",
22
+ entry: "index.html",
23
+ permissions: ["chat"],
24
+ view: "chat",
25
+ runtime: {
26
+ host: ">=1.2",
27
+ clients: ["web", "desktop"]
28
+ },
29
+ license: "MIT"
30
+ };
31
+ }
32
+ /**
33
+ * `index.html`: the whole app in one file, as the sandbox needs it (no network, so nothing is loaded from elsewhere).
34
+ * Its script is plain JavaScript typed with JSDoc against `@ghostlytools/sdk/app`, so it runs as written and still type
35
+ * checks (test/appInit.test.ts does). The title is the only thing put into it, as text. Its type imports are in single
36
+ * quotes: they are text of the page, not imports of the CLI's own build (test/packageDeps.test.ts reads those).
37
+ */
38
+ function templatePage({ title }) {
39
+ return `<!doctype html>
40
+ <html lang="en">
41
+ <head>
42
+ <meta charset="utf-8">
43
+ <meta name="viewport" content="width=device-width, initial-scale=1">
44
+ <title>${html(title)}</title>
45
+ <style>
46
+ :root { color-scheme: light dark; --bg: #ffffff; --fg: #16181d; --muted: #5b6270; --line: #d9dde3; --accent: #2f6fed; }
47
+ :root[data-theme="dark"] { --bg: #16181d; --fg: #f2f4f7; --muted: #a3abb8; --line: #343a45; --accent: #7aa5ff; }
48
+ body { margin: 0; padding: 20px; background: var(--bg); color: var(--fg); font: 16px/1.4 system-ui, sans-serif; }
49
+ h1 { margin: 0 0 4px; font-size: 20px; }
50
+ #status { margin: 0 0 16px; color: var(--muted); }
51
+ button { font: inherit; padding: 8px 16px; border: 1px solid var(--accent); border-radius: 8px; background: var(--accent); color: var(--bg); }
52
+ button:disabled { opacity: 0.5; }
53
+ ul { margin: 16px 0 0; padding: 0; list-style: none; }
54
+ li { padding: 8px 0; border-top: 1px solid var(--line); }
55
+ </style>
56
+ </head>
57
+ <body>
58
+ <h1>${html(title)}</h1>
59
+ <p id="status" role="status">Starting</p>
60
+ <button id="hello" type="button" disabled>Say hello</button>
61
+ <ul id="log" aria-label="Messages"></ul>
62
+ <script type="module">
63
+ // The types of the API come from the SDK (npm install --save-dev @ghostlytools/sdk); nothing of it runs here.
64
+ /** @typedef {import('@ghostlytools/sdk/app').MiniAppApi} MiniAppApi */
65
+ /** @typedef {import('@ghostlytools/sdk/app').MiniAppJson} MiniAppJson */
66
+
67
+ /** The longest text shown from a contact's frame. */
68
+ const MAX_TEXT = 200;
69
+
70
+ const status = /** @type {HTMLElement} */ (document.getElementById("status"));
71
+ const button = /** @type {HTMLButtonElement} */ (document.getElementById("hello"));
72
+ const log = /** @type {HTMLElement} */ (document.getElementById("log"));
73
+
74
+ /** @param {string} text */
75
+ function show(text) {
76
+ const line = document.createElement("li");
77
+ line.textContent = text;
78
+ log.append(line);
79
+ }
80
+
81
+ /**
82
+ * What the contact's app sent, when it is a hello. A frame comes from another device: read only what this app sends,
83
+ * and never put it in the page as HTML.
84
+ * @param {MiniAppJson} data
85
+ * @returns {string | null}
86
+ */
87
+ function helloOf(data) {
88
+ if (!data || typeof data !== "object" || Array.isArray(data) || data.type !== "hello") return null;
89
+ return typeof data.text === "string" && data.text.length <= MAX_TEXT ? data.text : null;
90
+ }
91
+
92
+ /**
93
+ * Why a send was refused, in words. The code is the rejection's message (MINI_APP_ERROR_CODES in the SDK); a later
94
+ * client may add one.
95
+ * @param {unknown} error
96
+ */
97
+ function refusal(error) {
98
+ switch (error instanceof Error ? error.message : "") {
99
+ case "offline": return "Not sent: you are not both online";
100
+ case "peer-closed": case "not-open": return "Not sent: your contact has not opened this app";
101
+ case "not-allowed": return "Open this app in a chat to say hello";
102
+ default: return "Not sent";
103
+ }
104
+ }
105
+
106
+ /** @param {MiniAppApi} ghostly */
107
+ async function start(ghostly) {
108
+ // Listen before anything is awaited, so no frame is missed.
109
+ ghostly.chat.on("message", (data) => {
110
+ const text = helloOf(data);
111
+ if (text !== null) show("Your contact: " + text);
112
+ });
113
+ ghostly.chat.on("peer", (peer) => {
114
+ status.textContent = peer.open ? "Your contact has this app open" : "Waiting for your contact to open this app";
115
+ });
116
+
117
+ const context = await ghostly.context();
118
+ const dark = context.theme ? context.theme === "dark" : matchMedia("(prefers-color-scheme: dark)").matches;
119
+ document.documentElement.dataset.theme = dark ? "dark" : "light";
120
+ status.textContent = !context.inChat ? "Open this app in a chat to say hello"
121
+ : context.peer ? "Your contact has this app open" : "Waiting for your contact to open this app";
122
+
123
+ button.disabled = false;
124
+ button.addEventListener("click", async () => {
125
+ try {
126
+ await ghostly.chat.send({ type: "hello", text: "Hello!" });
127
+ show("You: Hello!");
128
+ } catch (error) {
129
+ show(refusal(error));
130
+ }
131
+ });
132
+ }
133
+
134
+ /** Ghostly's runner gives the app \`window.ghostly\`; a browser tab has none. */
135
+ const ghostly = /** @type {{ ghostly?: MiniAppApi }} */ (window).ghostly;
136
+ if (ghostly) void start(ghostly);
137
+ else status.textContent = "This page is a Ghostly app: publish it and open it in Ghostly";
138
+ <\/script>
139
+ </body>
140
+ </html>
141
+ `;
142
+ }
143
+ /** `README.md`: what the folder holds and the way from it to a store. */
144
+ function templateReadme({ name, title }) {
145
+ return `# ${title}
146
+
147
+ A [Ghostly](https://github.com/MiguelMedeiros/ghostly) app, started with \`ghostly app init\`. Opened in a 1:1 chat, it
148
+ gives your contact a card to join, and each side can say hello to the other.
149
+
150
+ A Ghostly app ([WISP 1200](https://github.com/MiguelMedeiros/ghostly/blob/dev/docs/wisps/1200-marketplace.md)) is a
151
+ page that runs in the client's sandbox. It has no network, and talks to Ghostly only through \`window.ghostly\`:
152
+ frames of at most 32 KiB to the same app on the contact's side (\`chat.send\`, \`chat.on\`), storage per app and per
153
+ chat, and the files of its own bundle. Apps run in Ghostly from release 1.2.
154
+
155
+ | File | What it is |
156
+ |---|---|
157
+ | \`ghostly-app.json\` | The manifest, without \`publisher\`, \`sequence\` and \`files\`: \`ghostly app publish\` writes those |
158
+ | \`index.html\` | The app, whole: styles and script inline |
159
+ | \`README.md\` | This file |
160
+
161
+ ## Write it
162
+
163
+ \`index.html\` is plain JavaScript, typed with JSDoc against \`@ghostlytools/sdk/app\`: \`MiniAppApi\` is
164
+ \`window.ghostly\`, and the same module has the limits (\`MINI_APP_LIMITS\`) and the codes a refused call rejects with
165
+ (\`MINI_APP_ERROR_CODES\`). For the types in your editor:
166
+
167
+ \`\`\`sh
168
+ npm install --save-dev @ghostlytools/sdk
169
+ \`\`\`
170
+
171
+ - What a contact's app sends is untrusted: check its shape, bound its size, and show it as text, never as HTML.
172
+ - \`"view": "chat"\` runs the app inside a chat with one contact. \`"full"\` is a full-screen app opened from the Apps page.
173
+ - \`permissions\` lists what the person is asked for: \`chat\` (frames to the contact's app), \`name\` (the person's
174
+ display name) and \`internet\`. Adding one later makes every install ask before it updates.
175
+
176
+ ## Build
177
+
178
+ There is nothing to build yet: the page is the app. Once it grows into several source files, or TypeScript, build it
179
+ into one self-contained \`index.html\` (scripts, styles and images inline) and put that file and \`ghostly-app.json\`
180
+ alone in a folder of their own, which is the folder you publish.
181
+ [ghostly-chess](https://github.com/MiguelMedeiros/ghostly-chess) does it with Vite and a small
182
+ \`scripts/stage.mjs\`. \`ghostly app publish\` takes every file of the folder it is given but dot files, this README
183
+ included, so do not run it on a repository with sources, \`node_modules\` or anything private in it.
184
+
185
+ ## Publish
186
+
187
+ \`\`\`sh
188
+ ghostly app publish . --key ~/ghostly-keys/${name}-publisher.key
189
+ ghostly app verify app.ghostlyapp
190
+ \`\`\`
191
+
192
+ \`publish\` signs the folder into \`app.ghostlyapp\`. The first run makes the publisher key at the path you name:
193
+
194
+ - Keep it **outside this folder** and its repository (\`publish\` refuses a key inside it), and never commit it.
195
+ - Back it up. An app is updated only with the key that signed it; a lost key means a new app.
196
+
197
+ \`verify\` checks the bundle as Ghostly will and prints its \`ref\` (\`<publisher key>/${name}\`), \`sequence\` and
198
+ \`digest\`. For the next version, raise \`version\` in \`ghostly-app.json\` and publish again: the sequence goes up
199
+ by one by itself.
200
+
201
+ ## Try it
202
+
203
+ Commit \`app.ghostlyapp\` to a public GitHub repository, then in Ghostly open Apps, choose Add, and paste its URL
204
+ (\`https://raw.githubusercontent.com/<owner>/<repo>/HEAD/app.ghostlyapp\`). Set \`sources\` in \`ghostly-app.json\`
205
+ to that URL, in a list, so installs made from it find your updates. Open the app from a chat's + menu; your contact
206
+ gets a card to install and open it.
207
+
208
+ ## List it in a store
209
+
210
+ A store is a signed list of apps. The official one is a Git repository,
211
+ [ghostly-store](https://github.com/MiguelMedeiros/ghostly-store), and apps enter it by pull request:
212
+
213
+ 1. Commit \`app.ghostlyapp\` to your public repository and note that commit.
214
+ 2. Add \`apps/${name}.<first 16 characters of your publisher key>/listing.json\` with the \`ref\`, \`sequence\`
215
+ and \`digest\` that \`ghostly app verify\` printed. Its \`urls\` start with a jsDelivr URL pinned to the full commit
216
+ (\`https://cdn.jsdelivr.net/gh/<owner>/<repo>@<40-character commit>/app.ghostlyapp\`).
217
+ 3. Open the pull request. Its check verifies the bundle; a maintainer reviews the app and the owner signs the index.
218
+
219
+ The store's [CONTRIBUTING](https://github.com/MiguelMedeiros/ghostly-store/blob/main/CONTRIBUTING.md) has the rules.
220
+ An app installed from a store updates only to the version that store lists, so each new version is a pull request too.
221
+
222
+ ## If something goes wrong
223
+
224
+ If a version is harmful, \`ghostly app revoke . --key <your key> --up-to <sequence>\` writes \`ghostly-revoke.json\`;
225
+ publish it beside \`app.ghostlyapp\` and Ghostly stops those versions.
226
+ `;
227
+ }
228
+ //#endregion
229
+ //#region src/apps.ts
230
+ var APP_SOURCE = "ghostly-app.json";
231
+ var APP_BUNDLE_FILE = "app.ghostlyapp";
232
+ var APP_REVOKE_FILE = "ghostly-revoke.json";
233
+ var STORE_INDEX_FILE = "ghostly-store.json";
234
+ var STORE_SIG_FILE = "ghostly-store.sig";
235
+ /** How long `app verify` waits for a URL, and how many redirects it follows (each checked as the first). */
236
+ var FETCH_TIMEOUT_MS = 6e4;
237
+ var MAX_REDIRECTS = 5;
238
+ var KEY_HEADER = {
239
+ publisher: "Ghostly publisher key (WISP 1200). Secret: never share or commit it. Back it up: an app signed with it is updated only with it.",
240
+ store: "Ghostly store key (WISP 1200). Secret: keep it offline, never share or commit it. Back it up: a store is its key."
241
+ };
242
+ var PEM = /-----BEGIN PRIVATE KEY-----\r?\n([A-Za-z0-9+/=\r\n]+)-----END PRIVATE KEY-----/;
243
+ /** Refuses a key file that is not a plain file of this user's, or that anyone else may read or write. */
244
+ function checkKeyFile(path) {
245
+ const info = statSync(path);
246
+ if (!info.isFile()) throw new CliError("bad_request", `${path} is not a file: --key names a key file`);
247
+ if (process.platform === "win32") return;
248
+ if (process.getuid && info.uid !== process.getuid()) throw new CliError("refused", `${path} is not this user's: a key file must be yours alone`);
249
+ if ((info.mode & 63) !== 0) throw new CliError("refused", `${path} may be read by others (mode ${(info.mode & 511).toString(8)}): run chmod 600 ${path}, and think whether the key was seen`);
250
+ }
251
+ /** Reads a key file: the PEM block, an Ed25519 key, and the kind its first line names (when it names one). */
252
+ function readKey(path, kind) {
253
+ checkKeyFile(path);
254
+ const text = readFileSync(path, "utf8");
255
+ const other = kind === "publisher" ? "store" : "publisher";
256
+ if (text.startsWith(`Ghostly ${other} key`)) throw new CliError("refused", `${path} is a ${other} key: ${kind === "publisher" ? "a store key never signs an app" : "a publisher key never signs a store index"} (WISP 1200 · Stores)`);
257
+ const block = PEM.exec(text);
258
+ let seed;
259
+ if (block) try {
260
+ const key = createPrivateKey(block[0]);
261
+ const d = key.asymmetricKeyType === "ed25519" ? key.export({ format: "jwk" }).d : void 0;
262
+ if (d) seed = fromBase64Url(d);
263
+ } catch {}
264
+ if (!seed || seed.length !== 32) throw new CliError("bad_request", `${path} holds no Ed25519 private key (a PEM "PRIVATE KEY" block, as openssl genpkey -algorithm ed25519 writes)`);
265
+ const signer = seedSigner(seed);
266
+ return {
267
+ signer,
268
+ key: toZ32(signer.publicKey),
269
+ created: false
270
+ };
271
+ }
272
+ /** Makes a key file: owner-only from the start, never over a file that is there. */
273
+ function makeKey(path, kind) {
274
+ mkdirSync(dirname(path), {
275
+ recursive: true,
276
+ mode: 448
277
+ });
278
+ const { privateKey } = generateKeyPairSync("ed25519");
279
+ const pem = privateKey.export({
280
+ type: "pkcs8",
281
+ format: "pem"
282
+ });
283
+ const fd = openSync(path, "wx", 384);
284
+ try {
285
+ writeSync(fd, `${KEY_HEADER[kind]}\n${pem}`);
286
+ } finally {
287
+ closeSync(fd);
288
+ }
289
+ return {
290
+ ...readKey(path, kind),
291
+ created: true
292
+ };
293
+ }
294
+ /** What the person is told on stderr when a key was made: where, which, and to back it up. Never the key. */
295
+ function madeKeyNote(path, kind, key) {
296
+ return kind === "publisher" ? `new publisher key written to ${path}; back it up (${appFingerprint(key)}: an app is updated only with the key that signed it, and phase 1 has no recovery)` : `new store key written to ${path}; back it up and keep it offline (${appFingerprint(key)}: a store is its key)`;
297
+ }
298
+ var refused = (what, reading, extra = {}) => new CliError("refused", `${what} (${reading.reason}${reading.detail ? `: ${reading.detail}` : ""})`, {
299
+ reason: reading.reason,
300
+ ...reading.detail ? { detail: reading.detail } : {},
301
+ ...extra
302
+ });
303
+ /** A bundle file's bytes: its size is checked before it is read, so a huge file is refused unread. */
304
+ function readBundleFile(path) {
305
+ const info = statSync(path);
306
+ if (info.isDirectory()) throw new CliError("bad_request", `${path} is a folder: name a .ghostlyapp file`);
307
+ if (info.size > APP_BUNDLE_LIMITS.bundleBytes) throw refused(`${path} is not a valid bundle`, {
308
+ reason: "too-large",
309
+ detail: `${info.size} bytes`
310
+ });
311
+ return new Uint8Array(readFileSync(path));
312
+ }
313
+ /**
314
+ * A bundle at an https URL, as a client fetches one: `https:` only, no user or password, jsDelivr only at a full
315
+ * commit (WISP 1200 · Manifest, "Every URL"); every redirect is held to the same rule, and the download stops past
316
+ * 16 MiB.
317
+ */
318
+ async function fetchBundle(url) {
319
+ let at = url;
320
+ for (let hop = 0;; hop++) {
321
+ if (!isAppUrl(at)) throw new CliError(hop ? "refused" : "usage", hop ? `${url} redirects to ${at}, which a client does not read (https only, no user or password, jsDelivr only at a full commit)` : `Not a URL a client reads: https only, at most ${APP_BUNDLE_LIMITS.url} characters, no user or password, and jsDelivr only as https://cdn.jsdelivr.net/gh/<owner>/<repo>@<40-hex commit>/<path>`);
322
+ let response;
323
+ try {
324
+ response = await fetch(at, {
325
+ redirect: "manual",
326
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS)
327
+ });
328
+ } catch (error) {
329
+ if (error instanceof Error && error.name === "TimeoutError") throw new CliError("timeout", `${at} did not answer in ${FETCH_TIMEOUT_MS / 1e3} s`);
330
+ throw new CliError("unavailable", `Could not read ${at}: ${error instanceof Error ? error.message : String(error)}`);
331
+ }
332
+ if (response.status >= 300 && response.status < 400 && response.headers.get("location")) {
333
+ await response.body?.cancel();
334
+ if (hop >= MAX_REDIRECTS) throw new CliError("refused", `${url} redirects more than ${MAX_REDIRECTS} times`);
335
+ at = new URL(response.headers.get("location"), at).href;
336
+ continue;
337
+ }
338
+ if (response.status === 404 || response.status === 410) {
339
+ await response.body?.cancel();
340
+ throw new CliError("not_found", `${at} answered ${response.status}`);
341
+ }
342
+ if (!response.ok || !response.body) {
343
+ await response.body?.cancel();
344
+ throw new CliError("unavailable", `${at} answered ${response.status}`);
345
+ }
346
+ const limit = APP_BUNDLE_LIMITS.bundleBytes;
347
+ const tooLarge = (bytes) => refused(`${url} is not a valid bundle`, {
348
+ reason: "too-large",
349
+ detail: `${bytes} bytes`
350
+ });
351
+ const length = Number(response.headers.get("content-length"));
352
+ if (Number.isFinite(length) && length > limit) {
353
+ await response.body.cancel();
354
+ throw tooLarge(length);
355
+ }
356
+ const chunks = [];
357
+ let total = 0;
358
+ const reader = response.body.getReader();
359
+ try {
360
+ for (;;) {
361
+ const { done, value } = await reader.read();
362
+ if (done) break;
363
+ total += value.length;
364
+ if (total > limit) {
365
+ await reader.cancel();
366
+ throw tooLarge(`more than ${limit}`);
367
+ }
368
+ chunks.push(value);
369
+ }
370
+ } catch (error) {
371
+ if (error instanceof CliError) throw error;
372
+ if (error instanceof Error && error.name === "TimeoutError") throw new CliError("timeout", `${at} did not finish in ${FETCH_TIMEOUT_MS / 1e3} s`);
373
+ throw new CliError("unavailable", `Could not read ${at}: ${error instanceof Error ? error.message : String(error)}`);
374
+ }
375
+ const out = new Uint8Array(total);
376
+ let offset = 0;
377
+ for (const chunk of chunks) {
378
+ out.set(chunk, offset);
379
+ offset += chunk.length;
380
+ }
381
+ return out;
382
+ }
383
+ }
384
+ /** What a checked bundle is, as `app verify` and `app publish` print it. */
385
+ function described(bundle, bytes) {
386
+ const m = bundle.manifest;
387
+ return {
388
+ ref: appRef(m.publisher, m.name),
389
+ publisher: m.publisher,
390
+ fingerprint: appFingerprint(m.publisher),
391
+ name: m.name,
392
+ title: m.title,
393
+ version: m.version,
394
+ sequence: m.sequence,
395
+ digest: bundle.digest,
396
+ bytes: bytes.length,
397
+ sha256: toBase64Url(createHash("sha256").update(bytes).digest())
398
+ };
399
+ }
400
+ /** `app verify <bundle|url>`: every check a client makes before it stores a bundle (WISP 1200 · Reading a bundle). */
401
+ async function verifyApp(source) {
402
+ const { bundle, bytes } = await checkedBundle(source);
403
+ return {
404
+ valid: true,
405
+ ...described(bundle, bytes),
406
+ manifest: bundle.manifest
407
+ };
408
+ }
409
+ /** A bundle from a file or an https URL, with every check of `app verify`; `url` when it came from one. */
410
+ async function checkedBundle(source) {
411
+ const isUrl = /^[a-z][a-z0-9+.-]*:\/\//i.test(source);
412
+ const bytes = isUrl ? await fetchBundle(source) : readBundleFile(resolve(source));
413
+ const read = readAppBundle(bytes);
414
+ if (!read.ok) throw refused(`${source} is not a valid bundle`, read);
415
+ return {
416
+ bundle: read.bundle,
417
+ bytes,
418
+ ...isUrl && { url: source }
419
+ };
420
+ }
421
+ /** A folder's name as an app's: lower case, anything else a hyphen (`My App` is `my-app`). */
422
+ var nameFrom = (folder) => folder.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
423
+ /** An app's name as a title: `my-app` is `My app`. */
424
+ var titleFrom = (name) => (name[0].toUpperCase() + name.slice(1)).replace(/-/g, " ");
425
+ /**
426
+ * `app init <dir> [--name <name>] [--title <title>] [--force]`: a small working app in `<dir>` (made when missing),
427
+ * which `app publish <dir>` signs as it is: `ghostly-app.json`, an `index.html` that says hello to the contact, and a
428
+ * README with the way to a store (./appTemplate.ts). The name is `--name`, else the folder's; the title `--title`, else
429
+ * the name's. Nothing is written when one of the three files is there, unless `--force`, which replaces those three only.
430
+ */
431
+ async function initApp(dirArg, what = {}) {
432
+ const usage = "ghostly app init <dir> [--name <name>] [--title <title>] [--force]";
433
+ const dir = resolve(dirArg);
434
+ const name = what.name ?? nameFrom(basename(dir));
435
+ const title = what.title ?? (name ? titleFrom(name) : "");
436
+ const page = templatePage({
437
+ name,
438
+ title
439
+ });
440
+ const pageBytes = new TextEncoder().encode(page);
441
+ const checked = checkAppManifest({
442
+ ...templateManifest({
443
+ name,
444
+ title
445
+ }),
446
+ ghostlyApp: 1,
447
+ publisher: toZ32(/* @__PURE__ */ new Uint8Array(32)),
448
+ sequence: 1,
449
+ files: [{
450
+ path: "index.html",
451
+ size: pageBytes.length,
452
+ sha256: toBase64Url(createHash("sha256").update(pageBytes).digest())
453
+ }]
454
+ }, { strict: true });
455
+ if (!checked.ok) {
456
+ 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)}`);
457
+ 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)}`);
458
+ throw refused("The template makes no valid manifest", checked);
459
+ }
460
+ if (existsSync(dir) && !statSync(dir).isDirectory()) throw new CliError("bad_request", `${dir} is a file: name a folder for the app`);
461
+ const files = [
462
+ [APP_SOURCE, `${JSON.stringify(templateManifest({
463
+ name,
464
+ title
465
+ }), null, 2)}\n`],
466
+ ["index.html", page],
467
+ ["README.md", templateReadme({
468
+ name,
469
+ title
470
+ })]
471
+ ];
472
+ const held = files.map(([path]) => path).filter((path) => existsSync(join(dir, path)));
473
+ 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 });
474
+ mkdirSync(dir, { recursive: true });
475
+ for (const [path, text] of files) writeWhole(join(dir, path), new TextEncoder().encode(text));
476
+ return {
477
+ dir,
478
+ name,
479
+ title,
480
+ files: files.map(([path]) => path),
481
+ ...held.length ? { replaced: held } : {},
482
+ next: `ghostly app publish ${dirArg} --key <a key file outside ${dirArg}>`
483
+ };
484
+ }
485
+ /** Every file of the folder, as the bundle names it; dot files and folders are left out and listed. */
486
+ function collectFiles(dir, leaveOut) {
487
+ const files = [];
488
+ const skipped = [];
489
+ let bytes = 0;
490
+ const walk = (folder) => {
491
+ for (const name of readdirSync(folder).sort()) {
492
+ const full = join(folder, name);
493
+ const path = relative(dir, full).split(sep).join("/");
494
+ if (leaveOut.has(full)) continue;
495
+ if (name.startsWith(".")) {
496
+ skipped.push(path);
497
+ continue;
498
+ }
499
+ const info = lstatSync(full);
500
+ if (info.isSymbolicLink()) throw new CliError("bad_request", `${path} is a link: a bundle holds the folder's own files (copy it in, or remove it)`);
501
+ if (info.isDirectory()) {
502
+ walk(full);
503
+ continue;
504
+ }
505
+ if (!info.isFile()) throw new CliError("bad_request", `${path} is not a file`);
506
+ files.push({
507
+ path,
508
+ full,
509
+ size: info.size
510
+ });
511
+ bytes += info.size;
512
+ if (files.length > APP_BUNDLE_LIMITS.files) throw refused(`${dir} holds more files than a bundle may`, {
513
+ reason: "too-many-files",
514
+ detail: `more than ${APP_BUNDLE_LIMITS.files}`
515
+ });
516
+ if (bytes > APP_BUNDLE_LIMITS.bundleBytes) throw refused(`${dir} holds more than a bundle may`, {
517
+ reason: "too-large",
518
+ detail: `more than ${APP_BUNDLE_LIMITS.bundleBytes} bytes of files`
519
+ });
520
+ }
521
+ };
522
+ walk(dir);
523
+ files.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
524
+ return {
525
+ files,
526
+ skipped
527
+ };
528
+ }
529
+ /**
530
+ * A private key in a file: a PEM private key block with its body (PKCS #8, OpenSSH, RSA, EC, encrypted), or a Ghostly
531
+ * key file's first line. A library that only names the block's header in its code does not match.
532
+ */
533
+ 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\)/;
534
+ /** Whether `path` is `dir` or anything under it. */
535
+ var isInside = (dir, path) => {
536
+ const rel = relative(dir, path);
537
+ return !rel.startsWith("..") && !isAbsolute(rel);
538
+ };
539
+ /** Fields of `ghostly-app.json` the command writes itself. */
540
+ var WRITTEN = ["publisher", "files"];
541
+ function readSource(path) {
542
+ if (!existsSync(path)) throw new CliError("not_found", `No ${APP_SOURCE} in ${dirname(path)}: it holds the manifest's fields (name, version, title, tagline, entry, permissions, runtime, license, ...) but publisher, sequence and files, which publish writes`);
543
+ let value;
544
+ try {
545
+ value = JSON.parse(readFileSync(path, "utf8"));
546
+ } catch (error) {
547
+ throw new CliError("bad_request", `${path} is not JSON: ${error instanceof Error ? error.message : String(error)}`);
548
+ }
549
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new CliError("bad_request", `${path} is not a JSON object`);
550
+ const draft = value;
551
+ const written = WRITTEN.filter((k) => k in draft);
552
+ if (written.length) throw new CliError("bad_request", `${path} has ${written.join(" and ")}: publish writes ${written.length > 1 ? "them" : "it"} from the key and the folder's files`);
553
+ if (draft.ghostlyApp !== void 0 && draft.ghostlyApp !== 1) throw refused(`${path} is not a manifest this release makes`, {
554
+ reason: "unsupported-format",
555
+ detail: "ghostlyApp is not 1"
556
+ });
557
+ if (draft.sequence !== void 0 && !(Number.isSafeInteger(draft.sequence) && draft.sequence >= 1)) throw new CliError("bad_request", `${path}: sequence is a whole number from 1 (or leave it out: publish raises it by itself)`);
558
+ return draft;
559
+ }
560
+ /** Writes a file whole or not at all: a reader never sees half a bundle (and a dot file left by a crash is never bundled). */
561
+ function writeWhole(path, bytes) {
562
+ const tmp = join(dirname(path), `.${basename(path)}.${process.pid}.tmp`);
563
+ writeFileSync(tmp, bytes);
564
+ renameSync(tmp, path);
565
+ }
566
+ /**
567
+ * `app publish <dir> --key <file> [--out <file>]`: `<dir>/ghostly-app.json` (the manifest without `publisher`,
568
+ * `sequence` and `files`) and every other file in `<dir>` become one signed bundle, by default `<dir>/app.ghostlyapp`.
569
+ * The sequence is `--sequence` (higher than the bundle already at `--out`'s), else one more than that bundle's (or
570
+ * the source's `sequence`, if higher, else 1). The
571
+ * key file is made when it is missing and no earlier bundle is there to update.
572
+ */
573
+ async function publishApp(dirArg, keyArg, outArg, sequenceArg) {
574
+ if (sequenceArg !== void 0 && !(Number.isSafeInteger(sequenceArg) && sequenceArg >= 1)) throw new CliError("usage", `--sequence takes a whole number from 1, not ${sequenceArg}`);
575
+ const dir = resolve(dirArg);
576
+ if (!statSync(dir).isDirectory()) throw new CliError("bad_request", `${dir} is not a folder`);
577
+ const keyPath = resolve(keyArg);
578
+ 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" });
579
+ const out = resolve(outArg ?? join(dir, "app.ghostlyapp"));
580
+ const sourcePath = join(dir, APP_SOURCE);
581
+ const draft = readSource(sourcePath);
582
+ let previous = null;
583
+ if (existsSync(out)) {
584
+ const read = readAppBundle(readBundleFile(out));
585
+ if (!read.ok) throw refused(`${out} is there and is not a valid bundle: move it away, or name another --out`, read);
586
+ previous = read.bundle.manifest;
587
+ }
588
+ let key;
589
+ if (existsSync(keyPath)) key = readKey(keyPath, "publisher");
590
+ else if (previous) throw new CliError("not_found", `No key file ${keyPath}: ${out} is signed by ${appFingerprint(previous.publisher)}, and only that key publishes its next version`);
591
+ else key = makeKey(keyPath, "publisher");
592
+ if (previous && previous.publisher !== key.key) throw refused(`${out} is ${appRef(previous.publisher, previous.name)}, signed by another key (${appFingerprint(previous.publisher)}): a version of an app is signed by its own key`, { reason: "other-app" }, { publisher: previous.publisher });
593
+ if (previous && previous.name !== draft.name) throw refused(`${out} is the app ${JSON.stringify(previous.name)}, not ${JSON.stringify(draft.name)}: name another --out for another app`, { reason: "other-app" });
594
+ if (sequenceArg !== void 0 && previous && sequenceArg <= previous.sequence) throw refused(`--sequence ${sequenceArg} is not higher than the ${previous.sequence} of ${out}: every client refuses a lower one, and the same one with other content is equivocation`, { reason: sequenceArg < previous.sequence ? "rollback" : "equivocation" }, { previous: previous.sequence });
595
+ const sequence = sequenceArg ?? Math.max(previous ? previous.sequence + 1 : 1, draft.sequence ?? 1);
596
+ const { files, skipped } = collectFiles(dir, /* @__PURE__ */ new Set([
597
+ sourcePath,
598
+ out,
599
+ join(dir, APP_REVOKE_FILE)
600
+ ]));
601
+ const contents = files.map((f) => ({
602
+ path: f.path,
603
+ bytes: new Uint8Array(readFileSync(f.full))
604
+ }));
605
+ const keyFile = contents.find((f) => PRIVATE_KEY.test(Buffer.from(f.bytes).toString("latin1")));
606
+ 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`, {
607
+ reason: "private-key",
608
+ detail: keyFile.path
609
+ });
610
+ const manifest = {
611
+ ...draft,
612
+ ghostlyApp: 1,
613
+ publisher: key.key,
614
+ sequence,
615
+ files: contents.map((f) => ({
616
+ path: f.path,
617
+ size: f.bytes.length,
618
+ sha256: toBase64Url(createHash("sha256").update(f.bytes).digest())
619
+ }))
620
+ };
621
+ const checked = checkAppManifest(JSON.parse(JSON.stringify(manifest)), { strict: true });
622
+ if (!checked.ok) throw refused(`${sourcePath} and the folder's files make no valid manifest`, checked);
623
+ const { bytes: manifestBytes, signature } = await signAppObject(APP_PREFIXES.app, checked.manifest, key.signer);
624
+ const bundle = assembleAppBundle(manifestBytes, canonicalJsonBytes(signature), contents.map((f) => f.bytes));
625
+ const read = readAppBundle(bundle);
626
+ if (!read.ok) throw refused("The bundle made is not valid", read);
627
+ mkdirSync(dirname(out), { recursive: true });
628
+ writeWhole(out, bundle);
629
+ return {
630
+ ...described(read.bundle, bundle),
631
+ previous: previous?.sequence ?? null,
632
+ out,
633
+ files: contents.map((f) => f.path),
634
+ ...skipped.length ? { skipped } : {},
635
+ keyCreated: key.created,
636
+ ...key.created ? { warning: madeKeyNote(keyPath, "publisher", key.key) } : {}
637
+ };
638
+ }
639
+ /**
640
+ * `app revoke <dir> --key <file> (--digest <digest>... | --up-to <sequence>) [--reason <text>] [--bundle <file>]`: a
641
+ * `ghostly-revoke/1` statement (WISP 1200 · Publisher keys, Revocation) for the app whose bundle is `<dir>/app.ghostlyapp`
642
+ * (or `--bundle`), signed by that app's publisher key and added to `<dir>/ghostly-revoke.json`, which stays canonical.
643
+ * The key is never made here: only the key that signed the app may revoke its versions.
644
+ */
645
+ async function revokeApp(dirArg, keyArg, what) {
646
+ const dir = resolve(dirArg);
647
+ const bundlePath = resolve(what.bundle ?? join(dir, "app.ghostlyapp"));
648
+ const listPath = join(dir, APP_REVOKE_FILE);
649
+ const usage = "ghostly app revoke <dir> --key <file> (--digest <digest>... | --up-to <sequence>) [--reason <text>]";
650
+ const digests = what.digests ?? [];
651
+ if (digests.length > 0 === (what.upTo !== void 0)) throw new CliError("usage", `Give --digest (again for each) or --up-to, one of the two: ${usage}`);
652
+ if (what.upTo !== void 0 && !(Number.isSafeInteger(what.upTo) && what.upTo >= 1)) throw new CliError("usage", `--up-to takes a sequence, a whole number from 1, not ${what.upTo}`);
653
+ if (!existsSync(bundlePath)) throw new CliError("not_found", `No bundle ${bundlePath}: a revocation names the app of the bundle in <dir> (or --bundle)`);
654
+ const read = readAppBundle(readBundleFile(bundlePath));
655
+ if (!read.ok) throw refused(`${bundlePath} is not a valid bundle`, read);
656
+ const manifest = read.bundle.manifest;
657
+ const ref = appRef(manifest.publisher, manifest.name);
658
+ const keyPath = resolve(keyArg);
659
+ if (!existsSync(keyPath)) throw new CliError("not_found", `No key file ${keyPath}: only the key that signed ${ref} (${appFingerprint(manifest.publisher)}) revokes its versions`);
660
+ const key = readKey(keyPath, "publisher");
661
+ if (key.key !== manifest.publisher) throw refused(`${keyPath} is not the publisher key of ${ref} (${appFingerprint(manifest.publisher)}): a revocation by any other key is refused by every client`, { reason: "signature-key" });
662
+ const statement = digests.length ? {
663
+ ghostlyRevoke: 1,
664
+ app: ref,
665
+ digests
666
+ } : {
667
+ ghostlyRevoke: 1,
668
+ app: ref,
669
+ upTo: what.upTo
670
+ };
671
+ if (what.reason !== void 0) statement.reason = what.reason;
672
+ const problem = checkAppRevokeStatement(statement);
673
+ if (problem) throw refused("Not a revocation a client reads", {
674
+ reason: "bad-revocation",
675
+ detail: problem
676
+ });
677
+ let list = [];
678
+ if (existsSync(listPath)) {
679
+ const held = readAppRevocations(new Uint8Array(readFileSync(listPath)));
680
+ if (!held.ok) throw refused(`${listPath} is there and is not a valid ghostly-revoke.json: fix or move it first`, held);
681
+ list = held.revocations;
682
+ }
683
+ const signed = await signAppRevocation(statement, key.signer);
684
+ const same = list.some((r) => canonicalJson(r.statement) === canonicalJson(signed.statement));
685
+ if (!same) {
686
+ if (list.length >= APP_STATEMENT_LIMITS.revocations) throw refused(`${listPath} holds ${list.length} revocations already`, {
687
+ reason: "bad-revocation",
688
+ detail: `at most ${APP_STATEMENT_LIMITS.revocations}`
689
+ });
690
+ list = [...list, signed];
691
+ const bytes = canonicalJsonBytes(list);
692
+ const check = readAppRevocations(bytes);
693
+ if (!check.ok) throw refused("The revocation list made is not valid", check);
694
+ writeWhole(listPath, bytes);
695
+ }
696
+ return {
697
+ app: ref,
698
+ ...digests.length ? {
699
+ digests,
700
+ current: digests.includes(read.bundle.digest)
701
+ } : {
702
+ upTo: what.upTo,
703
+ current: manifest.sequence <= what.upTo
704
+ },
705
+ ...what.reason !== void 0 ? { reason: what.reason } : {},
706
+ added: !same,
707
+ revocations: list.length,
708
+ file: listPath
709
+ };
710
+ }
711
+ /**
712
+ * `store sign <index> --key <file> [--out <dir>]`: a store index written by hand (any JSON layout; `key` may be left
713
+ * out and is then the signing key's) becomes `ghostly-store.json`, canonical, and `ghostly-store.sig`, in `--out` or
714
+ * beside the index. It is read back as a client reads it (`expires` at most 90 days ahead), and refused when the index
715
+ * already there, signed by the same key, has a higher `sequence`, or the same one with other content.
716
+ */
717
+ async function signStore(indexArg, keyArg, outArg, now = Math.floor(Date.now() / 1e3)) {
718
+ const indexPath = resolve(indexArg);
719
+ const keyPath = resolve(keyArg);
720
+ const outDir = resolve(outArg ?? dirname(indexPath));
721
+ let value;
722
+ try {
723
+ value = JSON.parse(readFileSync(indexPath, "utf8"));
724
+ } catch (error) {
725
+ if (error instanceof SyntaxError) throw new CliError("bad_request", `${indexPath} is not JSON: ${error.message}`);
726
+ throw error;
727
+ }
728
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new CliError("bad_request", `${indexPath} is not a JSON object`);
729
+ const index = value;
730
+ let key;
731
+ if (existsSync(keyPath)) key = readKey(keyPath, "store");
732
+ else if (index.key !== void 0) throw new CliError("not_found", `No key file ${keyPath}: the index names the store key ${typeof index.key === "string" ? appFingerprint(index.key) : JSON.stringify(index.key)}`);
733
+ else key = makeKey(keyPath, "store");
734
+ 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" });
735
+ index.key = key.key;
736
+ const checked = checkAppStoreIndex(index, { strict: true });
737
+ if (!checked.ok) throw refused(`${indexPath} is not a valid store index`, checked);
738
+ const { indexBytes, sigBytes } = await signAppStore(index, key.signer);
739
+ const read = readAppStore(indexBytes, sigBytes, now, key.key);
740
+ if (!read.ok) throw refused(`${indexPath} would be refused by a client`, read);
741
+ const store = read.store;
742
+ const indexOut = join(outDir, STORE_INDEX_FILE), sigOut = join(outDir, STORE_SIG_FILE);
743
+ let previous = null;
744
+ if (existsSync(indexOut) && existsSync(sigOut)) {
745
+ const held = readAppStore(new Uint8Array(readFileSync(indexOut)), new Uint8Array(readFileSync(sigOut)), now);
746
+ if (held.ok && held.store.index.key === key.key) {
747
+ previous = held.store.index.sequence;
748
+ const decision = appStoreDecision({
749
+ key: key.key,
750
+ sequence: held.store.index.sequence,
751
+ digest: held.store.digest
752
+ }, {
753
+ key: key.key,
754
+ sequence: store.index.sequence,
755
+ digest: store.digest
756
+ });
757
+ if (decision === "rollback" || decision === "equivocation") throw refused(`${indexOut} already holds sequence ${held.store.index.sequence} of this store: ${decision === "rollback" ? "a lower one is refused by every client" : "the same one with other content is refused as equivocation"}; raise sequence`, { reason: decision }, { held: held.store.index.sequence });
758
+ }
759
+ }
760
+ mkdirSync(outDir, { recursive: true });
761
+ writeWhole(indexOut, indexBytes);
762
+ writeWhole(sigOut, sigBytes);
763
+ const notes = [...key.created ? [madeKeyNote(keyPath, "store", key.key)] : [], ...store.expired ? [`expires (${(/* @__PURE__ */ new Date(store.index.expires * 1e3)).toISOString()}) is past: clients still install from it and say the store was not updated since then`] : []];
764
+ return {
765
+ key: key.key,
766
+ fingerprint: appFingerprint(key.key),
767
+ name: store.index.name,
768
+ kind: store.index.kind,
769
+ sequence: store.index.sequence,
770
+ previous,
771
+ expires: store.index.expires,
772
+ expired: store.expired,
773
+ apps: store.index.apps.length,
774
+ removed: store.index.removed.length,
775
+ revoked: store.index.revoked.length,
776
+ digest: store.digest,
777
+ index: indexOut,
778
+ sig: sigOut,
779
+ keyCreated: key.created,
780
+ ...notes.length ? { warning: notes.join("; ") } : {}
781
+ };
782
+ }
783
+ //#endregion
784
+ export { STORE_SIG_FILE as a, publishApp as c, verifyApp as d, STORE_INDEX_FILE as i, revokeApp as l, APP_REVOKE_FILE as n, checkedBundle as o, APP_SOURCE as r, initApp as s, APP_BUNDLE_FILE as t, signStore as u };
785
+
786
+ //# sourceMappingURL=apps-BxV9i_Y4.js.map