@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,296 @@
1
+ // `nmts mkdir`, `nmts mv` and `nmts rename` — the three edits that touch no bytes.
2
+ //
3
+ // ⛔ NONE OF THESE ASKS THE SERVER TO CHANGE ANYTHING BUT THE SEALED LIST. A folder has no server
4
+ // row at all, and a name and a parent live only inside the list — the server holds an id, a
5
+ // size and a time, and was deliberately never given a place to put a name
6
+ // (`PATCH /v1/items/{id}` was removed from the API for exactly that reason).
7
+ // ⚠ They are not offline and they are not unobserved: each one reads the list and writes it
8
+ // back, so the server sees a manifest write of a known size at a known moment. The sentence
9
+ // that used to sit here said "invisible to us", which was never true (2026-08-23).
10
+ //
11
+ // ⛔ AND NONE OF THEM STOPS TO ASK. Renaming, moving and making a folder are reversible and cost
12
+ // nothing, which is the whole test for whether this tool interrupts somebody. It does not.
13
+ import { randomUUID } from "node:crypto";
14
+ import { buildIndex, entryAt, folderIdFor, fullPathOf, KIND_FOLDER, namesIn, normaliseName, normalisePath, } from "../drive-paths.js";
15
+ import { NmtsError } from "../errors.js";
16
+ import { readFileList } from "../manifest.js";
17
+ import { applyManyToList, applyToList, batchTargets } from "../manifest-write.js";
18
+ import { openSession } from "../session.js";
19
+ import { applyIntent } from "../shared/lib/drive/manifest-ops.js";
20
+ const say_ = (line) => void process.stdout.write(`${line}\n`);
21
+ /**
22
+ * Make a folder, and any folder above it that is missing.
23
+ *
24
+ * ⚠ MISSING PARENTS ARE CREATED, and that is a decision rather than a convenience. A folder costs
25
+ * nothing, holds nothing and can be moved to the trash, so the failure mode of creating one too
26
+ * many is a tidy-up; the failure mode of refusing is an agent that has to discover the tree one
27
+ * command at a time. Every folder made is named in the output, so it is never a surprise.
28
+ */
29
+ /**
30
+ * Make a folder path, and every folder above it that is missing, under an OPEN session.
31
+ *
32
+ * ⛔ SPLIT OUT SO AN UPLOAD OF A WHOLE DIRECTORY CAN USE IT. The rules below are the ones a second
33
+ * copy would get subtly wrong: a folder that is already there IS the folder asked for (never a
34
+ * numbered one), the decision is taken inside each attempt so a lost race cannot make two, and
35
+ * what was made before a failure is named rather than silently kept.
36
+ */
37
+ export async function ensureFolderPath(session, wanted) {
38
+ const made = [];
39
+ let parentId = null;
40
+ let walked = "";
41
+ for (const name of wanted.split("/")) {
42
+ // ⚠ A name that is only spaces is refused too. `mkdir` used to accept it, and then `rm` and
43
+ // `restore` rejected the very path `ls` printed for it as "no path given" — a code-2 message
44
+ // blaming the caller for an argument they had supplied (2026-08-23).
45
+ if (name.trim() === "" || name === "." || name === "..") {
46
+ throw new NmtsError(`"${wanted}" is not a folder path this tool will make.`, {
47
+ exitCode: 2,
48
+ nextStep: `Empty names, "." and ".." are not folder names in a drive. Nothing was made.`,
49
+ });
50
+ }
51
+ walked = walked === "" ? name : `${walked}/${name}`;
52
+ const under = parentId;
53
+ const here = walked;
54
+ const fresh = randomUUID();
55
+ let landedOn = fresh;
56
+ // ⛔ ONE WRITE PER FOLDER, and the check that decides whether to write happens INSIDE the
57
+ // attempt. Two things went wrong when it sat outside (2026-08-23):
58
+ // · running `mkdir` twice at the same moment made `shared` AND `shared (2)`, because the
59
+ // loser of the compare-and-swap re-applied a decision taken against the older list;
60
+ // · a trashed folder of the same name made the second `mkdir` produce `photos (2)` while
61
+ // printing `Made "photos"`, because it went through the upload helper — and picking a
62
+ // free name is the right rule for BYTES and the wrong rule for a folder. A folder with
63
+ // that name in that parent IS the folder that was asked for.
64
+ // Building the whole chain in memory and writing once would be fewer round trips and would
65
+ // also mean a lost compare-and-swap threw away folders the ones below already point at.
66
+ const result = await applyToList(session, (entries) => {
67
+ const there = entries.find((e) => e.parentId === under &&
68
+ normaliseName(e.name) === normaliseName(name) &&
69
+ e.deletedAt === undefined);
70
+ if (there !== undefined) {
71
+ if (there.kind !== KIND_FOLDER) {
72
+ throw new NmtsError(`"${here}" is a file, so nothing can be made inside it.`, {
73
+ exitCode: 4,
74
+ nextStep: made.length > 0 ? `The folders made so far are kept: ${made.join(", ")}.` : "Nothing was made.",
75
+ });
76
+ }
77
+ landedOn = there.id;
78
+ return null;
79
+ }
80
+ landedOn = fresh;
81
+ const at = Date.now();
82
+ return {
83
+ op: "add",
84
+ entry: { id: fresh, parentId: under, kind: KIND_FOLDER, name, size: 0, createdAt: at, updatedAt: at },
85
+ };
86
+ }).catch((error) => {
87
+ // ⛔ WHAT SURVIVED IS NAMED. A run that stops half way leaves real folders behind, and the
88
+ // message that says so was attached only to the "that is a file" refusal.
89
+ if (error instanceof NmtsError || made.length === 0)
90
+ throw error;
91
+ const because = error instanceof Error ? error.message : "the server refused";
92
+ throw new NmtsError(because, {
93
+ exitCode: 1,
94
+ nextStep: `The folders made so far are kept: ${made.join(", ")}. Running the same command again ` +
95
+ `makes the rest — nothing is lost.`,
96
+ });
97
+ });
98
+ if (result.changed)
99
+ made.push(here);
100
+ parentId = landedOn;
101
+ }
102
+ return { parentId, made };
103
+ }
104
+ export async function mkdir(path, options = {}) {
105
+ const say = options.write ?? say_;
106
+ const wanted = normalisePath(path ?? "");
107
+ if (wanted === "") {
108
+ throw new NmtsError("`nmts mkdir` needs the path of the folder to make.", { exitCode: 2 });
109
+ }
110
+ const session = await openSession(options);
111
+ const { parentId, made } = await ensureFolderPath(session, wanted);
112
+ if (options.json) {
113
+ say(JSON.stringify({ path: wanted, id: parentId, made }));
114
+ return 0;
115
+ }
116
+ // ⛔ THE FOLDERS ARE NAMED, NOT COUNTED. "including 2 folder(s) above it" cannot disagree with
117
+ // what was written, so it could never have caught a helper quietly renaming one.
118
+ if (made.length === 0)
119
+ say(`"${wanted}" is already there. Nothing was made.`);
120
+ else
121
+ say(`Made ${made.map((m) => `"${m}"`).join(", ")}.`);
122
+ return 0;
123
+ }
124
+ /**
125
+ * Move things into a folder. Every operand but the last is something to move; the last is where
126
+ * they go, and an empty one means the top of the drive.
127
+ *
128
+ * ⛔ ONE WRITE FOR THE WHOLE RUN, however many things are named. The list is rewritten whole on
129
+ * every save, so a second thing costs nothing extra — while a second WRITE is a second chance
130
+ * to lose the compare-and-swap, and losing it half way through a run leaves some things moved
131
+ * and some not, which is a state the caller cannot tell apart from the one it asked for.
132
+ *
133
+ * ⛔ AND THE NAME CHECK RUNS AGAINST WHAT THIS RUN HAS ALREADY MOVED, not against the list as it
134
+ * was read. Two files called `notes.txt` in two folders, moved into one folder by one command,
135
+ * would otherwise both be written — two entries at one path, which no command in this tool can
136
+ * address afterwards: every one of them answers "names 2 things in this account". So the loop
137
+ * folds each move onto a working copy and asks the working copy the next question.
138
+ */
139
+ export async function mv(operands, options = {}) {
140
+ const say = options.write ?? say_;
141
+ const destination = operands.at(-1);
142
+ const paths = operands.slice(0, -1);
143
+ if (destination === undefined || paths.length === 0) {
144
+ throw new NmtsError("`nmts mv` needs what to move and where to put it.", {
145
+ exitCode: 2,
146
+ nextStep: `For example: nmts mv notes.txt archive · nmts mv a.txt b.txt archive · ` +
147
+ `nmts mv archive/notes.txt /`,
148
+ });
149
+ }
150
+ const session = await openSession(options);
151
+ const at = Date.now();
152
+ const outcome = { moved: [], already: [], parentId: null };
153
+ // ⛔ EVERY GUARD RUNS INSIDE THE ATTEMPT, INCLUDING WHICH ENTRY EACH PATH NAMES. A lost
154
+ // compare-and-swap re-applies the intent to a list that changed underneath — and when the
155
+ // winner had just taken this name, the loser landed on top of it and produced two entries at
156
+ // one path. Meanwhile the tool exited 0 and said the move had been made.
157
+ const result = await applyManyToList(session, (now) => {
158
+ const targets = batchTargets(now, paths, { nothingHappened: "Nothing was moved." });
159
+ const into = folderIdFor(destination, now, "Nothing was moved.");
160
+ const intents = [];
161
+ const moved = [];
162
+ const already = [];
163
+ let working = now;
164
+ for (const target of targets) {
165
+ if (into !== null && (into === target.id || isUnder(working, into, target.id))) {
166
+ throw new NmtsError(`A folder cannot be moved inside itself.`, {
167
+ exitCode: 4,
168
+ nextStep: "Nothing was moved.",
169
+ });
170
+ }
171
+ if (into === target.parentId) {
172
+ already.push(target.name);
173
+ continue;
174
+ }
175
+ if (namesIn(working, into).has(normaliseName(target.name))) {
176
+ throw new NmtsError(`Something called "${target.name}" is already in that folder.`, {
177
+ exitCode: 4,
178
+ nextStep: `Nothing was moved. Rename it first: nmts rename "${target.name}" <new name>`,
179
+ });
180
+ }
181
+ const intent = { op: "move", id: target.id, parentId: into, at };
182
+ intents.push(intent);
183
+ working = applyIntent(working, intent);
184
+ moved.push({ id: target.id, name: target.name });
185
+ }
186
+ outcome.moved = moved;
187
+ outcome.already = already;
188
+ outcome.parentId = into;
189
+ return intents;
190
+ });
191
+ const shownDestination = normalisePath(destination);
192
+ const where = shownDestination === "" ? "the top of the drive" : `"${shownDestination}"`;
193
+ if (options.json) {
194
+ const index = buildIndex(result.entries);
195
+ say(JSON.stringify({
196
+ moved: outcome.moved.map((m) => {
197
+ const live = result.entries.find((e) => e.id === m.id);
198
+ return { id: m.id, name: m.name, path: live === undefined ? null : fullPathOf(index, live) };
199
+ }),
200
+ already: outcome.already,
201
+ parentId: outcome.parentId,
202
+ changed: result.changed,
203
+ reappliedAfterConflict: result.reappliedAfterConflict,
204
+ seq: result.seq,
205
+ }));
206
+ return 0;
207
+ }
208
+ // ⛔ THE THINGS ARE NAMED, NOT COUNTED. "2 things moved" cannot disagree with what was written,
209
+ // so it could never catch a run that moved something the caller did not name.
210
+ const names = outcome.moved.map((m) => `"${m.name}"`).join(", ");
211
+ say(outcome.moved.length > 0
212
+ ? `Moved ${names} to ${where}.`
213
+ : `Everything named is already in ${where}. Nothing was moved.`);
214
+ if (outcome.moved.length > 0 && outcome.already.length > 0) {
215
+ say(` ${outcome.already.map((n) => `"${n}"`).join(", ")} was already there.`);
216
+ }
217
+ if (result.changed && result.reappliedAfterConflict) {
218
+ say(` Another device wrote the file list first, so this was applied to that version.`);
219
+ }
220
+ return 0;
221
+ }
222
+ /** Give one thing a new name. The path stays the same otherwise. */
223
+ export async function rename(path, name, options = {}) {
224
+ const say = options.write ?? say_;
225
+ if (path === undefined || name === undefined || name.trim() === "") {
226
+ throw new NmtsError("`nmts rename` needs what to rename and the new name.", {
227
+ exitCode: 2,
228
+ nextStep: `For example: nmts rename notes.txt "meeting notes.txt"`,
229
+ });
230
+ }
231
+ if (name.includes("/")) {
232
+ throw new NmtsError(`A name cannot contain "/" — that is what makes it a path.`, {
233
+ exitCode: 2,
234
+ nextStep: `To move it, use \`nmts mv\`. Nothing was renamed.`,
235
+ });
236
+ }
237
+ const session = await openSession(options);
238
+ const list = await readFileList(session.server, session.apiKey, session.code, session.accountId);
239
+ const entries = list.manifest?.entries ?? [];
240
+ const target = entryAt(entries, path, { nothingHappened: "Nothing was renamed." });
241
+ const at = Date.now();
242
+ const before = fullPathOf(buildIndex(entries), target);
243
+ // ⛔ REFUSED RATHER THAN NUMBERED, AND THE REFUSAL IS RE-DECIDED ON EVERY ATTEMPT. An upload
244
+ // picks `report (2).pdf` because nobody was watching; a rename is somebody typing a name on
245
+ // purpose, and silently giving them a different one is how two files end up looking like a
246
+ // mistake nobody made. Checking once, before the write, was not enough: when another device
247
+ // took the name in between, the retry re-applied the old decision and produced two entries at
248
+ // one path, which no command in this tool can address afterwards (2026-08-23).
249
+ const result = await applyToList(session, (now) => {
250
+ const live = now.find((e) => e.id === target.id);
251
+ if (live === undefined)
252
+ return null;
253
+ if (normaliseName(name) !== normaliseName(live.name) && namesIn(now, live.parentId).has(normaliseName(name))) {
254
+ throw new NmtsError(`Something called "${name}" is already in that folder.`, {
255
+ exitCode: 4,
256
+ nextStep: "Nothing was renamed.",
257
+ });
258
+ }
259
+ return { op: "rename", id: target.id, name, at };
260
+ });
261
+ return report(say, options, result.changed, result.reappliedAfterConflict, {
262
+ text: result.changed ? `Renamed "${before}" to "${name}".` : `"${before}" is already called that.`,
263
+ json: {
264
+ id: target.id,
265
+ from: target.name,
266
+ to: name,
267
+ changed: result.changed,
268
+ reappliedAfterConflict: result.reappliedAfterConflict,
269
+ seq: result.seq,
270
+ },
271
+ });
272
+ }
273
+ /** Is `id` at or under `rootId`? Used to refuse moving a folder into its own subtree. */
274
+ function isUnder(entries, id, rootId) {
275
+ const byId = buildIndex(entries).byId;
276
+ const seen = new Set();
277
+ let at = id;
278
+ while (at !== null && !seen.has(at)) {
279
+ if (at === rootId)
280
+ return true;
281
+ seen.add(at);
282
+ at = byId.get(at)?.parentId ?? null;
283
+ }
284
+ return false;
285
+ }
286
+ function report(say, options, changed, conflicted, out) {
287
+ if (options.json) {
288
+ say(JSON.stringify(out.json));
289
+ return 0;
290
+ }
291
+ say(out.text);
292
+ if (changed && conflicted) {
293
+ say(` Another device wrote the file list first, so this was applied to that version.`);
294
+ }
295
+ return 0;
296
+ }
@@ -0,0 +1,89 @@
1
+ // `nmts public-code` — the value other accounts send files to, and publishing it so they can.
2
+ //
3
+ // ⛔ IT IS CALLED THE PUBLIC CODE HERE BECAUSE THAT IS WHAT THE PRODUCT CALLS IT. The browser's
4
+ // screens say "public code", and this program used to say "address" for the same value — two
5
+ // names for one thing, which is the mistake a locked wording decision exists to stop. It also
6
+ // printed it in a different encoding than the browser shows, so somebody copying from one and
7
+ // pasting into the other had two ways to be wrong about one value. Both are fixed here: one
8
+ // name, and the same grouped form a person sees on the screen.
9
+ //
10
+ // ⛔ WHY PUBLISHING IS A SEPARATE STEP AND NOT SOMETHING THIS COMMAND JUST DOES. Sending a file
11
+ // already publishes the sender's code as a side effect, because a share cannot exist without
12
+ // one and the person has already decided to hand something over. RECEIVING is the other way
13
+ // round: nothing has been decided yet, and the record is permanent. So the plain command reads,
14
+ // says whether it can be sent to, and names the flag; `--publish` is the deliberate act.
15
+ //
16
+ // ⛔ WHAT "PERMANENT" DOES AND DOES NOT MEAN HERE. The record cannot be withdrawn or replaced. It
17
+ // is also not a choice: the code and the identity behind it are derived from the account code,
18
+ // so the same account code produces the same bytes on any device, and the server refuses a
19
+ // bundle whose claimed value is not the fingerprint of its own root. The only way to publish a
20
+ // wrong one is to be holding a different account code. That is worth saying plainly rather than
21
+ // warning vaguely — a warning that cannot be acted on just teaches people to click through.
22
+ //
23
+ // ⚠ IT IS NOT THE ACCOUNT CODE. That one opens every file in the account and must never be given
24
+ // to anybody; this one is meant to be given away, and on its own it opens nothing.
25
+ import { request } from "../api.js";
26
+ import { NmtsError } from "../errors.js";
27
+ import { isRecord } from "../guards.js";
28
+ import { loadCrypto } from "../crypto.js";
29
+ import { BINARY_NAME } from "../product.js";
30
+ import { openSession } from "../session.js";
31
+ import { shareKeysOf } from "../share.js";
32
+ function b64(bytes) {
33
+ return Buffer.from(bytes).toString("base64url");
34
+ }
35
+ export async function publicCode(options = {}) {
36
+ const say = options.write ?? ((line) => process.stdout.write(`${line}\n`));
37
+ const session = await openSession({ server: options.server, network: options.network });
38
+ const crypt = await loadCrypto();
39
+ const keys = shareKeysOf(crypt, session.code);
40
+ const mine = b64(keys.address);
41
+ const shown = keys.display;
42
+ const seen = await request(session.server, "/v1/account/share-identity", {
43
+ token: session.apiKey,
44
+ });
45
+ let published = isRecord(seen) && seen["published"] === true;
46
+ // ⛔ IF THE SERVER ALREADY HOLDS A DIFFERENT ONE, STOP. Publishing is first-writer-wins and the
47
+ // server would refuse the write anyway, but the useful thing to report is not "the write
48
+ // failed" — it is that the account code this machine is holding is not the one this account
49
+ // was made with, which is a much bigger fact than a failed request.
50
+ const held = isRecord(seen) ? seen["share_address"] : null;
51
+ if (typeof held === "string" && held !== mine) {
52
+ throw new NmtsError("This account already publishes a different public code.", {
53
+ exitCode: 4,
54
+ nextStep: "The public code is derived from the account code, so a different one means this machine " +
55
+ "is holding a different account's code than the key beside it. Check which account you meant.",
56
+ });
57
+ }
58
+ if (options.publish === true && !published) {
59
+ await request(session.server, "/v1/account/share-identity", {
60
+ token: session.apiKey,
61
+ method: "PUT",
62
+ body: { share_public_key: b64(keys.identity), share_address: mine },
63
+ });
64
+ published = true;
65
+ }
66
+ if (options.json === true) {
67
+ // ⚠ BOTH FORMS. `code` is what a person reads and types; `raw` is what the wire carries.
68
+ // A reader that has only one of them ends up converting, and that is a second place to be wrong.
69
+ say(JSON.stringify({ code: shown, raw: mine, published }));
70
+ return 0;
71
+ }
72
+ say(`public code ${shown}`);
73
+ if (published) {
74
+ say(` published — another account can send files to it`);
75
+ say(``);
76
+ say(`Give it to whoever is sending. ⛔ It is NOT your account code — that one opens`);
77
+ say(`every file you have and is never given to anybody. This one opens nothing.`);
78
+ return 0;
79
+ }
80
+ say(` NOT published — nobody can send to it yet`);
81
+ say(``);
82
+ say(`Publishing writes it on the server so a sender can find the key to seal to.`);
83
+ say(`It is permanent: it cannot be withdrawn or changed afterwards. It is also not a`);
84
+ say(`choice — it comes from your account code, so the same account code always gives`);
85
+ say(`the same public code, on this machine or any other.`);
86
+ say(``);
87
+ say(` ${BINARY_NAME} public-code --publish`);
88
+ return 0;
89
+ }
@@ -0,0 +1,185 @@
1
+ // `nmts pull <folder>` — a whole folder, or a whole account, onto this machine.
2
+ //
3
+ // ⛔ ONE BAD FILE MUST NOT LOSE NINETEEN GOOD ONES. A single `get` refuses rather than writing a
4
+ // half-right file, and that is right for one file. Applied to two hundred it would mean one
5
+ // unreadable file throws away every file downloaded before it. So each file is attempted on its
6
+ // own, what failed is named at the end, and the exit code says whether anything did.
7
+ //
8
+ // ⛔ AND A FILE THAT FAILED LEAVES NOTHING — not even the part of it that had already arrived. Each
9
+ // file streams into a temporary name beside where it is going and is renamed into place only
10
+ // once its digest matches, so a pull that stops half way through a large file does not leave
11
+ // something that looks finished. A second run fetches what is missing (`download-sink.ts`).
12
+ //
13
+ // ⛔ AND NOTHING IS OVERWRITTEN BY DEFAULT. A pull into a directory that already holds files is
14
+ // ordinary — a second run, a resumed transfer — and silently replacing what is there is the one
15
+ // outcome nobody can undo. An existing file is SKIPPED and counted; `--force` replaces.
16
+ //
17
+ // ⛔ THE TREE IS THE ACCOUNT'S, AND IT IS BUILT UNDER THE DESTINATION AND NOWHERE ELSE. A name in
18
+ // the sealed list is written by whoever holds the account, so it is not to be trusted with a
19
+ // path: a name containing a separator, or dots that climb, would otherwise write outside the
20
+ // directory that was asked for. Every segment is checked before anything is created.
21
+ import { mkdirSync, existsSync } from "node:fs";
22
+ import { isAbsolute, join, relative, resolve, sep } from "node:path";
23
+ import { fetchFile } from "../download.js";
24
+ import { fileSink } from "../download-sink.js";
25
+ import { buildIndex, entryAt, fullPathOf, isLive, KIND_FILE, KIND_FOLDER, normalisePath, underPrefix, } from "../drive-paths.js";
26
+ import { NmtsError } from "../errors.js";
27
+ import { readFileList } from "../manifest.js";
28
+ import { resolveNetwork } from "../network.js";
29
+ import { refuseUnwritableName } from "../safe-path.js";
30
+ import { BINARY_NAME } from "../product.js";
31
+ import { openSession } from "../session.js";
32
+ export async function pull(target, options = {}) {
33
+ const say = options.write ?? ((line) => process.stdout.write(`${line}\n`));
34
+ const session = await openSession({ server: options.server, network: options.network });
35
+ const chain = resolveNetwork(session.server, session.network);
36
+ const list = await readFileList(session.server, session.apiKey, session.code, session.accountId);
37
+ if (list.manifest === null) {
38
+ throw new NmtsError("This account has no file list, so there is nothing to pull.", { exitCode: 4 });
39
+ }
40
+ const entries = list.manifest.entries;
41
+ const index = buildIndex(entries);
42
+ // ⚠ No target means the WHOLE account. It is the same walk from the root, said as its own case
43
+ // so that `nmts pull` cannot be read as "pull something and I will guess what".
44
+ const root = target === undefined || target === "" || target === "/" ? null : entryAt(entries, normalisePath(target), { nothingHappened: "Nothing was written." });
45
+ if (root !== null && root.kind !== KIND_FOLDER) {
46
+ throw new NmtsError(`"${fullPathOf(index, root)}" is a file.`, {
47
+ exitCode: 4,
48
+ nextStep: `Nothing was written. \`${BINARY_NAME} get\` fetches one file.`,
49
+ });
50
+ }
51
+ const base = resolve(options.out ?? ".");
52
+ const wanted = filesUnder(index, entries, root?.id ?? null);
53
+ if (wanted.length === 0) {
54
+ if (options.json) {
55
+ say(JSON.stringify({ files: 0, written: 0, skipped: 0, failed: 0, out: base }));
56
+ return 0;
57
+ }
58
+ say(`Nothing to pull${root === null ? "" : ` from "${fullPathOf(index, root)}"`}.`);
59
+ return 0;
60
+ }
61
+ const prefix = root === null ? "" : fullPathOf(index, root);
62
+ const outcomes = [];
63
+ for (const entry of wanted) {
64
+ const drivePath = fullPathOf(index, entry);
65
+ const local = safeJoin(base, underPrefix(prefix, drivePath));
66
+ if (existsSync(local) && options.force !== true) {
67
+ outcomes.push({ path: drivePath, bytes: entry.size, state: "skipped" });
68
+ continue;
69
+ }
70
+ if (!options.json)
71
+ say(` ${drivePath}`);
72
+ try {
73
+ if (entry.dekWrapped === undefined)
74
+ throw new NmtsError("the file list holds no key for it");
75
+ // ⛔ THE DIRECTORY IS MADE BEFORE THE DOWNLOAD, because the file is now written as it is
76
+ // decrypted: its temporary name lives beside the destination, so the destination's
77
+ // directory has to exist before the first byte arrives rather than after the last.
78
+ mkdirSync(join(local, ".."), { recursive: true });
79
+ const fetched = await fetchFile({
80
+ base: session.server,
81
+ apiKey: session.apiKey,
82
+ accountCode: session.code,
83
+ itemId: entry.id,
84
+ size: entry.size,
85
+ dekWrapped: entry.dekWrapped,
86
+ ...(entry.contentHashCt === undefined ? {} : { contentHashCt: entry.contentHashCt }),
87
+ chain,
88
+ sink: fileSink(local, { force: options.force === true }),
89
+ });
90
+ outcomes.push({ path: drivePath, bytes: fetched.byteCount, state: "written" });
91
+ }
92
+ catch (error) {
93
+ // ⛔ COUNTED AND CARRIED ON. See the module note: refusing the whole pull over one file is
94
+ // what makes somebody run it twenty times and lose the same nineteen files each time.
95
+ outcomes.push({
96
+ path: drivePath,
97
+ bytes: entry.size,
98
+ state: "failed",
99
+ why: error instanceof Error ? error.message : String(error),
100
+ });
101
+ }
102
+ }
103
+ const written = outcomes.filter((o) => o.state === "written");
104
+ const skipped = outcomes.filter((o) => o.state === "skipped");
105
+ const failed = outcomes.filter((o) => o.state === "failed");
106
+ if (options.json) {
107
+ say(JSON.stringify({ files: outcomes.length, written: written.length, skipped: skipped.length, failed: failed.map((f) => ({ path: f.path, why: f.why })), out: base }));
108
+ return failed.length === 0 ? 0 : 1;
109
+ }
110
+ say(``);
111
+ say(`${written.length} written · ${skipped.length} already there · ${failed.length} failed`);
112
+ if (skipped.length > 0) {
113
+ say(``);
114
+ say(` What was already there was left alone. --force replaces it, which cannot be undone.`);
115
+ }
116
+ if (failed.length > 0) {
117
+ say(``);
118
+ for (const one of failed)
119
+ say(` ${one.path}: ${one.why ?? "failed"}`);
120
+ say(``);
121
+ say(` Everything else was written. Running this again retries only what is missing.`);
122
+ return 1;
123
+ }
124
+ return 0;
125
+ }
126
+ /** Every live file at or under `parentId`, in path order. */
127
+ function filesUnder(index, entries, parentId) {
128
+ const under = (entry) => {
129
+ if (parentId === null)
130
+ return true;
131
+ let at = entry;
132
+ const seen = new Set();
133
+ while (at !== undefined) {
134
+ if (at.parentId === parentId)
135
+ return true;
136
+ if (at.parentId === null || seen.has(at.parentId))
137
+ return false;
138
+ seen.add(at.parentId);
139
+ at = index.byId.get(at.parentId);
140
+ }
141
+ return false;
142
+ };
143
+ return entries
144
+ .filter((e) => e.kind === KIND_FILE && isLive(index, e) && under(e))
145
+ .sort((a, b) => fullPathOf(index, a).localeCompare(fullPathOf(index, b)));
146
+ }
147
+ /**
148
+ * Join a drive path onto a local directory, refusing anything that would leave it.
149
+ *
150
+ * ⛔ THE NAMES COME FROM THE SEALED LIST, which is written by whoever holds the account — including
151
+ * an account somebody else set up. A name with a separator in it, or one made of dots, would
152
+ * otherwise write outside the directory that was asked for, which is a file appearing somewhere
153
+ * nobody chose.
154
+ */
155
+ /**
156
+ * Where one drive path lands under `base`, or a refusal.
157
+ *
158
+ * ⚠ `platform` exists so the Windows branch can be exercised from any machine. Without it the
159
+ * ordering below is only ever checked by the Windows runner, and it was wrong there for one
160
+ * release.
161
+ */
162
+ export function safeJoin(base, drivePath, platform = process.platform) {
163
+ const segments = drivePath.split("/").filter((s) => s !== "");
164
+ for (const segment of segments) {
165
+ // ⛔ THE ORDER OF THESE TWO IS LOAD-BEARING. A segment that climbs — `..` — also ends in a dot,
166
+ // which is one of the shapes Windows will not keep, so putting the platform check first made
167
+ // a path-traversal attempt report itself as a naming problem, and only on Windows. The
168
+ // reason a refusal gives has to be the same reason on every machine.
169
+ if (segment === "." || segment === ".." || segment.includes(sep) || isAbsolute(segment)) {
170
+ throw new NmtsError(`"${drivePath}" cannot be written to a path on this machine.`, {
171
+ exitCode: 4,
172
+ nextStep: "Nothing was written. Rename it in the drive, then pull again.",
173
+ });
174
+ }
175
+ // A name this machine cannot hold is refused BEFORE anything is fetched — on Windows some of
176
+ // them are accepted by the operating system and then keep nothing (see `safe-path.ts`).
177
+ refuseUnwritableName(segment, platform);
178
+ }
179
+ const full = resolve(base, ...segments);
180
+ const inside = relative(base, full);
181
+ if (inside.startsWith("..") || isAbsolute(inside)) {
182
+ throw new NmtsError(`"${drivePath}" would be written outside the destination.`, { exitCode: 4 });
183
+ }
184
+ return full;
185
+ }