@needmoretruth/nmts-cli 0.17.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (151) hide show
  1. package/AGENTS.md +591 -0
  2. package/LICENSE +202 -0
  3. package/LICENSING.md +49 -0
  4. package/README.ko.md +641 -0
  5. package/README.md +657 -0
  6. package/dist/account-proof.js +78 -0
  7. package/dist/account.js +75 -0
  8. package/dist/api-key.js +310 -0
  9. package/dist/api.js +271 -0
  10. package/dist/args.js +137 -0
  11. package/dist/artifact-about.js +70 -0
  12. package/dist/autonomy.js +98 -0
  13. package/dist/code-access.js +76 -0
  14. package/dist/code-vault.js +233 -0
  15. package/dist/collision.js +133 -0
  16. package/dist/commands/balance.js +93 -0
  17. package/dist/commands/consent.js +77 -0
  18. package/dist/commands/create.js +356 -0
  19. package/dist/commands/env.js +136 -0
  20. package/dist/commands/expiring.js +167 -0
  21. package/dist/commands/extend.js +236 -0
  22. package/dist/commands/get.js +130 -0
  23. package/dist/commands/kit.js +136 -0
  24. package/dist/commands/listfile.js +105 -0
  25. package/dist/commands/login.js +335 -0
  26. package/dist/commands/logout.js +33 -0
  27. package/dist/commands/ls.js +202 -0
  28. package/dist/commands/marks.js +176 -0
  29. package/dist/commands/mcp.js +139 -0
  30. package/dist/commands/mode.js +54 -0
  31. package/dist/commands/on-collision.js +45 -0
  32. package/dist/commands/organise.js +296 -0
  33. package/dist/commands/public-code.js +89 -0
  34. package/dist/commands/pull.js +185 -0
  35. package/dist/commands/push.js +278 -0
  36. package/dist/commands/put.js +282 -0
  37. package/dist/commands/rebuild.js +160 -0
  38. package/dist/commands/receive.js +125 -0
  39. package/dist/commands/recovery-list.js +104 -0
  40. package/dist/commands/recovery.js +291 -0
  41. package/dist/commands/s3.js +241 -0
  42. package/dist/commands/settings.js +28 -0
  43. package/dist/commands/share.js +276 -0
  44. package/dist/commands/sweep.js +240 -0
  45. package/dist/commands/trash.js +288 -0
  46. package/dist/commands/trial.js +264 -0
  47. package/dist/commands/update.js +169 -0
  48. package/dist/commands/usage.js +84 -0
  49. package/dist/commands/verify.js +331 -0
  50. package/dist/commands/wallet.js +122 -0
  51. package/dist/commands/whoami.js +33 -0
  52. package/dist/consent.js +200 -0
  53. package/dist/credentials.js +311 -0
  54. package/dist/crypto-surface.js +54 -0
  55. package/dist/crypto.js +137 -0
  56. package/dist/download-part.js +144 -0
  57. package/dist/download-sink.js +213 -0
  58. package/dist/download.js +163 -0
  59. package/dist/drive-paths.js +170 -0
  60. package/dist/environment.js +190 -0
  61. package/dist/errors.js +71 -0
  62. package/dist/exit.js +107 -0
  63. package/dist/expiry.js +115 -0
  64. package/dist/extend-chain.js +137 -0
  65. package/dist/extend-plan.js +149 -0
  66. package/dist/extend-sign.js +130 -0
  67. package/dist/guards.js +15 -0
  68. package/dist/help.js +192 -0
  69. package/dist/human-check.js +54 -0
  70. package/dist/item-trash.js +25 -0
  71. package/dist/kit-file.js +133 -0
  72. package/dist/list-file.js +71 -0
  73. package/dist/list-view-find.js +71 -0
  74. package/dist/list-view-order.js +96 -0
  75. package/dist/main.js +394 -0
  76. package/dist/manifest-create.js +81 -0
  77. package/dist/manifest-write.js +244 -0
  78. package/dist/manifest.js +213 -0
  79. package/dist/mark-render.js +45 -0
  80. package/dist/mcp-args.js +154 -0
  81. package/dist/mcp-tools/context.js +50 -0
  82. package/dist/mcp-tools/files.js +133 -0
  83. package/dist/mcp-tools/organise.js +128 -0
  84. package/dist/mcp-tools/reads.js +89 -0
  85. package/dist/mcp-tools/share.js +61 -0
  86. package/dist/mcp.js +142 -0
  87. package/dist/net-retry.js +84 -0
  88. package/dist/network.js +43 -0
  89. package/dist/notice.js +45 -0
  90. package/dist/product.js +38 -0
  91. package/dist/progress.js +110 -0
  92. package/dist/prompt.js +145 -0
  93. package/dist/rebuild.js +255 -0
  94. package/dist/recovery-assemble.js +148 -0
  95. package/dist/recovery-build.js +260 -0
  96. package/dist/recovery-map-file.js +114 -0
  97. package/dist/recovery-map.js +191 -0
  98. package/dist/recovery-release.js +229 -0
  99. package/dist/recovery-seq.js +63 -0
  100. package/dist/recovery-source.js +143 -0
  101. package/dist/registration.js +83 -0
  102. package/dist/s3/listing.js +134 -0
  103. package/dist/s3/multipart.js +104 -0
  104. package/dist/s3/response-sink.js +41 -0
  105. package/dist/s3/same-file.js +117 -0
  106. package/dist/s3/server.js +252 -0
  107. package/dist/s3/sigv4.js +168 -0
  108. package/dist/s3/staging.js +87 -0
  109. package/dist/s3/xml.js +80 -0
  110. package/dist/safe-path.js +89 -0
  111. package/dist/seal.js +206 -0
  112. package/dist/secret-reader.js +112 -0
  113. package/dist/server.js +33 -0
  114. package/dist/session.js +49 -0
  115. package/dist/setup-questions.js +31 -0
  116. package/dist/share.js +195 -0
  117. package/dist/shared/lib/crypto/size-padding.js +126 -0
  118. package/dist/shared/lib/drive/manifest-codec.js +287 -0
  119. package/dist/shared/lib/drive/manifest-index.js +235 -0
  120. package/dist/shared/lib/drive/manifest-ops.js +267 -0
  121. package/dist/shared/lib/drive/manifest-settings.js +65 -0
  122. package/dist/shared/lib/drive/name-conflict.js +121 -0
  123. package/dist/shared/lib/drive/unique-name.js +48 -0
  124. package/dist/shared/lib/extend/epochs.js +256 -0
  125. package/dist/shared/lib/net/retry-budget.js +95 -0
  126. package/dist/shared/lib/share/shared-file-info.js +65 -0
  127. package/dist/shared/lib/storage-network.js +64 -0
  128. package/dist/shared/lib/upload/part-plan.js +28 -0
  129. package/dist/stdout.js +109 -0
  130. package/dist/trash-sweep.js +123 -0
  131. package/dist/units.js +19 -0
  132. package/dist/update-check.js +164 -0
  133. package/dist/update-source.js +143 -0
  134. package/dist/upload-api.js +98 -0
  135. package/dist/upload-file.js +242 -0
  136. package/dist/upload-price.js +117 -0
  137. package/dist/upload-steps.js +111 -0
  138. package/dist/upload-store.js +252 -0
  139. package/dist/upload-wire.js +25 -0
  140. package/dist/upload.js +294 -0
  141. package/dist/usage-report.js +54 -0
  142. package/dist/wallet-chain.js +76 -0
  143. package/dist/wallet.js +209 -0
  144. package/dist/walrus-write.js +188 -0
  145. package/dist/walrus.js +156 -0
  146. package/package.json +56 -0
  147. package/vendor/nmts-crypto/nmts_crypto_wasm.d.ts +469 -0
  148. package/vendor/nmts-crypto/nmts_crypto_wasm.js +1300 -0
  149. package/vendor/nmts-crypto/nmts_crypto_wasm_bg.wasm +0 -0
  150. package/vendor/nmts-crypto/nmts_crypto_wasm_bg.wasm.d.ts +55 -0
  151. package/vendor/nmts-crypto/package.json +17 -0
