@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,278 @@
1
+ // `nmts push <directory>` — a whole directory, with its shape, paid for with credits.
2
+ //
3
+ // ⛔ IT STOPS AT THE FIRST FAILURE, and that is the opposite of what `pull` does. Pulling costs
4
+ // nothing, so carrying on past one bad file saves the nineteen good ones. Pushing SPENDS: a
5
+ // failure that is really "this account cannot pay any more" would, if carried past, keep asking
6
+ // to pay for every remaining file. So the run stops, says what is already uploaded — those
7
+ // files are real and paid for — and says that running it again does the rest.
8
+ //
9
+ // ⛔ AND IT SKIPS WHAT IS ALREADY THERE, which is what makes running it again safe. Without that,
10
+ // a second run would upload every file a second time and the drive would fill with numbered
11
+ // copies: this tool never replaces a file, so the second `notes.txt` becomes `notes (2).txt`
12
+ // and the account pays for both.
13
+ //
14
+ // ⛔ NOTHING HIDDEN IS SENT. Entries whose name begins with a dot are left alone unless they are
15
+ // asked for: a directory of source code carries credentials in exactly those files, and an
16
+ // upload goes to a public storage network. `--hidden` includes them.
17
+ import { readdirSync, statSync } from "node:fs";
18
+ import { basename, join, resolve } from "node:path";
19
+ import { requireConsent } from "../consent.js";
20
+ import { DERIVED, loadCrypto } from "../crypto.js";
21
+ import { normaliseName, normalisePath } from "../drive-paths.js";
22
+ import { NmtsError } from "../errors.js";
23
+ import { addEntry } from "../manifest-write.js";
24
+ import { parseAsked } from "../collision.js";
25
+ import { setTrashed } from "../item-trash.js";
26
+ import { readFileList } from "../manifest.js";
27
+ import { BINARY_NAME } from "../product.js";
28
+ import { Progress, silentSink, stderrSink } from "../progress.js";
29
+ import { openSession } from "../session.js";
30
+ import { fileSource, partKeysOf, uploadFile } from "../upload-file.js";
31
+ import { createUploadApi } from "../upload-api.js";
32
+ import { clearItemRecord, clearReservation } from "../upload-store.js";
33
+ import { CREDIT_BYTES, partSizeFor, planAndPrice, UPLOAD_EPOCHS } from "../upload-price.js";
34
+ import { createBlobProtocol, readCurrentEpoch } from "../walrus-write.js";
35
+ import { ensureFolderPath } from "./organise.js";
36
+ export async function push(target, options = {}) {
37
+ const say = options.write ?? ((line) => process.stdout.write(`${line}\n`));
38
+ if (target === undefined || target === "") {
39
+ throw new NmtsError("Say which directory to push.", {
40
+ exitCode: 2,
41
+ nextStep: `\`${BINARY_NAME} push <directory>\` — a directory on this machine.`,
42
+ });
43
+ }
44
+ const root = resolve(target);
45
+ let rootStat;
46
+ try {
47
+ rootStat = statSync(root);
48
+ }
49
+ catch {
50
+ throw new NmtsError(`There is nothing at ${root}.`, { exitCode: 4 });
51
+ }
52
+ if (!rootStat.isDirectory()) {
53
+ throw new NmtsError(`${root} is a file.`, {
54
+ exitCode: 4,
55
+ nextStep: `Nothing was sent. \`${BINARY_NAME} put\` uploads one file.`,
56
+ });
57
+ }
58
+ const under = normalisePath(options.to ?? "");
59
+ const base = under === "" ? basename(root) : `${under}/${basename(root)}`;
60
+ const found = walk(root, base, options.hidden === true);
61
+ if (found.length === 0) {
62
+ if (options.json) {
63
+ say(JSON.stringify({ files: 0, uploaded: 0, skipped: 0, credits: 0 }));
64
+ return 0;
65
+ }
66
+ say(`${root} holds no files to send.`);
67
+ return 0;
68
+ }
69
+ const session = await openSession({ server: options.server, network: options.network });
70
+ const partSize = partSizeFor(options.partSize);
71
+ // ⛔ READ BEFORE ANYTHING IS SEALED OR PAID FOR — a typo must not surface after the money.
72
+ const asked = parseAsked(options.onCollision);
73
+ const list = await readFileList(session.server, session.apiKey, session.code, session.accountId);
74
+ const rule = list.manifest?.settings?.paddingMode === "pow2" ? "pow2" : "padme";
75
+ // ⛔ WHAT IS ALREADY THERE IS DECIDED BEFORE ANYTHING IS PRICED, so the number printed is what
76
+ // this run will actually spend rather than what a first run would have.
77
+ const entries = list.manifest?.entries ?? [];
78
+ const taken = new Set(entries
79
+ .filter((e) => e.deletedAt === undefined)
80
+ .map((e) => `${e.parentId ?? ""} ${normaliseName(e.name)}`));
81
+ const folderIds = new Map();
82
+ const already = [];
83
+ const todo = [];
84
+ for (const one of found) {
85
+ const parentId = knownFolderId(entries, one.folder);
86
+ if (parentId !== undefined)
87
+ folderIds.set(one.folder, parentId);
88
+ const there = parentId !== undefined && taken.has(`${parentId ?? ""} ${normaliseName(one.name)}`);
89
+ (there ? already : todo).push(one);
90
+ }
91
+ const credits = todo.reduce((sum, one) => sum + planAndPrice(one.size, partSize, rule).credits, 0);
92
+ const bytes = todo.reduce((sum, one) => sum + one.size, 0);
93
+ if (options.dryRun === true) {
94
+ if (options.json) {
95
+ say(JSON.stringify({
96
+ files: found.length,
97
+ toSend: todo.length,
98
+ skipped: already.length,
99
+ bytes,
100
+ credits,
101
+ epochs: UPLOAD_EPOCHS,
102
+ }));
103
+ return 0;
104
+ }
105
+ say(`${todo.length} file${todo.length === 1 ? "" : "s"} ${bytes} bytes → ${credits} credit${credits === 1 ? "" : "s"}`);
106
+ if (already.length > 0) {
107
+ say(` ${already.length} already in the drive, which this would not send again`);
108
+ }
109
+ say(``);
110
+ say(` Nothing was sent and nothing was charged.`);
111
+ return 0;
112
+ }
113
+ requireConsent("spend");
114
+ const progress = new Progress(options.json === true ? silentSink() : stderrSink(), "uploading");
115
+ const crypt = await loadCrypto();
116
+ // ⛔ READ ONCE, AND ONLY IF SOMETHING IS ACTUALLY SENT. It is a chain read: a run whose files are
117
+ // all already in the drive should not need a storage network to be reachable to say so.
118
+ let epoch;
119
+ const currentEpoch = async () => {
120
+ if (epoch === undefined)
121
+ epoch = await readCurrentEpoch(session.network);
122
+ return epoch;
123
+ };
124
+ if (!options.json) {
125
+ say(`${todo.length} file${todo.length === 1 ? "" : "s"} → ${credits} credit${credits === 1 ? "" : "s"}`);
126
+ if (already.length > 0)
127
+ say(` ${already.length} already there, not sent again`);
128
+ }
129
+ const uploaded = [];
130
+ try {
131
+ for (const one of todo) {
132
+ const parentId = await folderFor(session, folderIds, one.folder);
133
+ if (!options.json)
134
+ say(` ${one.folder}/${one.name}`);
135
+ const send = options.send ??
136
+ (async (file, into) => sendOne(session, crypt, file, {
137
+ ...(asked !== undefined ? { onCollision: asked } : {}),
138
+ parentId: into,
139
+ partSize,
140
+ rule,
141
+ currentEpoch: await currentEpoch(),
142
+ progress,
143
+ }));
144
+ uploaded.push(`${one.folder}/${await send(one, parentId)}`);
145
+ }
146
+ }
147
+ catch (error) {
148
+ progress.done();
149
+ // ⛔ WHAT IS UPLOADED IS REAL AND PAID FOR. Saying so is the difference between somebody
150
+ // running this again — which sends only the rest — and somebody assuming it all failed.
151
+ const because = error instanceof Error ? error.message : String(error);
152
+ throw new NmtsError(because, {
153
+ exitCode: 1,
154
+ nextStep: uploaded.length === 0
155
+ ? "Nothing was uploaded."
156
+ : `${uploaded.length} file${uploaded.length === 1 ? " is" : "s are"} uploaded and paid ` +
157
+ `for. Running the same command again sends only what is missing.`,
158
+ });
159
+ }
160
+ finally {
161
+ progress.done();
162
+ }
163
+ if (options.json) {
164
+ say(JSON.stringify({ files: found.length, uploaded: uploaded.length, skipped: already.length, bytes, credits }));
165
+ return 0;
166
+ }
167
+ say(``);
168
+ say(`${uploaded.length} sent · ${already.length} already there`);
169
+ return 0;
170
+ }
171
+ /** Seal, buy, upload and record ONE file. The account's data key does not outlive it. */
172
+ async function sendOne(session, crypt, one, ctx) {
173
+ const derived = crypt.kdf_derive(crypt.account_code_parse(session.code));
174
+ const dataKey = derived.slice(DERIVED.dataKey[0], DERIVED.dataKey[1]);
175
+ derived.fill(0);
176
+ const protocol = createBlobProtocol(session.network, one.size, (sent, total) => ctx.progress.update(sent, total));
177
+ try {
178
+ const result = await uploadFile({
179
+ api: createUploadApi(session.server, session.apiKey),
180
+ protocol,
181
+ crypt,
182
+ dataKey,
183
+ source: fileSource(one.local, one.size),
184
+ name: one.name,
185
+ parentId: ctx.parentId,
186
+ destination: one.folder,
187
+ relayUrl: protocol.relayUrl,
188
+ epochs: UPLOAD_EPOCHS,
189
+ currentEpoch: ctx.currentEpoch,
190
+ partSize: ctx.partSize,
191
+ padding: { rule: ctx.rule, unitBytes: CREDIT_BYTES },
192
+ });
193
+ const now = Date.now();
194
+ // ⛔ FROM THE RESULT, NOT FROM THIS RUN. The key that opens the stored bytes is the key they
195
+ // were sealed with, which on a resume belongs to the run that sealed them.
196
+ const added = await addEntry({
197
+ server: session.server,
198
+ apiKey: session.apiKey,
199
+ code: session.code,
200
+ accountId: session.accountId,
201
+ ...(ctx.onCollision !== undefined ? { onCollision: ctx.onCollision } : {}),
202
+ entry: {
203
+ id: result.itemId,
204
+ parentId: ctx.parentId,
205
+ kind: 1,
206
+ name: result.entry.name,
207
+ size: result.entry.plaintextLen,
208
+ createdAt: now,
209
+ updatedAt: now,
210
+ dekWrapped: result.entry.dekWrapped,
211
+ contentHashCt: result.entry.contentHashCt,
212
+ },
213
+ });
214
+ // ⛔ ONLY NOW. Until the entry is in the list the file is paid for and invisible.
215
+ clearItemRecord(result.fileKey);
216
+ for (const record of partKeysOf(result.fileKey, result.parts))
217
+ clearReservation(record);
218
+ // The file this one displaced goes to the server's trash last — see the same note in `put.ts`.
219
+ if (added.replaced)
220
+ await setTrashed(session.server, session.apiKey, added.replaced.id, true);
221
+ return added.name;
222
+ }
223
+ finally {
224
+ dataKey.fill(0);
225
+ }
226
+ }
227
+ /** The folder id for a drive path, made if it is not there yet. Remembered for the next file. */
228
+ async function folderFor(session, known, folder) {
229
+ const held = known.get(folder);
230
+ if (held !== undefined)
231
+ return held;
232
+ const { parentId } = await ensureFolderPath(session, folder);
233
+ known.set(folder, parentId);
234
+ return parentId;
235
+ }
236
+ /** The id of a drive folder path that ALREADY exists, or undefined when it does not. */
237
+ function knownFolderId(entries, folder) {
238
+ if (folder === "")
239
+ return null;
240
+ let parentId = null;
241
+ for (const name of folder.split("/")) {
242
+ const there = entries.find((e) => e.parentId === parentId &&
243
+ e.kind === 0 &&
244
+ e.deletedAt === undefined &&
245
+ normaliseName(e.name) === normaliseName(name));
246
+ if (there === undefined)
247
+ return undefined;
248
+ parentId = there.id;
249
+ }
250
+ return parentId;
251
+ }
252
+ export { walk as filesUnderDirectory };
253
+ /** Every file under a local directory, with the drive folder each one belongs in. */
254
+ function walk(dir, driveFolder, hidden) {
255
+ const out = [];
256
+ const items = readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name));
257
+ for (const item of items) {
258
+ if (!hidden && item.name.startsWith("."))
259
+ continue;
260
+ const local = join(dir, item.name);
261
+ // ⛔ SYMBOLIC LINKS ARE NOT FOLLOWED. One pointing at a parent directory would walk forever,
262
+ // and one pointing outside would upload a file nobody meant to send.
263
+ if (item.isSymbolicLink())
264
+ continue;
265
+ if (item.isDirectory()) {
266
+ out.push(...walk(local, `${driveFolder}/${item.name}`, hidden));
267
+ continue;
268
+ }
269
+ if (!item.isFile())
270
+ continue;
271
+ const size = statSync(local).size;
272
+ // An empty file has nothing to store, and the storage network would refuse the reservation.
273
+ if (size === 0)
274
+ continue;
275
+ out.push({ local, folder: driveFolder, name: item.name, size });
276
+ }
277
+ return out;
278
+ }
@@ -0,0 +1,282 @@
1
+ // `nmts put <file>` — one file in, sealed on this machine, paid for with credits.
2
+ //
3
+ // ⛔ THE ONLY COMMAND IN THIS TOOL THAT SPENDS. Everything about it is arranged so that is never a
4
+ // surprise: `--dry-run` says the price without paying it, the price is printed before the work
5
+ // starts, and a failure says whether the money already moved. The machinery that keeps a half
6
+ // finished upload from becoming money that bought nothing is in `upload.ts`.
7
+ //
8
+ // ⛔ THE PLAINTEXT NEVER LEAVES THIS PROCESS. What goes to the storage network is the sealed
9
+ // stream; what goes to the server is its length, and a name that is itself inside the account's
10
+ // sealed file list. Nothing in this file sends a file name anywhere but into that list.
11
+ import { basename, resolve } from "node:path";
12
+ import { identityOf } from "../account.js";
13
+ import { requireAccountCode } from "../code-access.js";
14
+ import { API_KEY_ENV_VAR, CODE_ENV_VAR, readCredentialsFile, resolveApiKey } from "../credentials.js";
15
+ import { parseAsked } from "../collision.js";
16
+ import { requireConsent } from "../consent.js";
17
+ import { DERIVED, loadCrypto } from "../crypto.js";
18
+ import { buildIndex, fullPathOf, isLive, KIND_FOLDER, normalisePath } from "../drive-paths.js";
19
+ import { NmtsError } from "../errors.js";
20
+ import { Progress, silentSink, stderrSink } from "../progress.js";
21
+ import { setTrashed } from "../item-trash.js";
22
+ import { addEntry } from "../manifest-write.js";
23
+ import { readFileList } from "../manifest.js";
24
+ import { resolveNetwork } from "../network.js";
25
+ import { BINARY_NAME } from "../product.js";
26
+ import { resolveServer } from "../server.js";
27
+ import { clearItemRecord, clearReservation } from "../upload-store.js";
28
+ import { createUploadApi } from "../upload-api.js";
29
+ import { fileSource, partKeysOf, uploadFile } from "../upload-file.js";
30
+ import { CREDIT_BYTES, creditsFor, measureLocal, partSizeFor, planAndPrice, UPLOAD_EPOCHS, } from "../upload-price.js";
31
+ import { createBlobProtocol, readCurrentEpoch } from "../walrus-write.js";
32
+ /**
33
+ * The folder id `--to` names, or null for the root. Refuses rather than guessing.
34
+ *
35
+ * ⛔ IT IS THE SAME LOOKUP EVERY OTHER COMMAND USES. This had its own walk and its own
36
+ * `e.deletedAt` test, which meant it would happily accept a folder whose PARENT was in the
37
+ * trash and put a paid-for upload somewhere the drive does not show (2026-08-23).
38
+ */
39
+ export function folderIdFor(wanted, entries) {
40
+ if (wanted === undefined)
41
+ return null;
42
+ const target = normalisePath(wanted);
43
+ if (target === "")
44
+ return null;
45
+ const index = buildIndex(entries);
46
+ const matches = entries.filter((e) => e.kind === KIND_FOLDER && isLive(index, e) && normalisePath(fullPathOf(index, e)) === target);
47
+ const folder = matches[0];
48
+ if (folder === undefined) {
49
+ throw new NmtsError(`No folder at "${target}".`, {
50
+ exitCode: 4,
51
+ nextStep: `Nothing was sent and nothing was charged. Make it first with ` +
52
+ `\`${BINARY_NAME} mkdir "${target}"\`, or leave --to off to put the file at the top of the drive.`,
53
+ });
54
+ }
55
+ if (matches.length > 1) {
56
+ throw new NmtsError(`"${target}" names ${matches.length} folders in this account.`, {
57
+ exitCode: 4,
58
+ nextStep: `Nothing was sent. Rename one of them with \`${BINARY_NAME} rename\`, then try again.`,
59
+ });
60
+ }
61
+ return folder.id;
62
+ }
63
+ export async function put(target, options = {}) {
64
+ const say = options.write ?? ((line) => process.stdout.write(`${line}\n`));
65
+ if (target === undefined || target === "") {
66
+ throw new NmtsError("Say which file to put.", {
67
+ exitCode: 2,
68
+ nextStep: `\`${BINARY_NAME} put <file>\` — a path on this machine.`,
69
+ });
70
+ }
71
+ const resolved = await requireAccountCode();
72
+ const key = resolveApiKey();
73
+ if (key === null) {
74
+ throw new NmtsError("This account has no API key on this machine, and the server needs one.", {
75
+ exitCode: 3,
76
+ nextStep: `Make a key on the account screen at nmts.me and put it in ${API_KEY_ENV_VAR}, or store ` +
77
+ `it with \`${BINARY_NAME} login\`.`,
78
+ });
79
+ }
80
+ const localPath = resolve(target);
81
+ const size = measureLocal(localPath);
82
+ const partSize = partSizeFor(options.partSize);
83
+ const stored = readCredentialsFile();
84
+ const server = resolveServer(options.server ?? stored?.server);
85
+ const network = resolveNetwork(server, options.network ?? stored?.network);
86
+ const identity = await identityOf(resolved.code);
87
+ const crypt = await loadCrypto();
88
+ const [from, to] = DERIVED.dataKey;
89
+ const derived = crypt.kdf_derive(crypt.account_code_parse(resolved.code));
90
+ const dataKey = derived.slice(from, to);
91
+ derived.fill(0);
92
+ const name = options.name ?? basename(localPath);
93
+ // The destination AS TYPED, so the reservation key can be worked out before any network call.
94
+ const destination = (options.to ?? "").replace(/^\.?\//, "").replace(/\/$/, "");
95
+ // ⛔ THE ACCOUNT'S OWN LIST IS READ BEFORE THE PRICE IS QUOTED, and that is why `--dry-run` needs
96
+ // the network. The rounding rule that hides a file's true size lives in the sealed list, it
97
+ // changes how many bytes are stored, and therefore it changes the price. A quote worked out
98
+ // without it would be right for one account and wrong for the other, which is worse than
99
+ // being slower.
100
+ // ⛔ READ BEFORE ANYTHING IS SEALED OR PAID FOR. A misspelt answer that surfaced after the upload
101
+ // would have cost real money to produce a message about a typo.
102
+ const asked = parseAsked(options.onCollision);
103
+ const list = await readFileList(server, key.key, resolved.code, identity.accountId);
104
+ const rule = list.manifest?.settings?.paddingMode === "pow2" ? "pow2" : "padme";
105
+ // ⛔ THE PRICE IS ARITHMETIC, NOT A MEASUREMENT: quoting it by sealing would mean reading and
106
+ // encrypting a very large file to answer `--dry-run`. Every part rounds up to a whole credit
107
+ // on its own, exactly as the server charges each reservation, so a file in several parts is
108
+ // quoted the way it will actually be billed.
109
+ //
110
+ // ⚠ ONLY THE LAST PART IS ROUNDED UP. The earlier ones are exactly the part size, which is what
111
+ // lets a reader work out where the padding is.
112
+ const { plan, sealedBytes, credits } = planAndPrice(size, partSize, rule);
113
+ if (options.dryRun === true) {
114
+ // ⛔ Nothing above this line touched the network and nothing below it runs. The file was not
115
+ // even read — the price is arithmetic on its size, and the server does the same arithmetic.
116
+ if (options.json) {
117
+ say(JSON.stringify({
118
+ dryRun: true,
119
+ name,
120
+ bytes: size,
121
+ sealedBytes,
122
+ parts: plan.length,
123
+ partSize,
124
+ credits,
125
+ epochs: UPLOAD_EPOCHS,
126
+ }));
127
+ return 0;
128
+ }
129
+ say(`${name} ${size} bytes → ${credits} credit${credits === 1 ? "" : "s"}`);
130
+ if (plan.length > 1)
131
+ say(` in ${plan.length} parts of up to ${partSize} bytes`);
132
+ say(``);
133
+ say(` Nothing was sent and nothing was charged. Run the same command without --dry-run`);
134
+ say(` to upload it.`);
135
+ return 0;
136
+ }
137
+ // ⛔ ASKED AFTER THE PRICE IS KNOWN AND BEFORE ANYTHING LEAVES. Working the price out is local
138
+ // and free, so doing it first costs nothing and lets the refusal name a real number instead
139
+ // of a warning about spending in general. `--dry-run` returns above this line and never asks.
140
+ requireConsent("spend");
141
+ // ⛔ Resolved from the list read above — which happened BEFORE the money moves, so a rolled-back
142
+ // or forked list stops the upload rather than being discovered after it is paid for.
143
+ const parentId = folderIdFor(options.to, list.manifest?.entries ?? []);
144
+ // ⛔ `--json` promises one JSON document and nothing else, so it gets a reporter that says
145
+ // nothing. Everything else reports to STDERR, where it cannot land in a redirected answer.
146
+ const progress = new Progress(options.json === true ? silentSink() : stderrSink(), "uploading");
147
+ const protocol = createBlobProtocol(network, sealedBytes, (sent, total) => progress.update(sent, total));
148
+ const currentEpoch = await readCurrentEpoch(network);
149
+ if (!options.json) {
150
+ say(`${name} ${size} bytes → ${credits} credit${credits === 1 ? "" : "s"}`);
151
+ if (plan.length > 1) {
152
+ say(` in ${plan.length} parts — each one is bought separately and can be finished later`);
153
+ }
154
+ }
155
+ const steps = [];
156
+ const onStep = (step) => {
157
+ steps.push(step.step);
158
+ if (options.json)
159
+ return;
160
+ if (step.step === "planning")
161
+ return;
162
+ if (step.step === "hashing") {
163
+ say(` reading ${size} bytes`);
164
+ return;
165
+ }
166
+ // ⚠ Only said for a file that HAS several parts. On a one-part file the number is noise.
167
+ const where = step.parts > 1 ? ` [${step.partIndex + 1}/${step.parts}]` : "";
168
+ if (step.step === "sealing")
169
+ say(` sealing${where} ${step.bytes} bytes`);
170
+ if (step.step === "resuming")
171
+ say(` picking up reservation ${step.ledgerId} (${step.state})${where}`);
172
+ if (step.step === "encoding")
173
+ say(` preparing${where} ${step.bytes} sealed bytes`);
174
+ if (step.step === "reserving")
175
+ say(` buying storage${where}`);
176
+ if (step.step === "uploading")
177
+ say(` uploading${where} ${step.bytes} bytes to ${step.relayUrl}`);
178
+ if (step.step === "certifying") {
179
+ // The live line is finished before anything else prints, or the next line lands on top of it.
180
+ progress.done();
181
+ say(` certifying${where}`);
182
+ }
183
+ if (step.step === "committing")
184
+ say(` saving to the drive`);
185
+ };
186
+ let result;
187
+ try {
188
+ result = await uploadFile({
189
+ api: createUploadApi(server, key.key),
190
+ protocol,
191
+ crypt,
192
+ dataKey,
193
+ source: fileSource(localPath, size),
194
+ name,
195
+ parentId,
196
+ destination,
197
+ relayUrl: protocol.relayUrl,
198
+ epochs: UPLOAD_EPOCHS,
199
+ currentEpoch,
200
+ partSize,
201
+ padding: { rule, unitBytes: CREDIT_BYTES },
202
+ onStep,
203
+ });
204
+ }
205
+ finally {
206
+ // ⛔ Also on the way out of a failure. A half-written progress line would otherwise sit in
207
+ // front of the error message, which is the one line that must be readable.
208
+ progress.done();
209
+ dataKey.fill(0);
210
+ }
211
+ const now = Date.now();
212
+ // ⛔ FROM `result.entry`, NOT FROM THIS RUN. The key that opens the stored bytes is the key they
213
+ // were sealed with, which on a resume belongs to the run that sealed them. Writing this run's
214
+ // key would produce a file that is paid for, present, correctly named and impossible to open.
215
+ const added = await addEntry({
216
+ server,
217
+ apiKey: key.key,
218
+ code: resolved.code,
219
+ accountId: identity.accountId,
220
+ ...(asked !== undefined ? { onCollision: asked } : {}),
221
+ entry: {
222
+ id: result.itemId,
223
+ // ⚠ The FRESHLY resolved folder, not the record's. The reservation key already covers the
224
+ // destination as typed, so both runs asked for the same path — and if that folder has
225
+ // since been removed, resolving again is what says so instead of writing into a folder id
226
+ // that no longer exists.
227
+ parentId,
228
+ kind: 1,
229
+ name: result.entry.name,
230
+ size: result.entry.plaintextLen,
231
+ createdAt: now,
232
+ updatedAt: now,
233
+ dekWrapped: result.entry.dekWrapped,
234
+ contentHashCt: result.entry.contentHashCt,
235
+ },
236
+ });
237
+ // ⛔ ONLY NOW, AND EVERY PART. Until the entry is in the list the file is paid for and invisible,
238
+ // and the records are the only thing that lets a second run finish the job without spending
239
+ // again. Clearing the file-level one first would leave a run able to commit a second time.
240
+ clearItemRecord(result.fileKey);
241
+ for (const record of partKeysOf(result.fileKey, result.parts))
242
+ clearReservation(record);
243
+ // ⛔ THE DISPLACED FILE IS TOLD TO THE SERVER ONLY NOW, and only after the new one is in the
244
+ // list. Until this line the person still had the file they started with.
245
+ // ⚠ A failure here leaves it hidden in this account's trash while the server still counts it as
246
+ // live. That is the harmless direction: it stays restorable, it still expires on its own, and
247
+ // `nmts rm` on it again finishes the job. The other order — server first — would show a live
248
+ // file the server has already trashed, which is the one state a person cannot act on.
249
+ if (added.replaced)
250
+ await setTrashed(server, key.key, added.replaced.id, true);
251
+ if (options.json) {
252
+ say(JSON.stringify({
253
+ id: result.itemId,
254
+ name: added.name,
255
+ bytes: size,
256
+ sealedBytes,
257
+ parts: plan.length,
258
+ credits: result.resumed ? 0 : credits,
259
+ resumed: result.resumed,
260
+ renamed: added.name !== name,
261
+ ...(added.replaced ? { replacedIntoTrash: added.replaced.id } : {}),
262
+ fileListVersion: added.seq,
263
+ }));
264
+ return 0;
265
+ }
266
+ say(` saved as ${added.name}`);
267
+ if (added.replaced) {
268
+ say(``);
269
+ say(` A file called ${name} was already there. It is in the trash now — ${BINARY_NAME} restore`);
270
+ say(` brings it back for 30 days. This machine is set to overwrite: ${BINARY_NAME} on-collision`);
271
+ }
272
+ else if (added.name !== name) {
273
+ say(``);
274
+ say(` A file called ${name} was already there, so this one was numbered rather than`);
275
+ say(` replacing it. This machine is set to rename: ${BINARY_NAME} on-collision`);
276
+ }
277
+ if (result.resumed) {
278
+ say(``);
279
+ say(` This finished an upload a previous run had already paid for. Nothing was charged now.`);
280
+ }
281
+ return 0;
282
+ }