@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
package/dist/help.js ADDED
@@ -0,0 +1,192 @@
1
+ // What `nmts` prints when asked what it can do.
2
+ //
3
+ // ⛔ COMMANDS THAT ARE NOT BUILT ARE MARKED, NOT HIDDEN. An agent that discovers a command by
4
+ // running it and getting "unknown command" learns the wrong thing — it will try synonyms. Being
5
+ // told the command exists and is unfinished is a fact it can act on: stop, and do not retry.
6
+ import { AGENTS_DOC, BINARY_NAME, HOME_URL, PRODUCT_NAME, SOURCE_URL } from "./product.js";
7
+ import { API_KEY_ENV_VAR, API_KEY_FILE_ENV_VAR, CODE_ENV_VAR, CODE_FILE_ENV_VAR, PASSPHRASE_ENV_VAR, } from "./credentials.js";
8
+ import { NETWORK_ENV_VAR } from "./network.js";
9
+ import { LATEST_RELEASE_URL, NO_CHECK_ENV_VAR } from "./update-source.js";
10
+ import { SERVER_ENV_VAR } from "./server.js";
11
+ import { AGGREGATOR_ENV_VAR } from "./walrus.js";
12
+ import { CHECK_DOES_NOT_PROVE, CHECK_PROVES, RECOVERY_TOOL_URL, wrapText, } from "./recovery-release.js";
13
+ export function helpText(version) {
14
+ return [
15
+ `${PRODUCT_NAME} ${version} — command-line access to end-to-end encrypted NMTS storage.`,
16
+ ``,
17
+ `USAGE`,
18
+ ` ${BINARY_NAME} <command> [options]`,
19
+ ``,
20
+ `COMMANDS`,
21
+ ` login Keep an account code on this machine, sealed — and take an API key`,
22
+ ` logout Remove the stored account code`,
23
+ ` whoami Show which account the stored code belongs to (offline)`,
24
+ ` create Make a NEW account. Prints its code once — nothing can print it again`,
25
+ ` ls List files in the account`,
26
+ ` usage What the account holds: counts, bytes, largest files, trash`,
27
+ ` balance Credits left, what they buy, and the ceilings on spending`,
28
+ ` trial What is left of this week's free credits. \`trial apply\` asks for some`,
29
+ ` expiring List files whose bought storage runs out soon`,
30
+ ` extend <path> Buy more storage time for one file — **spends WAL from the wallet**`,
31
+ ` public-code The code other accounts send files to. \`--publish\` makes it reachable`,
32
+ ` wallet Show the account's wallet address, and its SUI and WAL balances`,
33
+ ` wallet address Just the address, derived on this machine — no network call`,
34
+ ` put <file> Encrypt one file and upload it, paid with credits`,
35
+ ` get <path> Download one file and decrypt it`,
36
+ ` pull [folder] Download a whole folder, or the whole account, keeping its shape`,
37
+ ` push <directory> Upload a whole directory, keeping its shape — **spends credits**`,
38
+ ` rm <paths> Move things to the trash — restorable for 30 days`,
39
+ ` restore <paths> Bring things back out of the trash`,
40
+ ` sweep Drop trash entries whose 30 days have run out. Asks first`,
41
+ ` rebuild Build a file list from the server's rows, for an account with none`,
42
+ ` listfile Write this machine's copy of the sealed file list out as a file`,
43
+ ` mkdir <path> Make a folder, and any folder above it that is missing`,
44
+ ` mv <paths> <folder> Move things into a folder. \`/\` is the top of the drive`,
45
+ ` rename <path> <name> Give one thing a new name`,
46
+ ` star <files> Star files — they are gathered in favourites as well`,
47
+ ` unstar <files> Take the star off`,
48
+ ` pin <files> Hold files at the top of the folder they are in`,
49
+ ` unpin <files> Let them fall back into the ordinary order`,
50
+ ` label <name> <files> Put one label on files. A label exists while a file wears it`,
51
+ ` unlabel <name> <files>`,
52
+ ` Take one label off files`,
53
+ ` share <path> <addr> Give one file to another account. Withdrawing does not recall it`,
54
+ ` shares What was shared with this account`,
55
+ ` receive <id> Download one file somebody shared with this account`,
56
+ ` unshare <id> Withdraw a share you sent, or remove one you were sent`,
57
+ ` env What this machine is, and what it means for the account code`,
58
+ ` consent See what this machine has agreed to, and change it`,
59
+ ` mode How much an agent driving this tool may decide without asking`,
60
+ ` on-collision What to do when an upload's name is already in use`,
61
+ ` update Install the newest published release of this tool`,
62
+ ` recovery Download the standalone program that reads files back without NMTS`,
63
+ ` recovery-list Write the file that finds your bytes without NMTS. Holds no code`,
64
+ ` kit Recovery kit: that list AND your account code, together in one file`,
65
+ ` verify Ask a person to pass the check that opens this account's limits`,
66
+ ` mcp Serve this account's commands as tools, for an agent that speaks MCP`,
67
+ ` s3 Serve the drive to any S3 program, on this machine only`,
68
+ ``,
69
+ `OPTIONS`,
70
+ ` --server <url> NMTS server (default ${SERVER_ENV_VAR} or the live one)`,
71
+ ` --network <name> mainnet or testnet. Required for any server but the live one`,
72
+ ` --json Machine-readable output (ls, usage, get, put, env, verify,`,
73
+ ` expiring, sweep, rebuild, pull, wallet, shares, share, receive,`,
74
+ ` recovery, balance, trial, extend, recovery-list, kit, update,`,
75
+ ` create — create needs --out as well, so the code never enters`,
76
+ ` the output)`,
77
+ ` --all Include what is in the trash (ls)`,
78
+ ` --find <text> List only the files whose name contains this text (ls). Folders`,
79
+ ` appear only where they hold a match`,
80
+ ` --sort <key> name, size or date. Default: whole paths, ascending (ls)`,
81
+ ` --desc Reverse whichever order is in effect (ls)`,
82
+ ` --hidden Include entries whose name begins with a dot (push)`,
83
+ ` --out <path> Where to write files (get, pull, mcp, listfile, recovery,`,
84
+ ` recovery-list, kit, create). Default: here`,
85
+ ` --out - Send a fetched file to stdout instead of writing it (get, listfile)`,
86
+ ` --force Replace a file that is already there (get, pull, listfile,`,
87
+ ` recovery, recovery-list, kit), or rebuild an account this machine`,
88
+ ` has seen a list for (rebuild). \`create\` never replaces a file`,
89
+ ` --name <name> The name the uploaded file gets in the drive (put)`,
90
+ ` --to <folder> Destination folder, as \`${BINARY_NAME} ls\` prints it (put, push)`,
91
+ ` --dry-run Say what it would cost, and stop (put, push, extend), or what it`,
92
+ ` would install, and stop (update). With \`extend\` nothing is`,
93
+ ` signed and the wallet key is never touched`,
94
+ ` --part-size <n> Bytes per part for a large upload (put, push). Default 64MiB.`,
95
+ ` --on-collision <what> rename | overwrite, for THIS run only (put, push). Without it the`,
96
+ ` machine's own setting decides -- see \`${BINARY_NAME} on-collision\`.`,
97
+ ` a plain number, or one with KiB, MiB or GiB. Bigger parts mean`,
98
+ ` fewer purchases; smaller parts use less memory`,
99
+ ` --yes Go ahead with something that cannot be taken back (sweep, rebuild),`,
100
+ ` or extend a file that is nowhere near its deadline (extend)`,
101
+ ` --epochs <n> How many of the storage network's epochs to add (extend)`,
102
+ ` --port <n> Which loopback port to listen on (s3). Default 9000`,
103
+ ` --accept-terms <v> The version of the Terms of Service a PERSON read and accepts for`,
104
+ ` the new account (create). A version, never "the current one"`,
105
+ ` --accept-privacy <v> The version of the Privacy Policy accepted in the same act (create)`,
106
+ ` --publish Publish this account's public code (public-code). Permanent —`,
107
+ ` it cannot be withdrawn or changed afterwards`,
108
+ ` --status Say whether the check is live, and stop (verify)`,
109
+ ` --plain Store the code unsealed instead (login). Asks for an agreement`,
110
+ ` --env Store nothing; print the variable to set (login). Same agreement`,
111
+ ` --version Print the version and exit`,
112
+ ` --help Print this and exit`,
113
+ ``,
114
+ `ENVIRONMENT`,
115
+ ` ${CODE_FILE_ENV_VAR.padEnd(24)}Names a FILE holding the account code. The recommended way, and`,
116
+ ` the only one that asks nothing: the file is read, never copied.`,
117
+ ` ${CODE_ENV_VAR.padEnd(24)}The account code itself. Takes precedence over anything stored,`,
118
+ ` and asks once — an environment variable is readable through`,
119
+ ` \`docker inspect\`, /proc/<pid>/environ and most CI logs.`,
120
+ ` ${PASSPHRASE_ENV_VAR.padEnd(24)}Opens a sealed stored code without a terminal.`,
121
+ ` ${API_KEY_FILE_ENV_VAR.padEnd(24)}Names a FILE holding the API key. \`${BINARY_NAME} login\` reads this`,
122
+ ` first, and a container cannot leak it the way it leaks a value.`,
123
+ ` ${API_KEY_ENV_VAR.padEnd(24)}Key made on the account screen. It waives the human check a`,
124
+ ` browser sign-in does, and nothing else — it opens no file.`,
125
+ ` \`${BINARY_NAME} login\` checks one it finds here with the server and`,
126
+ ` then stores it, so it outlives the terminal it was set in.`,
127
+ ` ${SERVER_ENV_VAR.padEnd(24)}Server to talk to. For development stacks.`,
128
+ ` ${AGGREGATOR_ENV_VAR.padEnd(24)}Storage-network read hosts, comma-separated. Replaces`,
129
+ ` the built-in list rather than adding to it.`,
130
+ ` ${NETWORK_ENV_VAR.padEnd(24)}mainnet or testnet. Never guessed: a wrong one looks in a place`,
131
+ ` your files were never stored.`,
132
+ ` ${NO_CHECK_ENV_VAR.padEnd(24)}Set to anything to stop the version check described below.`,
133
+ ``,
134
+ // ⛔ THE TWO SENTENCES ABOUT THE CHECK ARE IMPORTED, NOT RETYPED. They are also printed by
135
+ // the command itself, and a second copy is how one of them starts promising more than the
136
+ // other. What is added here is the other half of the same honesty: where the file goes.
137
+ `THE RECOVERY PROGRAM`,
138
+ ` \`${BINARY_NAME} recovery\` downloads one executable for this machine from the releases at`,
139
+ ``,
140
+ ` ${RECOVERY_TOOL_URL}`,
141
+ ``,
142
+ ...wrapText(CHECK_PROVES).map((line) => ` ${line}`),
143
+ ...wrapText(CHECK_DOES_NOT_PROVE).map((line) => ` ${line}`),
144
+ ``,
145
+ ` It writes one file where you point it, refuses a name that is already taken unless you`,
146
+ ` pass --force, and installs nothing: no copy goes onto your PATH.`,
147
+ ``,
148
+ // ⛔ IT IS WRITTEN DOWN BECAUSE IT IS THE ONLY REQUEST THIS TOOL MAKES THAT NOBODY ASKED FOR.
149
+ // Everything else it sends goes to the NMTS server or the storage network because a command
150
+ // needed it. This one goes to a third host, on its own, and a tool that does that without
151
+ // saying so is one whose network behaviour has to be found out with a packet capture.
152
+ `THE VERSION CHECK`,
153
+ ` Once a day, after a command has finished, this asks which release of this tool is newest:`,
154
+ ``,
155
+ ` ${LATEST_RELEASE_URL}`,
156
+ ``,
157
+ ` and writes the answer down. When it is newer than the one running, the NEXT run prints one`,
158
+ ` line on stderr saying so. It sends no account code, no key and no command name: the`,
159
+ ` request is for a page address, and what the site can see is that it was asked.`,
160
+ ``,
161
+ ` Setting ${NO_CHECK_ENV_VAR} to anything stops both halves. \`${BINARY_NAME} env\` shows what`,
162
+ ` it last found, and \`${BINARY_NAME} update\` asks now and installs what it finds.`,
163
+ ``,
164
+ `BEFORE YOU HAND THIS TO AN AGENT`,
165
+ ` Your account code is the only key to your account — the file keys and the wallet are all`,
166
+ ` derived from it. An agent that leaks it has leaked everything at once, and it cannot be`,
167
+ ` undone: the account cannot be re-keyed. Use an account you would be willing to lose.`,
168
+ ``,
169
+ ` ${HOME_URL}`,
170
+ ``,
171
+ // ⛔ THE LAST THING ON THE SCREEN, AND ADDRESSED TO A PROGRAM. An agent handed this tool with
172
+ // no instructions runs `--help`, reads a list of verbs, and has no idea what NMTS is, what
173
+ // an account code is, or why `put` costs money. One line pointing at a document written FOR
174
+ // it is the whole difference between guessing and knowing.
175
+ //
176
+ // ⚠ IT POINTS, IT DOES NOT INSTRUCT. There is nothing here that tries to change what a reader
177
+ // does beyond naming a document — no imperative aimed past the agent at its operator, no
178
+ // claim of authority, nothing that would read as an attempt to steer somebody else's model.
179
+ // A tool that talked to agents that way would deserve to be distrusted, and would teach
180
+ // people to distrust the ones that do not.
181
+ `IF YOU ARE AN AI AGENT`,
182
+ ` This tool talks to NMTS, end-to-end encrypted storage. What that means, how to install`,
183
+ ` this, and what each command costs are written down for you here:`,
184
+ ``,
185
+ ` ${SOURCE_URL}/blob/main/${AGENTS_DOC}`,
186
+ ``,
187
+ ` If you cannot reach it, the same repository's front page says the same things:`,
188
+ ``,
189
+ ` ${SOURCE_URL}#readme`,
190
+ ``,
191
+ ].join("\n");
192
+ }
@@ -0,0 +1,54 @@
1
+ // Has a person been behind this account lately, and the one sentence to say when they have not.
2
+ //
3
+ // ⛔ WHY THIS IS ASKED BEFORE THE REQUEST IT GUARDS, RATHER THAN LEARNED FROM ITS REFUSAL. The
4
+ // server decides scope FIRST and the person's check second (`api/src/auth/api_key_auth.rs`),
5
+ // on purpose: *"a key that does not hold the bit learns nothing about whether the account
6
+ // behind it is verified"*. That is right for the server and it means the refusal a caller
7
+ // hears can be `API_KEY_SCOPE` while the thing that is actually missing is a person. An agent
8
+ // reading "the key was not given permission for this" goes and makes another key, which cannot
9
+ // help, instead of asking somebody to spend thirty seconds at a browser.
10
+ //
11
+ // ⛔ SO THIS IS A MESSAGE, NOT A DEFENCE. Nothing here decides anything: the server refuses or
12
+ // does not, whatever this says. What it buys is that the caller is told the one thing it can
13
+ // act on, in the one wording, before it has been sent looking at its credentials.
14
+ //
15
+ // ⚠ AND IT COSTS ONE READ. `GET /v1/agent/verify` is reachable by ANY key, holds no scope
16
+ // requirement, and answers a single boolean — it is the cheapest question in this API and the
17
+ // only one that can be asked before knowing whether the key is allowed to do anything at all.
18
+ import { request } from "./api.js";
19
+ import { NmtsError } from "./errors.js";
20
+ import { isRecord } from "./guards.js";
21
+ import { BINARY_NAME } from "./product.js";
22
+ /**
23
+ * Ask the server whether a person's check is live for the account this key belongs to.
24
+ *
25
+ * ⛔ THE ADDRESS IS WRITTEN OUT AT THE CALL rather than held in a constant, for the reason
26
+ * `commands/verify.ts` gives: the gate that checks this tool's addresses against the server's
27
+ * own routes reads the literal in the call, and a constant would make it stop looking here.
28
+ */
29
+ export async function humanCheck(server, apiKey) {
30
+ const answer = await request(server, "/v1/agent/verify", { token: apiKey });
31
+ if (!isRecord(answer) || typeof answer["verified"] !== "boolean") {
32
+ throw new NmtsError("The server's answer did not say whether this account is verified.", {
33
+ exitCode: 1,
34
+ nextStep: `This version of \`${BINARY_NAME}\` and that server do not agree about this. Update the tool, or check --server.`,
35
+ });
36
+ }
37
+ const until = answer["verified_until"];
38
+ return { live: answer["verified"], until: typeof until === "string" ? until : null };
39
+ }
40
+ /**
41
+ * The one refusal for "a person has to do something first".
42
+ *
43
+ * ⛔ IT NAMES THE COMMAND AND SAYS WHO HAS TO RUN IT. `verify` prints a short code and waits; the
44
+ * typing is a person's, at a browser, and no amount of retrying here replaces it. Saying only
45
+ * "refused" would send an agent round the loop of credentials it already has.
46
+ */
47
+ export function askAPersonToVerify(whatIsRefused) {
48
+ return new NmtsError(`${whatIsRefused} until somebody passes this account's human check.`, {
49
+ exitCode: 4,
50
+ nextStep: `Run \`${BINARY_NAME} verify\`. It prints a short code and an address; a person opens that ` +
51
+ `address, types the code, and this account's limits are lifted for four of the server's ` +
52
+ `weeks. Nothing on this machine can pass that check — being unable to is what it measures.`,
53
+ });
54
+ }
@@ -0,0 +1,25 @@
1
+ // Move one item into the trash on the server, or bring it back.
2
+ //
3
+ // ⛔ 404 IS "ALREADY IN THE STATE YOU ASKED FOR", which is what a half-finished run leaves behind.
4
+ // Treating it as a failure would make the retry of an interrupted command impossible -- the one
5
+ // moment the retry is needed.
6
+ //
7
+ // ⛔ AND THIS IS AS FAR AS AN API KEY REACHES. `POST /v1/items/erase` -- the one that destroys a
8
+ // row for good -- is closed to keys and stays closed (`api` domain/agent_routes.rs, Reach::Never:
9
+ // "PERMANENT destruction. The soft delete is reachable because it is recoverable; this is the
10
+ // line where that stops."). Anything in this tool that says a file is gone forever is wrong.
11
+ import { request, ServerError } from "./api.js";
12
+ export async function setTrashed(base, apiKey, id, trashed) {
13
+ const held = encodeURIComponent(id);
14
+ try {
15
+ if (trashed)
16
+ await request(base, `/v1/items/${held}`, { method: "DELETE", token: apiKey });
17
+ else
18
+ await request(base, `/v1/items/${held}/restore`, { method: "POST", token: apiKey, body: {} });
19
+ }
20
+ catch (error) {
21
+ if (error instanceof ServerError && error.status === 404)
22
+ return;
23
+ throw error;
24
+ }
25
+ }
@@ -0,0 +1,133 @@
1
+ // The RECOVERY KIT — the one file that carries everything, account code included.
2
+ //
3
+ // ⛔⛔ IT HOLDS THE ACCOUNT CODE IN THE CLEAR, AND THAT IS THE FORMAT. It was decided deliberately
4
+ // and against the earlier rule, by the owner, with the cost stated: whoever holds this file
5
+ // holds the account AND the wallet, because one code derives both. The two artefacts therefore
6
+ // mean two different things and every screen and every command must say which is which:
7
+ // · recovery LIST — sealed. Worthless to a thief. The account code opens it.
8
+ // · recovery KIT — everything. One stolen file is a total loss.
9
+ // The warning is not a formality; it is what makes the choice an honest one to offer.
10
+ //
11
+ // ⛔ THERE IS NO SPECIFICATION FOR THIS FORMAT. Its only authorities are the two programs that
12
+ // already write and read it: `web/src/lib/auth/recovery-kit.ts` (writer) and
13
+ // `recovery/src/kitfile.rs` (reader). The shape below was read off BOTH.
14
+ // ⚠ WHERE THEY DIFFER: the reader requires only `format`, `version` and `account_id`, treats
15
+ // `account_code` and `recovery_list` as optional, and reads nothing else — no
16
+ // `generated_at`, no `account_fingerprint`, no `recovery_manifest_blob`, no `about`. The
17
+ // writer always emits all of them. That is not a contradiction, it is the reader being
18
+ // deliberately lenient (it sets no `deny_unknown_fields`), and it is why adding a field never
19
+ // moved the kit version. This writer emits the writer's full set.
20
+ //
21
+ // ⛔ THE MACHINE BLOCK IS DELIMITED BY FIXED ASCII, NEVER BY THE HEADING ABOVE IT. The headings are
22
+ // written in whatever language the person was using; a parser that looked for one would work in
23
+ // one language and fail in another — on the day somebody is recovering, which is the only day
24
+ // it matters.
25
+ //
26
+ // ⚠ THE HUMAN HALF IS ENGLISH ONLY, and the browser's is bilingual. The English sentences below
27
+ // are the site's own, word for word (`RecoveryKit.*` in `web/src/messages/en.json`), so the two
28
+ // programs do not describe one artefact differently; the Korean half lives in message bundles
29
+ // this package does not import, and inventing a translation here would be worse than the gap.
30
+ // `cli/src/list-file.ts` set that precedent for the sibling artefact.
31
+ import { createHash } from "node:crypto";
32
+ import { artifactAbout } from "./artifact-about.js";
33
+ import { RECOVERY_TOOL_URL } from "./recovery-release.js";
34
+ /** Start of the machine block. ⛔ Fixed bytes, never translated. */
35
+ export const KIT_DATA_BEGIN = "--- BEGIN NMTS RECOVERY KIT DATA ---";
36
+ /** End of the machine block. */
37
+ export const KIT_DATA_END = "--- END NMTS RECOVERY KIT DATA ---";
38
+ /** The marker a reader matches on before anything else is attempted. */
39
+ export const KIT_FORMAT = "nmts-recovery-kit";
40
+ /**
41
+ * Kit version. 2 is the one that carries the recovery list; v1 carried only the code.
42
+ *
43
+ * ⛔ NOT RAISED FOR ANYTHING THIS TOOL ADDS. `MAX_KIT_VERSION` in the standalone program refuses a
44
+ * higher number outright, so a bump breaks every published build in exchange for fields those
45
+ * builds are happy to ignore.
46
+ */
47
+ export const KIT_VERSION = 2;
48
+ /**
49
+ * A short, human-checkable fingerprint of the PUBLIC account id.
50
+ *
51
+ * ⚠ RESTATED FROM `web/src/lib/auth/account-code.ts::accountIdFingerprint`, which this package
52
+ * cannot import. It has to produce the same string: a person comparing a kit written here with
53
+ * one written in a browser is checking two spellings of the same account.
54
+ *
55
+ * ⛔ IT FINGERPRINTS THE ACCOUNT ID, WHICH IS PUBLIC — never the account code and never a key. It
56
+ * lets somebody confirm two files refer to one account without either of them exposing a secret.
57
+ */
58
+ export function accountIdFingerprint(accountId) {
59
+ const hex = createHash("sha256")
60
+ .update(accountId, "utf8")
61
+ .digest("hex")
62
+ .slice(0, 16)
63
+ .toUpperCase();
64
+ // Grouped 4 by 4 for reading aloud: "AB12-CD34-EF56-7890".
65
+ return hex.replace(/(.{4})(?=.)/g, "$1-");
66
+ }
67
+ /** Build the kit's text: a part for a person, then a part for a program. */
68
+ export function buildRecoveryKit(input) {
69
+ const data = {
70
+ format: KIT_FORMAT,
71
+ version: KIT_VERSION,
72
+ generated_at: input.generatedAt,
73
+ account_id: input.accountId,
74
+ account_fingerprint: accountIdFingerprint(input.accountId),
75
+ account_code: input.code,
76
+ // ⛔ ALWAYS NULL HERE. That field names a copy of the list on the storage network, and this
77
+ // tool writes no such copy — a value would be an address nothing was ever written to.
78
+ recovery_manifest_blob: null,
79
+ recovery_list: input.recoveryList,
80
+ about: artifactAbout("recovery-kit"),
81
+ };
82
+ const lines = [
83
+ `# NMTS Recovery Kit`,
84
+ `Created: ${input.generatedAt}`,
85
+ ``,
86
+ // ⛔ THE THEFT WARNING COMES FIRST, ABOVE THE CODE IT IS ABOUT. A caution printed underneath
87
+ // the thing it cautions about has already been disregarded by the time it is read.
88
+ `⛔ Anyone who holds this file holds this account: every file in it, and the wallet that pays ` +
89
+ `for storage. One account code opens both.`,
90
+ `Do not keep it in a folder that syncs or backs up on its own, and do not send it to anyone. ` +
91
+ `A drawer is often safer than a cloud folder.`,
92
+ ``,
93
+ `This file is the only way to recover your NMTS files. Keep it private.`,
94
+ `This file carries your account code in the clear, your account identifier and fingerprint, ` +
95
+ `and the whole recovery list.`,
96
+ `Anyone with this code can open your files. If you lose it, no one — including NMTS — can ` +
97
+ `recover them.`,
98
+ ``,
99
+ `Account code:`,
100
+ ` ${input.code}`,
101
+ ``,
102
+ `Account identifier: ${data.account_id}`,
103
+ `Fingerprint: ${data.account_fingerprint}`,
104
+ `Recovery manifest blob: not yet created`,
105
+ `Recovery list: ${input.recoveryList === null
106
+ ? `not included — this account had no files when the kit was made`
107
+ : `included in this file (${input.listFileCount ?? 0} files)`}`,
108
+ ``,
109
+ `--- HOW TO GET YOUR FILES BACK (English) ---`,
110
+ `1. Get the recovery program from ${RECOVERY_TOOL_URL} — it runs on Linux, macOS and ` +
111
+ `Windows, and its full source is there.`,
112
+ `2. Open a terminal where you saved the program, and run:`,
113
+ ` nmts-recovery --map <this file> --out <a folder to write into>`,
114
+ ` It takes the account code and the file list out of this file, fetches your files from ` +
115
+ `public Walrus storage, checks every piece, and writes them out.`,
116
+ `3. To click instead of typing, run: nmts-recovery --gui`,
117
+ ` That opens a page only this machine can reach. The program still does the work; the page ` +
118
+ `only shows the list and sends back what you ticked.`,
119
+ `4. To see what your account code derives — your public code and your wallet addresses — ` +
120
+ `run: nmts-recovery --derive`,
121
+ `If you would rather it opened no network connections, run it with --print-fetch-plan: it ` +
122
+ `prints the exact addresses to fetch by hand, then restores from the folder you filled.`,
123
+ ``,
124
+ `--- FOR A PROGRAM ---`,
125
+ KIT_DATA_BEGIN,
126
+ JSON.stringify(data, null, 2),
127
+ KIT_DATA_END,
128
+ ``,
129
+ ];
130
+ // A short id slug keeps filenames distinct without printing the whole account id.
131
+ const slug = input.accountId.replace(/[^A-Za-z0-9]/g, "").slice(0, 8) || "account";
132
+ return { filename: `nmts-recovery-kit-${slug}.txt`, content: lines.join("\n") };
133
+ }
@@ -0,0 +1,71 @@
1
+ // The sealed file list as a FILE somebody keeps — the same wrapper the browser hands out.
2
+ //
3
+ // ⛔ ONE FORMAT, NOT TWO. `format`, `version` and the `.nmtslist` extension are what a reader
4
+ // matches on, and a person recovering an account may hold copies written by a browser and by
5
+ // this command months apart. A second spelling of the same artefact would mean whoever helps
6
+ // them has two formats to work out instead of one, at the worst possible moment. Everything in
7
+ // the document below is therefore the browser's, byte for byte, with the two exceptions named
8
+ // at the bottom of this comment.
9
+ //
10
+ // ⛔ NOTHING SECRET IN THE HEADER, AND THAT INCLUDES SIZES. The plaintext part of this file says
11
+ // what the file IS — never what is inside it, not a file name, not a count, not a total. The
12
+ // contents are one sealed envelope that opens with the account code and nothing else, so a
13
+ // stranger who finds this learns which account it belongs to and no more.
14
+ //
15
+ // ⛔ THE ACCOUNT CODE IS NOT IN IT, and that is not an oversight to fix later. This file plus the
16
+ // code is the account; keeping both in one place would make one theft into a total loss.
17
+ //
18
+ // ⚠ WHAT DIFFERS FROM THE BROWSER'S COPY, and neither is a format difference:
19
+ // · The finder's note is English only. The browser ships it in English and Korean because it
20
+ // cannot know which language the person who finds the file years later reads; this package
21
+ // ships English strings only, and inventing a translation here would be worse than the gap.
22
+ // · `about.app_version` names THIS PROGRAM and its version rather than the site release. It is
23
+ // the field's own contract — a claim the writer makes about itself — and a person holding two
24
+ // copies of one account's list can then tell which program wrote each.
25
+ import { artifactAbout, WRITTEN_BY } from "./artifact-about.js";
26
+ /** Wrapper format identifier, distinct from the recovery list's. */
27
+ export const LIST_FILE_FORMAT = "nmts-file-list";
28
+ /** The shell's version. Not the file list's own version, which is `seq`. */
29
+ export const LIST_FILE_VERSION = 1;
30
+ /** Filename extension. Deliberately not the recovery list's: the two must not be confusable. */
31
+ export const LIST_FILE_EXTENSION = "nmtslist";
32
+ // ⚠ THE HEADER BLOCK IS NOT SPELLED HERE. Three artefacts carry the same one — this file, the
33
+ // recovery list and the recovery kit — and it is built in `artifact-about.ts` so that a renamed
34
+ // program or a moved document reaches all three. `WRITTEN_BY` is re-exported because it is the
35
+ // value this wrapper puts in `app_version`, and a test reads it from here.
36
+ export { WRITTEN_BY };
37
+ /** Plain-language lines for whoever finds this file with no idea what it is. */
38
+ const NOTE = [
39
+ `This file is an encrypted copy of an NMTS (nmts.me) file list — the names, folders and file ` +
40
+ `keys of one account, locked with its account code.`,
41
+ `It is written by the \`nmts\` command from the copy that machine keeps. A higher number in the ` +
42
+ `filename is a newer copy.`,
43
+ `It does not replace the recovery list: storage-network addresses live only in the recovery ` +
44
+ `list. Keep both, somewhere other than the account code.`,
45
+ ];
46
+ /**
47
+ * Build the document and the name to offer it under.
48
+ *
49
+ * Pure on purpose: it touches no disk and no clock, so what it produces can be compared against
50
+ * the format itself rather than against whatever the machine running it happened to be doing.
51
+ *
52
+ * ⚠ THE VERSION IS IN THE FILENAME, ZERO-PADDED, so a folder holding several copies sorts into
53
+ * the order they were written and the newest is the last one.
54
+ */
55
+ export function buildFileListFile(input) {
56
+ const doc = {
57
+ format: LIST_FILE_FORMAT,
58
+ version: LIST_FILE_VERSION,
59
+ seq: input.seq,
60
+ ...(input.savedAt === undefined ? {} : { saved_at: input.savedAt }),
61
+ account_id: input.accountId,
62
+ sealed: input.sealed,
63
+ note: [...NOTE],
64
+ about: artifactAbout("file-list"),
65
+ };
66
+ const slug = input.accountId.replace(/[^A-Za-z0-9]/g, "").slice(0, 8) || "account";
67
+ return {
68
+ filename: `nmts-file-list-${slug}-${String(input.seq).padStart(4, "0")}.${LIST_FILE_EXTENSION}`,
69
+ content: `${JSON.stringify(doc, null, 2)}\n`,
70
+ };
71
+ }
@@ -0,0 +1,71 @@
1
+ // `--find` — which entries a name query leaves in the listing.
2
+ //
3
+ // ⛔ THE QUERY NAMES FILES. A folder is never matched by it, and a folder whose own name contains
4
+ // the text is not a result. Somebody searching for `report` wants the files called report, and
5
+ // a listing that also printed an empty folder called `reports` would be answering a question
6
+ // nobody asked — worse, it would print a row with nothing under it and no way to tell whether
7
+ // the folder is empty or whether its contents were filtered away.
8
+ //
9
+ // ⛔ BUT A MATCH KEEPS ITS FOLDERS. `ls` prints whole paths, and the folders on the way to a match
10
+ // are part of what that path means; dropping them would leave the listing describing a drive
11
+ // whose shape is not this account's. So the entries that survive are: every file that matches,
12
+ // plus every folder between one of those files and the top of the drive.
13
+ //
14
+ // ⛔ WHICH MEANS: UNDER A QUERY, A FOLDER HOLDING NO MATCH IS NOT PRINTED — an empty folder never
15
+ // is, and neither is a full one whose files all fail the query. That omission is deliberate and
16
+ // it is SAID OUT LOUD by the listing that made it, because a filtered listing that looks like a
17
+ // full one is exactly how somebody concludes a folder was lost.
18
+ //
19
+ // ⚠ FOLDING, NOT BYTES. Both sides are lowercased through the locale before comparing, which is
20
+ // what makes the search case-insensitive in the alphabets that have cases and harmless in the
21
+ // ones that do not. The stored name is never touched: it belongs to whoever wrote it.
22
+ import { KIND_FOLDER } from "./drive-paths.js";
23
+ /** The text to look for, folded. Empty means the option carried nothing to search on. */
24
+ export function needleOf(query) {
25
+ return query.trim().toLocaleLowerCase();
26
+ }
27
+ /** Does this name contain the (already folded) needle? */
28
+ export function nameContains(name, needle) {
29
+ return name.toLocaleLowerCase().includes(needle);
30
+ }
31
+ /**
32
+ * Walk from an entry to the top of the drive, marking every folder on the way as one to keep.
33
+ *
34
+ * ⚠ The `seen` set is not defensive tidiness. A list where two folders are each other's parent
35
+ * would loop here forever, and a list is a document that can be rebuilt by any device and any
36
+ * older build of any of them.
37
+ */
38
+ function keepAncestors(index, entry, keep) {
39
+ const seen = new Set([entry.id]);
40
+ let parentId = entry.parentId;
41
+ while (parentId !== null && !seen.has(parentId)) {
42
+ seen.add(parentId);
43
+ const parent = index.byId.get(parentId);
44
+ // A broken chain simply stops: the file is printed with the detached mark its path already
45
+ // carries, and nothing above the break is invented.
46
+ if (parent === undefined)
47
+ return;
48
+ keep.add(parent.id);
49
+ parentId = parent.parentId;
50
+ }
51
+ }
52
+ /**
53
+ * The ids to print for one query: the matching files, and the folders that lead to them.
54
+ *
55
+ * `entries` is what the listing was going to show anyway — live only, or everything under `--all`
56
+ * — and the ancestors added here are inside that same set by construction. Being in the trash is
57
+ * INHERITED, so a live file cannot sit under a trashed folder, and with `--all` there is nothing
58
+ * left to be outside the set.
59
+ */
60
+ export function idsForQuery(index, entries, needle) {
61
+ const keep = new Set();
62
+ for (const entry of entries) {
63
+ if (entry.kind === KIND_FOLDER)
64
+ continue;
65
+ if (!nameContains(entry.name, needle))
66
+ continue;
67
+ keep.add(entry.id);
68
+ keepAncestors(index, entry, keep);
69
+ }
70
+ return keep;
71
+ }
@@ -0,0 +1,96 @@
1
+ // The order a listing comes out in — the browser drive's rule, written out again here.
2
+ //
3
+ // ⛔ IT IS A TRANSCRIPTION, AND THAT IS THE WHOLE POINT. One account is read in a browser and from
4
+ // this tool, often minutes apart. If `--sort size` here and the size order there disagreed
5
+ // about two files of equal size, or about where folders sit, somebody comparing the two screens
6
+ // has no way to tell which of them is lying about their own drive — both are complete, exact
7
+ // listings of the same sealed list. So the rules below are the ones the NMTS web drive sorts
8
+ // by, transcribed in full.
9
+ //
10
+ // ⛔ TRANSCRIBED RATHER THAN IMPORTED. Nothing in this package reads the browser tree: that tree
11
+ // is React-bound and built by a different toolchain, and this tool has to keep running when it
12
+ // is packaged on its own. What holds the two together is that the rule is small enough to write
13
+ // down completely — which is what the block below does.
14
+ //
15
+ // WHAT THE RULE IS, IN FULL:
16
+ // 1. Names compare with the machine's own locale and NUMERIC collation, so `photo 2` comes
17
+ // before `photo 10`. Plain text order puts 10 first, which reads as a defect to anybody who
18
+ // has ever numbered files.
19
+ // 2. A tie on size or date falls back to the name, so two runs over one unchanged list print the
20
+ // same order rather than wobbling.
21
+ // 3. Descending is the EXACT REVERSE of ascending — the tie-break included, and not the
22
+ // comparator with its sign flipped. Flipping the sign would leave the name tie-break running
23
+ // upwards inside a downwards list, so reversing the sort would leave the tied rows sitting in
24
+ // the order they were already in.
25
+ // 4. Folders are their own group, above the files, whichever way the sort runs. The browser
26
+ // draws them as a separate section above the file list; a flat listing has no sections, so
27
+ // the group order is what carries that promise across.
28
+ // 5. Rows carrying the pinned mark are lifted to the top of their group afterwards, keeping the
29
+ // order the sort gave them. That is the only thing pinning is for, and it has to hold under
30
+ // every key and both directions or it means nothing.
31
+ //
32
+ // ⚠ A FOLDER HAS NO SIZE OF ITS OWN — the sealed list stores 0 — so sorting folders by size sorts
33
+ // them by name. That is the honest outcome rather than a gap: a folder's weight is known only by
34
+ // walking it, and inventing one to sort by would be a fabricated number in a column of measured
35
+ // ones.
36
+ //
37
+ // ⚠ ONE DIFFERENCE THAT IS NOT A DIFFERENCE. The browser compares an RFC3339 string; the sealed
38
+ // list holds milliseconds and this compares those. They order the same instants — the string
39
+ // form is zero-padded and in UTC, so text order is chronological order — and they tie on the
40
+ // same rows, because equal milliseconds render as one equal string.
41
+ import { KIND_FOLDER } from "./drive-paths.js";
42
+ import { NmtsError } from "./errors.js";
43
+ /** What `--sort` accepts, in the order the help text names them. */
44
+ export const SORT_KEYS = ["name", "size", "date"];
45
+ /**
46
+ * One of the three keys, or the refusal that names all three.
47
+ *
48
+ * ⛔ IT IS CHECKED BEFORE ANYTHING IS FETCHED. A misspelled key is a wrong command line, not a
49
+ * failing account, and an agent that read "could not list your files" after typing `--sort
50
+ * largest` would go looking at the account. Exit 2 says the same thing every other bad option
51
+ * in this tool says.
52
+ */
53
+ export function parseSortKey(input) {
54
+ // Compared rather than asserted: the compiler checks that every key in the list is a SortKey,
55
+ // which a cast from `string` would not.
56
+ for (const key of SORT_KEYS)
57
+ if (key === input)
58
+ return key;
59
+ throw new NmtsError(`"${input}" is not something this can sort by.`, {
60
+ exitCode: 2,
61
+ nextStep: `Nothing was listed. Sort by one of: ${SORT_KEYS.join(", ")}.`,
62
+ });
63
+ }
64
+ function byName(a, b) {
65
+ return a.name.localeCompare(b.name, undefined, { numeric: true });
66
+ }
67
+ function comparatorFor(sort) {
68
+ if (sort === "size")
69
+ return (a, b) => a.size - b.size || byName(a, b);
70
+ if (sort === "date")
71
+ return (a, b) => a.createdAt - b.createdAt || byName(a, b);
72
+ return byName;
73
+ }
74
+ /** One group — folders, or files — ordered, reversed if asked, then the pinned rows lifted. */
75
+ function orderGroup(rows, sort, dir) {
76
+ const sorted = [...rows].sort(comparatorFor(sort));
77
+ const ordered = dir === "desc" ? sorted.reverse() : sorted;
78
+ if (!ordered.some((row) => row.pinned === true))
79
+ return ordered;
80
+ return [
81
+ ...ordered.filter((row) => row.pinned === true),
82
+ ...ordered.filter((row) => row.pinned !== true),
83
+ ];
84
+ }
85
+ /**
86
+ * Rows in the browser's order: folders first, then files, each group by the chosen key.
87
+ *
88
+ * The input array is left alone — a caller printing one order and counting over another would
89
+ * otherwise depend on which of the two ran first.
90
+ */
91
+ export function orderRows(rows, sort, dir) {
92
+ return [
93
+ ...orderGroup(rows.filter((row) => row.kind === KIND_FOLDER), sort, dir),
94
+ ...orderGroup(rows.filter((row) => row.kind !== KIND_FOLDER), sort, dir),
95
+ ];
96
+ }