@@ -0,0 +1,276 @@
1
+ // `nmts share`, `nmts shares`, `nmts unshare` — giving one file to one other account.
2
+ //
3
+ // ⛔ THE ADDRESS IS TYPED AND NOT LOOKED UP AGAINST A PERSON. There is no directory, no name, no
4
+ // confirmation that whoever holds an address is who the sender thinks. What stops a typo is the
5
+ // check symbol built into the address, and it is checked on this machine before the server is
6
+ // asked anything — a mistyped address must not become a question about somebody who might exist.
7
+ //
8
+ // ⛔ WITHDRAWING DOES NOT RECALL. It removes the wrapped key and the download path; a copy already
9
+ // fetched is gone from our reach. Nothing printed here may suggest otherwise.
10
+ //
11
+ // ⛔ THE CLAIMED SENDER IS PRINTED ONLY AFTER THE ENVELOPE OPENED. Opening it IS the proof of who
12
+ // sent it, so a row that did not open is listed with no sender at all rather than with the name
13
+ // it claims.
14
+ import { requireConsent } from "../consent.js";
15
+ import { loadCrypto } from "../crypto.js";
16
+ import { buildIndex, entryAt, fullPathOf, KIND_FILE, normalisePath } from "../drive-paths.js";
17
+ import { NmtsError } from "../errors.js";
18
+ import { readFileList } from "../manifest.js";
19
+ import { BINARY_NAME } from "../product.js";
20
+ import { request, ServerError } from "../api.js";
21
+ import { openSession } from "../session.js";
22
+ import { addressFromTyped, identityMatches, openReceived, sealShare, shareKeysOf, } from "../share.js";
23
+ import { AAD, DERIVED } from "../crypto.js";
24
+ function out(options) {
25
+ return options.write ?? ((line) => process.stdout.write(`${line}\n`));
26
+ }
27
+ function b64(bytes) {
28
+ return Buffer.from(bytes).toString("base64url");
29
+ }
30
+ /**
31
+ * Make sure this account has published the identity other people encrypt to.
32
+ *
33
+ * ⛔ IT IS THE SAME BYTES EVERY TIME. The identity is derived from the account code, so a browser
34
+ * and this tool publish something identical for one account — which is why publishing from here
35
+ * cannot claim a different account's place or overwrite anything meaningful.
36
+ */
37
+ async function ensurePublished(session, identity, address, say, quiet) {
38
+ const seen = await request(session.server, "/v1/account/share-identity", {
39
+ token: session.apiKey,
40
+ });
41
+ const published = typeof seen === "object" && seen !== null && Reflect.get(seen, "published") === true;
42
+ if (published)
43
+ return;
44
+ if (!quiet)
45
+ say(` publishing this account's public code for the first time`);
46
+ await request(session.server, "/v1/account/share-identity", {
47
+ token: session.apiKey,
48
+ method: "PUT",
49
+ body: { share_public_key: b64(identity), share_address: b64(address) },
50
+ });
51
+ }
52
+ /** `nmts share <path> <address>` — hand one file to one account. */
53
+ export async function share(target, typedAddress, options = {}) {
54
+ const say = out(options);
55
+ if (target === undefined || target === "" || typedAddress === undefined || typedAddress === "") {
56
+ throw new NmtsError("Say which file, and which public code to share it with.", {
57
+ exitCode: 2,
58
+ nextStep: `\`${BINARY_NAME} share <path> <public-code>\` — the path as \`${BINARY_NAME} ls\` prints it, `
59
+ + `and the code as their \`${BINARY_NAME} public-code\` or account screen prints it.`,
60
+ });
61
+ }
62
+ const crypt = await loadCrypto();
63
+ // ⛔ BEFORE ANYTHING ELSE, AND BEFORE THE NETWORK. A typo caught here costs nothing; a typo sent
64
+ // to the recipient lookup asks the server a question about an account that is not ours to ask.
65
+ const recipientAddress = addressFromTyped(crypt, typedAddress);
66
+ const session = await openSession({ server: options.server, network: options.network });
67
+ const list = await readFileList(session.server, session.apiKey, session.code, session.accountId);
68
+ if (list.manifest === null) {
69
+ throw new NmtsError("This account has no file list, so there is nothing to share.", { exitCode: 4 });
70
+ }
71
+ const index = buildIndex(list.manifest.entries);
72
+ const entry = entryAt(list.manifest.entries, normalisePath(target), {
73
+ nothingHappened: "Nothing was shared.",
74
+ });
75
+ if (entry.kind !== KIND_FILE) {
76
+ throw new NmtsError(`No file at "${fullPathOf(index, entry)}".`, {
77
+ exitCode: 4,
78
+ nextStep: "That is a folder. Nothing was shared — this version shares one file at a time.",
79
+ });
80
+ }
81
+ if (entry.dekWrapped === undefined || entry.contentHashCt === undefined) {
82
+ throw new NmtsError(`The file list holds no ${entry.dekWrapped === undefined ? "key" : "hash"} for "${entry.name}".`, {
83
+ exitCode: 4,
84
+ nextStep: "A share carries both — the key that opens the file and the hash the recipient checks the " +
85
+ "bytes against. Nothing was shared.",
86
+ });
87
+ }
88
+ // ⛔ ASKED AFTER THE FILE IS KNOWN AND BEFORE ANYTHING LEAVES, so the question names a real file.
89
+ requireConsent("share");
90
+ const keys = shareKeysOf(crypt, session.code);
91
+ const derived = crypt.kdf_derive(crypt.account_code_parse(session.code));
92
+ const dataKey = derived.slice(DERIVED.dataKey[0], DERIVED.dataKey[1]);
93
+ derived.fill(0);
94
+ let dek = null;
95
+ let digest = null;
96
+ try {
97
+ dek = crypt.envelope_open(dataKey, new TextEncoder().encode(AAD.dekWrap), new Uint8Array(Buffer.from(entry.dekWrapped, "base64url")));
98
+ digest = crypt.envelope_open(dataKey, new TextEncoder().encode(AAD.contentHash), new Uint8Array(Buffer.from(entry.contentHashCt, "base64url")));
99
+ dataKey.fill(0);
100
+ await ensurePublished(session, keys.identity, keys.address, say, options.json === true);
101
+ const answer = await request(session.server, `/v1/share-recipients/${encodeURIComponent(b64(recipientAddress))}`, { token: session.apiKey });
102
+ const identityB64 = typeof answer === "object" && answer !== null ? Reflect.get(answer, "share_public_key") : null;
103
+ if (typeof identityB64 !== "string") {
104
+ throw new NmtsError("That public code has never published an identity to share to.", {
105
+ exitCode: 4,
106
+ nextStep: "Nothing was shared. The code may be right and simply unused — somebody has to open " +
107
+ "their account once before anything can be encrypted to them.",
108
+ });
109
+ }
110
+ const recipientIdentity = new Uint8Array(Buffer.from(identityB64, "base64url"));
111
+ // ⛔ CHECKED BEFORE ANYTHING IS ENCRYPTED TO IT. The engine checks it again inside the wrap;
112
+ // this exists so the refusal names WHICH thing was wrong.
113
+ if (!identityMatches(crypt, recipientIdentity, recipientAddress)) {
114
+ throw new NmtsError("The identity the server returned is not the one that public code names.", {
115
+ exitCode: 1,
116
+ nextStep: "Nothing was shared. Encrypting to it would hand the file to somebody else.",
117
+ });
118
+ }
119
+ const payload = sealShare(crypt, {
120
+ keys,
121
+ recipientIdentity,
122
+ recipientAddress,
123
+ dek,
124
+ itemId: entry.id,
125
+ name: entry.name,
126
+ size: entry.size,
127
+ digest,
128
+ });
129
+ const created = await request(session.server, "/v1/shares", {
130
+ token: session.apiKey,
131
+ method: "POST",
132
+ body: { item_id: entry.id, recipient_address: b64(recipientAddress), ...payload },
133
+ });
134
+ // ⛔ READ BACK, NOT ECHOED. The server returns the address it actually STORED the share under.
135
+ // Comparing it is the only way to notice a share that was filed against somebody else.
136
+ const stored = typeof created === "object" && created !== null
137
+ ? Reflect.get(created, "recipient_address")
138
+ : null;
139
+ if (typeof stored !== "string" || stored !== b64(recipientAddress)) {
140
+ throw new NmtsError("The server filed this share against a different public code.", {
141
+ exitCode: 1,
142
+ nextStep: `Withdraw it now with \`${BINARY_NAME} unshare\` and share it again. The file's key was ` +
143
+ `wrapped for the address you typed, so it is not readable by whoever it was filed under.`,
144
+ });
145
+ }
146
+ const id = typeof created === "object" && created !== null ? Reflect.get(created, "id") : null;
147
+ if (options.json) {
148
+ say(JSON.stringify({ id, name: entry.name, recipient: crypt.share_address_display(recipientAddress) }));
149
+ return 0;
150
+ }
151
+ say(`${entry.name} → ${crypt.share_address_display(recipientAddress)}`);
152
+ say(``);
153
+ say(` They can download it from now on. Withdrawing the share stops further downloads`);
154
+ say(` and cannot reach a copy they have already taken.`);
155
+ return 0;
156
+ }
157
+ catch (error) {
158
+ if (error instanceof ServerError && error.status === 403) {
159
+ throw new NmtsError(error.message, {
160
+ exitCode: 3,
161
+ nextStep: "This account's key is not allowed to do this. Sharing asks for the strongest evidence " +
162
+ `the server holds that a person was recently here — run \`${BINARY_NAME} verify\` and try again.`,
163
+ });
164
+ }
165
+ throw error;
166
+ }
167
+ finally {
168
+ dataKey.fill(0);
169
+ dek?.fill(0);
170
+ digest?.fill(0);
171
+ keys.wipe();
172
+ }
173
+ }
174
+ /** `nmts shares` — what was shared with this account, and what it shared. */
175
+ export async function shares(options = {}) {
176
+ const say = out(options);
177
+ const session = await openSession({ server: options.server, network: options.network });
178
+ const crypt = await loadCrypto();
179
+ const keys = shareKeysOf(crypt, session.code);
180
+ try {
181
+ const received = asReceived(await request(session.server, "/v1/shares/received", { token: session.apiKey }));
182
+ const opened = received.rows.map((row) => openReceived(crypt, keys, row));
183
+ for (const one of opened)
184
+ one.dek?.fill(0);
185
+ if (options.json) {
186
+ say(JSON.stringify({
187
+ received: opened.map((o) => ({
188
+ id: o.id,
189
+ name: o.name,
190
+ size: o.size,
191
+ sender: o.sender,
192
+ createdAt: o.createdAt,
193
+ problem: o.problem,
194
+ })),
195
+ total: received.total,
196
+ }));
197
+ return 0;
198
+ }
199
+ if (opened.length === 0) {
200
+ say(`Nothing has been shared with this account.`);
201
+ }
202
+ else {
203
+ say(`Shared with this account:`);
204
+ for (const one of opened)
205
+ printRow(say, one);
206
+ // ⛔ SAID OUT LOUD. The listing is bounded, and a person who is not told cannot know that
207
+ // what they are looking at is not everything.
208
+ if (received.total > opened.length) {
209
+ say(``);
210
+ say(` ${received.total} in total; this shows ${opened.length}.`);
211
+ }
212
+ }
213
+ say(``);
214
+ say(` \`${BINARY_NAME} receive <id>\` downloads one. \`${BINARY_NAME} unshare <id>\` removes it`);
215
+ say(` from this list, which does not touch the sender's own file.`);
216
+ return 0;
217
+ }
218
+ finally {
219
+ keys.wipe();
220
+ }
221
+ }
222
+ function printRow(say, one) {
223
+ if (one.problem !== null) {
224
+ say(` ${one.id} (will not open: ${one.problem})`);
225
+ return;
226
+ }
227
+ const size = one.size === null ? "" : ` ${one.size} bytes`;
228
+ say(` ${one.id} ${one.name ?? ""}${size}`);
229
+ say(` from ${one.sender ?? ""}`);
230
+ }
231
+ /** `nmts unshare <id>` — withdraw a share you sent, or remove one you were sent. */
232
+ export async function unshare(id, options = {}) {
233
+ const say = out(options);
234
+ if (id === undefined || id === "") {
235
+ throw new NmtsError("Say which share to remove.", {
236
+ exitCode: 2,
237
+ nextStep: `\`${BINARY_NAME} unshare <id>\` — the id \`${BINARY_NAME} shares\` prints.`,
238
+ });
239
+ }
240
+ const session = await openSession({ server: options.server, network: options.network });
241
+ await request(session.server, `/v1/shares/${encodeURIComponent(id)}`, {
242
+ token: session.apiKey,
243
+ method: "DELETE",
244
+ });
245
+ if (options.json) {
246
+ say(JSON.stringify({ id, removed: true }));
247
+ return 0;
248
+ }
249
+ say(`Removed ${id}.`);
250
+ say(``);
251
+ say(` If you sent it, they can no longer download the file — and any copy they already`);
252
+ say(` took is still theirs. If you received it, the sender's own file is untouched.`);
253
+ return 0;
254
+ }
255
+ function asReceived(value) {
256
+ if (typeof value !== "object" || value === null) {
257
+ throw new NmtsError("The server's answer was not an object.");
258
+ }
259
+ const rows = Reflect.get(value, "shares");
260
+ if (!Array.isArray(rows))
261
+ throw new NmtsError("The server did not list any shares.");
262
+ const total = Reflect.get(value, "total");
263
+ return {
264
+ rows: rows.filter(isRow),
265
+ total: typeof total === "number" ? total : rows.length,
266
+ };
267
+ }
268
+ function isRow(value) {
269
+ if (typeof value !== "object" || value === null)
270
+ return false;
271
+ for (const name of ["id", "item_id", "dek_share_ct", "name_share_ct", "content_hash_share_ct"]) {
272
+ if (typeof Reflect.get(value, name) !== "string")
273
+ return false;
274
+ }
275
+ return true;
276
+ }
@@ -0,0 +1,240 @@
1
+ // `nmts sweep` — dropping the entries whose thirty days in the trash have run out.
2
+ //
3
+ // ⛔ IT IS THE ONLY THING THIS TOOL DOES THAT CANNOT BE UNDONE, and that is why it is a command
4
+ // somebody types rather than something that happens. A browser runs this sweep at every sign-in,
5
+ // which is safe there because a person is standing in front of it and can see the drive. Here
6
+ // the caller is often a program in a loop on a machine whose clock nobody has checked, and the
7
+ // rule the sweep runs on is "thirty days by this machine's clock". A date set a year forward
8
+ // would empty the whole trash — and if the sweep were attached to `ls`, it would do it without
9
+ // any command about deletion ever having been typed.
10
+ //
11
+ // ⛔ SO IT STOPS, AND `--yes` IS WHAT ANSWERS. Not one of the once-per-machine agreements in
12
+ // `consent.ts`: those are the right shape for a capability (this account may spend, this machine
13
+ // may hold the code unsealed) and the wrong shape for an act. Granted once, a `sweep` key would
14
+ // make every future sweep silent, which is no better than the automatic version it was meant to
15
+ // replace. What has to be decided is not "may this tool ever sweep" but "may it drop THESE
16
+ // entries, today, on this clock" — so the answer is per run, and the run that is refused prints
17
+ // exactly what it would have dropped.
18
+ //
19
+ // ⛔ THE SERVER GOES FIRST, AND HERE THAT MEANS WAITING FOR IT RATHER THAN CALLING IT. The endpoint
20
+ // that erases a row for good refuses an API key on purpose — reversible is reachable, permanent
21
+ // is not — so this side cannot do the server's half and must not pretend the order does not
22
+ // matter. It asks what the server still holds, and anything still there stays in the list.
23
+ //
24
+ // ⛔ AND IT DROPS WHOLE BRANCHES. See `trash-sweep.ts`: a folder dropped while a file under it is
25
+ // kept leaves that file with no trashed ancestor, which reads as LIVE for ever after.
26
+ import { request } from "../api.js";
27
+ import { buildIndex, fullPathOf, trashedAt } from "../drive-paths.js";
28
+ import { NmtsError } from "../errors.js";
29
+ import { readFileList } from "../manifest.js";
30
+ import { applyToList } from "../manifest-write.js";
31
+ import { BINARY_NAME } from "../product.js";
32
+ import { openSession } from "../session.js";
33
+ import { expiredTrashEntries, filesAmong, planPurge, TRASH_RETENTION_DAYS, } from "../trash-sweep.js";
34
+ const DAY_MS = 86_400_000;
35
+ /**
36
+ * How many pages of the server's item listing one sweep will read.
37
+ *
38
+ * ⛔ THERE IS A CEILING BECAUSE "ABSENT FROM A PARTIAL LISTING" IS NOT "ERASED". The whole
39
+ * safeguard below rests on having seen every row the server still holds, so a listing that ran
40
+ * out of patience must stop the sweep rather than shorten it. A hundred rows a page puts the
41
+ * limit well past any drive a command-line tool is the right way to manage.
42
+ */
43
+ const MAX_PAGES = 200;
44
+ export async function sweep(options = {}) {
45
+ const say = options.write ?? ((line) => process.stdout.write(`${line}\n`));
46
+ const now = options.now ?? Date.now();
47
+ const session = await openSession(options);
48
+ const list = await readFileList(session.server, session.apiKey, session.code, session.accountId);
49
+ const entries = list.manifest?.entries ?? [];
50
+ const index = buildIndex(entries);
51
+ const expired = expiredTrashEntries(index, now);
52
+ if (expired.length === 0) {
53
+ // ⚠ The server is not asked at all in this case. It is the ordinary one, it costs a round trip,
54
+ // and there is nothing its answer could change.
55
+ if (options.json) {
56
+ // The same keys the other paths print, so a caller never has to branch on which one ran.
57
+ say(JSON.stringify({
58
+ expired: 0,
59
+ readyToDrop: 0,
60
+ dropped: 0,
61
+ waitingOnServer: 0,
62
+ heldWholeBranch: 0,
63
+ changed: false,
64
+ entries: [],
65
+ }));
66
+ return 0;
67
+ }
68
+ say(`Nothing in the trash has passed its ${TRASH_RETENTION_DAYS} days.`);
69
+ return 0;
70
+ }
71
+ const held = await serverItemIds(session.server, session.apiKey);
72
+ if (!held.complete) {
73
+ throw new NmtsError(`This account has more stored files than one sweep will read.`, {
74
+ exitCode: 4,
75
+ nextStep: `Nothing was changed. Dropping an entry is only safe once the server has been seen to let ` +
76
+ `go of its own copy of the key, and a listing that stopped early cannot show that. The ` +
77
+ `browser sweeps the same entries at sign-in.`,
78
+ });
79
+ }
80
+ const plan = planPurge(index, expired, held.ids);
81
+ const shown = (e) => fullPathOf(index, e);
82
+ const ageDays = (e) => Math.floor((now - (trashedAt(index, e) ?? now)) / DAY_MS);
83
+ // ⛔ THE MACHINE-READABLE ANSWER IS PRINTED ONCE, ON WHICHEVER PATH THE RUN TAKES. Printing it up
84
+ // front and then carrying on would put two JSON objects on one stream, and a caller reading
85
+ // the first would be told `changed: false` about a run that went on to change the list.
86
+ const summary = (dropped, changed, extra = {}) => JSON.stringify({
87
+ expired: expired.length,
88
+ readyToDrop: plan.drop.length,
89
+ dropped,
90
+ waitingOnServer: plan.waiting.length,
91
+ heldWholeBranch: plan.tangled.length,
92
+ changed,
93
+ ...extra,
94
+ entries: describe(plan, shown, ageDays),
95
+ });
96
+ if (plan.drop.length === 0) {
97
+ if (options.json)
98
+ say(summary(0, false));
99
+ else
100
+ reportHeldBack(say, plan);
101
+ return 0;
102
+ }
103
+ if (options.yes !== true) {
104
+ if (options.json) {
105
+ say(summary(0, false));
106
+ return 5;
107
+ }
108
+ const files = filesAmong(plan.drop);
109
+ say(`${plan.drop.length} thing${plan.drop.length === 1 ? "" : "s"} in the trash passed ` +
110
+ `${TRASH_RETENTION_DAYS} days and can be dropped from the file list (${files} of them ` +
111
+ `${files === 1 ? "is a file" : "are files"}).`);
112
+ say(``);
113
+ const width = Math.max(...plan.drop.map((e) => shown(e).length));
114
+ for (const entry of plan.drop)
115
+ say(` ${shown(entry).padEnd(width)} trashed ${ageDays(entry)} days ago`);
116
+ say(``);
117
+ say(`⛔ This cannot be undone. Dropping an entry destroys this account's only remaining copy of`);
118
+ say(` the key that opens that file — the server has already destroyed its own — so nothing`);
119
+ say(` anywhere will open those bytes again. The bytes themselves stay on the storage network`);
120
+ say(` until the term already paid for runs out; no refund follows from this.`);
121
+ say(``);
122
+ reportHeldBack(say, plan);
123
+ say(`Nothing was changed. To go ahead: ${BINARY_NAME} sweep --yes`);
124
+ say(`⛔ If a program is reading this on somebody's behalf: show it to them and let them decide.`);
125
+ return 5;
126
+ }
127
+ // ⛔ RE-DECIDED AGAINST THE LIST AS IT IS ON THIS ATTEMPT, and never wider than what was planned.
128
+ // A lost compare-and-swap means another device wrote in between, and it may have restored one
129
+ // of these — replaying a fixed set of ids would then purge something that is out of the trash.
130
+ // The intersection with `promised` is the other half: a run may do less than it announced, and
131
+ // may never do more.
132
+ const promised = new Set(plan.drop.map((e) => e.id));
133
+ let dropped = 0;
134
+ const result = await applyToList(session, (current) => {
135
+ const fresh = buildIndex(current);
136
+ const again = planPurge(fresh, expiredTrashEntries(fresh, now), held.ids);
137
+ const ids = again.drop.map((e) => e.id).filter((id) => promised.has(id));
138
+ dropped = ids.length;
139
+ return ids.length === 0 ? null : { op: "purge", ids };
140
+ });
141
+ if (options.json) {
142
+ say(summary(dropped, result.changed, { reappliedAfterConflict: result.reappliedAfterConflict, seq: result.seq }));
143
+ return 0;
144
+ }
145
+ say(dropped === 0
146
+ ? `Nothing was dropped: the file list changed while this ran and none of them is still due.`
147
+ : `Dropped ${dropped} entr${dropped === 1 ? "y" : "ies"} from the file list.`);
148
+ if (result.reappliedAfterConflict) {
149
+ say(` Another device wrote the file list first, so this was applied to that version.`);
150
+ }
151
+ reportHeldBack(say, plan);
152
+ return 0;
153
+ }
154
+ /** The entries this run deliberately left alone, each with the reason it was left. */
155
+ function reportHeldBack(say, plan) {
156
+ if (plan.waiting.length > 0) {
157
+ say(`${plan.waiting.length} more ${plan.waiting.length === 1 ? "is" : "are"} past ` +
158
+ `${TRASH_RETENTION_DAYS} days and stayed: the server still holds their rows, so the key it ` +
159
+ `keeps is still there. It sweeps its own side on a timer — running this again later finishes them.`);
160
+ }
161
+ if (plan.tangled.length > 0) {
162
+ say(`${plan.tangled.length} folder${plan.tangled.length === 1 ? "" : "s"} above ` +
163
+ `${plan.tangled.length === 1 ? "one of them" : "those"} stayed too. Dropping a folder while ` +
164
+ `something under it remains would leave that thing with nothing marking it as trash, and it ` +
165
+ `would read as a live file this tool can never fetch.`);
166
+ }
167
+ }
168
+ /** One row per expired entry, saying what happened to it. For the machine-readable answer. */
169
+ function describe(plan, shown, ageDays) {
170
+ const rows = [];
171
+ for (const [action, group] of [
172
+ ["drop", plan.drop],
173
+ ["waiting", plan.waiting],
174
+ ["tangled", plan.tangled],
175
+ ]) {
176
+ for (const entry of group) {
177
+ rows.push({ id: entry.id, path: shown(entry), daysInTrash: ageDays(entry), action });
178
+ }
179
+ }
180
+ return rows;
181
+ }
182
+ /**
183
+ * Every item id the server still holds a row for, live or trashed.
184
+ *
185
+ * ⛔ `/v1/objects` AND NOT `/v1/items?deleted=true`. The trash listing stops at the retention
186
+ * window, so a row that is past thirty days and not yet erased is missing from it — and
187
+ * "missing" is exactly the answer this function must not get wrong. `/v1/objects` is the
188
+ * reconciliation view: every row of the account, with no window on it.
189
+ *
190
+ * ⚠ The two calls are written out rather than built from a variable so that the gate comparing
191
+ * this tool's addresses against the server's routes can see them both. It reads literal strings.
192
+ */
193
+ async function serverItemIds(base, apiKey) {
194
+ const ids = new Set();
195
+ let cursor = null;
196
+ for (let page = 0; page < MAX_PAGES; page += 1) {
197
+ const answer = cursor === null
198
+ ? await request(base, "/v1/objects", { token: apiKey })
199
+ : await request(base, `/v1/objects?after=${encodeURIComponent(cursor)}`, { token: apiKey });
200
+ const read = asObjectsAnswer(answer);
201
+ for (const id of read.ids)
202
+ ids.add(id);
203
+ if (read.next === null)
204
+ return { ids, complete: true };
205
+ cursor = read.next;
206
+ }
207
+ return { ids, complete: false };
208
+ }
209
+ /**
210
+ * What `GET /v1/objects` answers, narrowed rather than trusted.
211
+ *
212
+ * ⛔ A ROW THIS CANNOT READ IS A REFUSAL. Skipping one would shrink the set of things the server is
213
+ * known to hold, and every id missing from that set is an entry this command then drops.
214
+ */
215
+ function asObjectsAnswer(value) {
216
+ const unreadable = () => {
217
+ throw new NmtsError("The server answered with an item listing this version cannot read.", {
218
+ nextStep: "Nothing was changed. Update this tool before sweeping.",
219
+ });
220
+ };
221
+ if (typeof value !== "object" || value === null)
222
+ return unreadable();
223
+ const raw = Reflect.get(value, "objects");
224
+ if (!Array.isArray(raw))
225
+ return unreadable();
226
+ const objects = raw;
227
+ const ids = [];
228
+ for (const object of objects) {
229
+ if (typeof object !== "object" || object === null)
230
+ return unreadable();
231
+ const id = Reflect.get(object, "id");
232
+ if (typeof id !== "string")
233
+ return unreadable();
234
+ ids.push(id);
235
+ }
236
+ const next = Reflect.get(value, "next_cursor");
237
+ if (next !== null && next !== undefined && typeof next !== "string")
238
+ return unreadable();
239
+ return { ids, next: typeof next === "string" && next !== "" ? next : null };
240
+ }