@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,335 @@
1
+ // `nmts login` — keep an account code on this machine, in one of three shapes.
2
+ //
3
+ // ⚠ IT DOES NOT CHECK THE CODE WITH THE SERVER, AND IT SAYS SO. The code never goes to the server
4
+ // at all — it is what opens the files, and nothing on the far end has ever seen it — so there
5
+ // is nobody to ask whether it is the right one. Its own check symbol is verified here, offline,
6
+ // and that is the whole of what can be known: a code for an account that does not exist is
7
+ // stored and looks fine until a command needs the server.
8
+ //
9
+ // ⛔ THE API KEY IS THE OTHER HALF OF SETTING THIS TOOL UP, AND IT IS CHECKED. It is a credential
10
+ // the SERVER issued, so the server can be asked about it — and it is, before it is written
11
+ // down, against the one route a key reaches without holding any permission. Until this command
12
+ // took one, a key could only arrive in an environment variable that vanishes with the terminal
13
+ // or by somebody editing the credentials file by hand. Where a key comes from and what is done
14
+ // to it lives in `api-key.ts`; what a person is told about it lives here.
15
+ //
16
+ // ⛔⭐ THE DEFAULT IS SEALED (owner, 2026-08-23: *support storing it, but by default only with
17
+ // encryption or the like; if they agree to a disclaimer and unlock it, allow plain storage
18
+ // too*). So there are three shapes and the person picks, rather than the tool deciding for
19
+ // them and being wrong on somebody's laptop:
20
+ //
21
+ // nmts login sealed under a passphrase. Asks nothing.
22
+ // nmts login --plain in the clear at mode 600. Asks once: `unsafe-code-storage`.
23
+ // nmts login --env stores nothing; prints the variable to set. Asks once: `plain-env`.
24
+ //
25
+ // ⚠ AND WHAT A PERSON DOES OUTSIDE THIS TOOL IS THEIRS. Writing the code into a note, a password
26
+ // manager or a repository is not something this can see, and it is not something it should try
27
+ // to stop. What it can do is make the shape it writes ITSELF a decision.
28
+ import { assertUsableCode } from "../account.js";
29
+ import { keySourceName, keyToStore, settleApiKey } from "../api-key.js";
30
+ import { lockCode, samePassphrase } from "../code-vault.js";
31
+ import { API_KEY_ENV_VAR, API_KEY_FILE_ENV_VAR, CODE_ENV_VAR, CODE_FILE_ENV_VAR, PASSPHRASE_ENV_VAR, codeStorageIsPrivate, credentialsPath, modesAreEnforced, readCredentialsFile, readSecretFile, writeCredentials, } from "../credentials.js";
32
+ import { requireConsent } from "../consent.js";
33
+ import { NmtsError } from "../errors.js";
34
+ import { firstRunNotice } from "../notice.js";
35
+ import { holdTerminal, promptSecret, stdinIsATerminal } from "../prompt.js";
36
+ import { askAboutCollisions } from "../setup-questions.js";
37
+ import { BINARY_NAME, HOME_URL } from "../product.js";
38
+ import { resolveNetwork } from "../network.js";
39
+ import { resolveServer } from "../server.js";
40
+ /**
41
+ * ⚠ EIGHT, AND NO COMPOSITION RULES. scrypt makes a short passphrase expensive to attack, not
42
+ * safe: four characters is guessed whatever the cost factor. Requiring a digit and a capital
43
+ * would not change that and would push people to reuse the one they always type.
44
+ */
45
+ const MIN_PASSPHRASE = 8;
46
+ export async function login(options = {}) {
47
+ const say = options.write ?? ((line) => process.stdout.write(`${line}\n`));
48
+ const server = resolveServer(options.server);
49
+ // ⛔ Settled BEFORE the notice and before anything is written: if the network cannot be decided,
50
+ // nothing about this account should be stored at all.
51
+ const network = resolveNetwork(server, options.network);
52
+ const existing = readCredentialsFile();
53
+ if (options.plain === true && options.env === true) {
54
+ throw new NmtsError("--plain and --env ask for two different things.", {
55
+ exitCode: 2,
56
+ nextStep: "--plain writes the code to this machine; --env writes nothing and prints it.",
57
+ });
58
+ }
59
+ if (existing === null)
60
+ say(firstRunNotice());
61
+ // ⛔ ONE HOLD OVER EVERY QUESTION THIS COMMAND ASKS. Between two prompts the terminal is back in
62
+ // line mode, where it echoes what is typed and swallows lines the next prompt never sees —
63
+ // and this command asks up to three things with a check in the middle.
64
+ const exit = await holdTerminal(async () => {
65
+ const code = await readTheCode(options);
66
+ // ⛔ CHECKED BEFORE IT IS WRITTEN. The engine verifies the code's own check symbol offline, so
67
+ // a mistyped code fails here as "that is not a code" instead of being stored and coming
68
+ // back later as a sign-in failure indistinguishable from a wrong password or a suspended
69
+ // account.
70
+ await assertUsableCode(code);
71
+ if (options.env === true)
72
+ return printEnvForm(code, say);
73
+ // ⛔ THE KEY IS SETTLED BEFORE THE PASSPHRASE IS ASKED FOR, for the reason the code is checked
74
+ // before it: everything that can refuse this run should refuse it before somebody has typed
75
+ // a passphrase twice. It comes after the `--env` branch because that branch writes nothing
76
+ // at all — there is nowhere for a key to go, and asking for one would be a question whose
77
+ // answer this command would then throw away.
78
+ const key = await settleApiKey({
79
+ server,
80
+ stored: existing?.apiKey,
81
+ readKey: options.readApiKey,
82
+ confirmReplace: options.confirmKeyReplace,
83
+ });
84
+ const apiKey = keyToStore(key);
85
+ const exit = options.plain === true
86
+ ? storePlain(code, server, network, apiKey, say)
87
+ : await storeSealed(code, server, network, apiKey, options, say);
88
+ sayCheckSymbol(say, key.kind === "stored");
89
+ sayAboutTheKey(key, server, say);
90
+ return exit;
91
+ });
92
+ // ⛔ AFTER THE HOLD, AND ONLY WHEN THE SIGN-IN WORKED. Inside the hold the terminal is in raw
93
+ // mode and a line prompt never sees a line; before the end, this would be a question about a
94
+ // setup that then failed. Why it is asked at all is in `setup-questions.ts`.
95
+ if (exit === 0)
96
+ await askAboutCollisions(say);
97
+ return exit;
98
+ }
99
+ /**
100
+ * Where `login` gets the code to store.
101
+ *
102
+ * ⛔⭐ THE ENVIRONMENT PATH ASKS HERE TOO, AND AN ADVERSARIAL REVIEW IS WHY. This function used to
103
+ * read `NMTS_ACCOUNT_CODE` directly, so `login` was the one command that used a plain
104
+ * environment variable without the agreement every other command demands — and the code it
105
+ * took that way came back out as a sealed store, which needs no agreement at all. Two commands
106
+ * and the recorded decision was gone, while the documentation said it could not be.
107
+ *
108
+ * ⚠ ORDER: a file first, because that is the shape this tool recommends and it asks for nothing;
109
+ * then the environment, which asks once; then the terminal.
110
+ */
111
+ async function readTheCode(options) {
112
+ const fromFile = readSecretFile(CODE_FILE_ENV_VAR);
113
+ const fromEnv = process.env[CODE_ENV_VAR];
114
+ if (options.readCode === undefined && fromFile === null && fromEnv !== undefined && fromEnv.length > 0) {
115
+ requireConsent("plain-env");
116
+ }
117
+ const code = options.readCode !== undefined
118
+ ? await options.readCode()
119
+ : fromFile !== null
120
+ ? fromFile
121
+ : fromEnv !== undefined && fromEnv.length > 0
122
+ ? fromEnv
123
+ : await promptSecret(`Account code (not shown as you type): `, CODE_ENV_VAR);
124
+ if (code.length === 0) {
125
+ throw new NmtsError("No account code was given.", {
126
+ exitCode: 2,
127
+ nextStep: stdinIsATerminal()
128
+ ? `Run \`${BINARY_NAME} login\` again and paste the code.`
129
+ : `Set ${CODE_ENV_VAR} in the environment.`,
130
+ });
131
+ }
132
+ return code;
133
+ }
134
+ /** Seal it. The default, and the only shape that survives the file being copied off the machine. */
135
+ async function storeSealed(code, server, network, apiKey, options, say) {
136
+ const passphrase = await newPassphrase(options);
137
+ const lockedCode = lockCode(code, passphrase);
138
+ writeCredentials({ lockedCode, server, network, ...(apiKey ? { apiKey } : {}) });
139
+ say(`Sealed for ${server} (${network}) in ${credentialsPath()}`);
140
+ say(``);
141
+ say(` What is on disk is not the account code. Opening it needs the passphrase, so a copy of`);
142
+ say(` this file — in a backup, a synced folder, a container image, a stolen disk — is worth`);
143
+ say(` nothing on its own.`);
144
+ say(``);
145
+ say(` ⚠ It does NOT protect the code from anything running as you on this machine while you`);
146
+ say(` are using the tool. Whatever supplies the passphrase can be read the same way.`);
147
+ say(``);
148
+ say(` Every command that needs the code will ask for the passphrase, or read it from`);
149
+ say(` ${PASSPHRASE_ENV_VAR}. Opening it costs a fraction of a second and 64 MiB of memory each`);
150
+ say(` time, on purpose: that is what makes guessing the passphrase expensive.`);
151
+ return 0;
152
+ }
153
+ /**
154
+ * In the clear, behind the disclaimer.
155
+ *
156
+ * ⛔ THE AGREEMENT IS DEMANDED BEFORE THE CODE IS WRITTEN. ⚠ Not before anything at all happens:
157
+ * `codeStorageIsPrivate()` runs first and creates the config directory 0700 while measuring
158
+ * whether a file there can be kept private — that measurement is what the refusal's text has
159
+ * to be right about, so it cannot come after it.
160
+ */
161
+ function storePlain(code, server, network, apiKey, say) {
162
+ const private_ = codeStorageIsPrivate();
163
+ requireConsent("unsafe-code-storage");
164
+ writeCredentials({ accountCode: code, server, network, ...(apiKey ? { apiKey } : {}) });
165
+ say(`Stored in the clear for ${server} (${network}) in ${credentialsPath()}`);
166
+ if (modesAreEnforced()) {
167
+ say(` The file is readable only by you (mode 600). It is not encrypted: anything running as`);
168
+ say(` you can read it, which includes every agent you run on this machine.`);
169
+ }
170
+ else {
171
+ say(` Windows does not apply a file mode here, so the file inherits the folder's permissions.`);
172
+ }
173
+ if (!private_) {
174
+ say(``);
175
+ say(` ⛔ This machine did not keep the mode that was asked for, so anybody who can reach that`);
176
+ say(` path can read the code. \`${BINARY_NAME} login\` without --plain seals it instead,`);
177
+ say(` and ${CODE_ENV_VAR}_FILE names a file this tool never copies.`);
178
+ }
179
+ return 0;
180
+ }
181
+ /**
182
+ * Write nothing; print what to set.
183
+ *
184
+ * ⛔ THIS PRINTS THE ACCOUNT CODE, which every other part of this tool refuses to do. It is the
185
+ * one command whose entire purpose is to hand it back, it says so, and it is behind an
186
+ * agreement that names how an environment variable leaks.
187
+ */
188
+ function printEnvForm(code, say) {
189
+ requireConsent("plain-env");
190
+ // ⛔ SINGLE QUOTES AND NO INTERPOLATION. A shell cannot be persuaded to run part of a
191
+ // single-quoted string, and a code containing a single quote — the one character that would
192
+ // break it — cannot exist: the alphabet is Crockford base32, and `'` is refused by the parser
193
+ // that already accepted this code.
194
+ //
195
+ // ⛔ AND THE WHITESPACE COMES OUT FIRST. The parser treats EVERY whitespace character as an
196
+ // ignorable separator, so a perfectly valid code can arrive with newlines in it — and this
197
+ // printed it as eight lines while calling it one, the first of which was an unterminated
198
+ // string. What is printed has to be a line somebody can paste.
199
+ say(`export ${CODE_ENV_VAR}='${code.replace(/\s+/gu, "")}'`);
200
+ say(``);
201
+ say(` Nothing was written to this machine. Paste that where your program's environment is set.`);
202
+ say(``);
203
+ say(` ⚠ The code is now on this screen, and probably in this terminal's scrollback. An`);
204
+ say(` environment variable is readable by anything running as you, by every child process,`);
205
+ say(` and — inside a container — by anybody who can run \`docker inspect\`.`);
206
+ say(``);
207
+ say(` ${CODE_ENV_VAR}_FILE names a FILE holding the code instead, and has none of those.`);
208
+ return 0;
209
+ }
210
+ /**
211
+ * What is known about the code once it is stored — which depends on whether a key was checked.
212
+ *
213
+ * ⛔⭐ THE SECOND HALF WENT FALSE THE DAY THIS COMMAND STARTED CHECKING A KEY. "Whether the account
214
+ * exists has not been checked" was exactly right while nothing here spoke to the server; after a
215
+ * key is accepted, an account demonstrably exists. What is STILL unknown is a different thing
216
+ * and a sharper one — whether the code and the key belong to the SAME account — and it cannot
217
+ * be checked by anybody, because the code never goes to the server. Saying the old sentence
218
+ * after a key had just been accepted would have been the tool describing the world before it.
219
+ */
220
+ function sayCheckSymbol(say, keyWasAccepted) {
221
+ say(``);
222
+ say(` The code is well-formed — its own check symbol matches. That was verified here, offline.`);
223
+ if (!keyWasAccepted) {
224
+ say(` Whether the account EXISTS has not been checked: signing in goes through a human check`);
225
+ say(` this tool cannot pass yet, so that will first show up on a command that needs the server.`);
226
+ return;
227
+ }
228
+ say(` ⚠ Whether it is the code for the account the KEY belongs to has not been checked, and`);
229
+ say(` cannot be: the code never goes to the server, so nothing can compare the two. A code`);
230
+ say(` from a different account looks like an account with nothing in it.`);
231
+ }
232
+ /**
233
+ * What was decided about the API key, said after where the code went.
234
+ *
235
+ * ⛔ THE HANDLE AND NOTHING ELSE. The half printed here is the one the account screen lists; the
236
+ * secret half is not shown in a confirmation, in an error, or under --json anywhere else in
237
+ * this tool. Whoever ran this command already has the key, so there is nothing to gain by
238
+ * echoing it — and a confirmation lands in scrollback, in a screen share, and in whatever an
239
+ * agent writes to its log.
240
+ */
241
+ function sayAboutTheKey(outcome, server, say) {
242
+ switch (outcome.kind) {
243
+ case "unchanged":
244
+ // The key that was here is still here and was not touched. Nothing happened to report.
245
+ return;
246
+ case "none":
247
+ say(``);
248
+ say(`No API key is stored, and every command that talks to the server needs one.`);
249
+ say(``);
250
+ say(` Make one on the account screen at ${HOME_URL}, then run \`${BINARY_NAME} login\` again`);
251
+ say(` with it in ${API_KEY_ENV_VAR} — or paste it when this asks. It is checked with the`);
252
+ say(` server before it is written down.`);
253
+ say(``);
254
+ say(` ${API_KEY_FILE_ENV_VAR} names a FILE holding the key instead, which is the shape a`);
255
+ say(` container cannot leak. The key waives the human check a browser sign-in does, and`);
256
+ say(` nothing else — it opens no file.`);
257
+ return;
258
+ case "stored":
259
+ say(``);
260
+ say(`Key ${outcome.handle} stored for ${server}.`);
261
+ say(``);
262
+ say(` That is the key's public handle — the part the account screen lists. The secret half`);
263
+ say(` is not printed here.`);
264
+ say(``);
265
+ say(` ${server} accepted it just now, so it is a key this account has and nothing has`);
266
+ say(` revoked. It waives the human check a browser sign-in does, and nothing else — it`);
267
+ say(` opens no file, and what it is allowed to ask for was decided when it was made.`);
268
+ if (!outcome.verified) {
269
+ say(``);
270
+ say(` ⚠ Nothing has checked lately that a person is behind this account, so some requests`);
271
+ say(` are refused and the rest are held to tighter limits. \`${BINARY_NAME} verify\` prints`);
272
+ say(` a code for a person to type at a browser.`);
273
+ }
274
+ return;
275
+ case "kept":
276
+ say(``);
277
+ say(`⚠ A different API key is in ${keySourceName(outcome.from)}. It was NOT stored.`);
278
+ say(``);
279
+ say(` The key already on this machine is left as it is. Replacing one is something to say`);
280
+ say(` out loud, not something a run about the account code does on the way past — every`);
281
+ say(` agent using the old one would stop working at a moment nobody would connect to this.`);
282
+ say(``);
283
+ say(` While that variable is set, every command uses what it holds anyway: the environment`);
284
+ say(` wins over what is stored. To store it instead — at a terminal, run this again and`);
285
+ say(` answer the question it asks; with no terminal, \`${BINARY_NAME} logout\` first and`);
286
+ say(` then \`${BINARY_NAME} login\`.`);
287
+ return;
288
+ }
289
+ }
290
+ /**
291
+ * A passphrase for a NEW seal: from the environment, or typed twice.
292
+ *
293
+ * ⚠ The environment form is not confirmed, because there is nothing to confirm it against and
294
+ * asking would hang. A typo there produces a file whose passphrase nobody knows — which is why
295
+ * the message below says to keep it, not merely to choose it.
296
+ */
297
+ async function newPassphrase(options) {
298
+ const ask = options.readPassphrase;
299
+ const fromEnv = process.env[PASSPHRASE_ENV_VAR];
300
+ if (ask === undefined && fromEnv !== undefined && fromEnv.length > 0) {
301
+ if (fromEnv.length < MIN_PASSPHRASE)
302
+ throw tooShort();
303
+ return fromEnv;
304
+ }
305
+ if (ask === undefined && !stdinIsATerminal()) {
306
+ throw new NmtsError("Sealing the account code needs a passphrase, and there is no terminal.", {
307
+ exitCode: 2,
308
+ nextStep: [
309
+ `One of these:`,
310
+ ` · set ${PASSPHRASE_ENV_VAR} and run this again`,
311
+ ` · ${BINARY_NAME} login --plain store it unsealed (asks for an agreement first)`,
312
+ ` · ${BINARY_NAME} login --env store nothing; print the variable to set`,
313
+ ].join("\n"),
314
+ });
315
+ }
316
+ const prompt = ask ?? ((q) => promptSecret(q, PASSPHRASE_ENV_VAR));
317
+ const first = await prompt(`New passphrase for the stored code (not shown as you type): `);
318
+ if (first.length < MIN_PASSPHRASE)
319
+ throw tooShort();
320
+ const again = await prompt(`Type it again: `);
321
+ if (!samePassphrase(first, again)) {
322
+ throw new NmtsError("Those two passphrases are not the same.", {
323
+ exitCode: 2,
324
+ nextStep: `Nothing was written. Run \`${BINARY_NAME} login\` again.`,
325
+ });
326
+ }
327
+ return first;
328
+ }
329
+ function tooShort() {
330
+ return new NmtsError(`That passphrase is shorter than ${MIN_PASSPHRASE} characters.`, {
331
+ exitCode: 2,
332
+ nextStep: `Nothing was written. A short passphrase is guessed whatever the tool does to slow guessing ` +
333
+ `down. ⛔ If it is lost, the sealed copy cannot be opened by anybody — keep it somewhere.`,
334
+ });
335
+ }
@@ -0,0 +1,33 @@
1
+ // `nmts logout` — remove the stored account code from this machine.
2
+ //
3
+ // ⚠ WHAT IT DOES NOT DO. It removes a file. It does not end any session on the server, and it
4
+ // cannot reach a copy of the code that has already been read by something else on this machine.
5
+ // Saying "logged out" without saying that would suggest a revocation that did not happen.
6
+ import { rmSync } from "node:fs";
7
+ import { credentialsPath } from "../credentials.js";
8
+ export function logout(write) {
9
+ const say = write ?? ((line) => process.stdout.write(`${line}\n`));
10
+ const path = credentialsPath();
11
+ let removed = true;
12
+ try {
13
+ rmSync(path);
14
+ }
15
+ catch (error) {
16
+ if (isNotFound(error))
17
+ removed = false;
18
+ else
19
+ throw error;
20
+ }
21
+ say(removed ? `Removed ${path}` : `Nothing to remove — ${path} does not exist.`);
22
+ if (removed) {
23
+ say(` This deleted a file. It did not end anything on the server, and it cannot reach a copy`);
24
+ say(` that something on this machine already read.`);
25
+ }
26
+ return 0;
27
+ }
28
+ function isNotFound(error) {
29
+ return (typeof error === "object" &&
30
+ error !== null &&
31
+ "code" in error &&
32
+ error.code === "ENOENT");
33
+ }
@@ -0,0 +1,202 @@
1
+ // `nmts ls` — what is in the account, read from the sealed file list.
2
+ //
3
+ // ⚠ IT ANSWERS FROM THE LIST, NOT FROM THE FILES. Names, sizes and folders live in one sealed
4
+ // blob that only this account's key opens; the server holds it and cannot read it. So this
5
+ // command proves what the list says, and proves nothing about whether the bytes behind each
6
+ // entry are still fetchable — that is what `get` will be for.
7
+ //
8
+ // ⛔ TRASHED ENTRIES ARE HIDDEN BY DEFAULT AND SAID TO BE HIDDEN. A count that silently omits
9
+ // things is how somebody concludes a file is gone. `--all` shows them, marked.
10
+ //
11
+ // ⛔ AND THAT RULE GOVERNS THE TWO NEW WAYS OF SHOWING LESS. `--find` prints a subset and says how
12
+ // many files it left out and that folders holding no match are gone from the table; `--sort`
13
+ // changes the order and never the membership. A narrowed listing that looks exactly like a full
14
+ // one is the same defect as a silent trash filter, arrived at from a different direction.
15
+ //
16
+ // ⛔ A WRONG OPTION IS REFUSED BEFORE THE ACCOUNT IS TOUCHED. `--sort largest` is a wrong command
17
+ // line, not a failing account, so it costs no round trip and reads as exit 2 — otherwise an
18
+ // agent that mistyped a key goes off investigating the account instead of its own arguments.
19
+ import { CODE_ENV_VAR } from "../credentials.js";
20
+ import { buildIndex, fullPathOf, isLive, KIND_FOLDER, trashedAt } from "../drive-paths.js";
21
+ import { NmtsError } from "../errors.js";
22
+ import { marksOf, markSuffix } from "../mark-render.js";
23
+ import { idsForQuery, needleOf } from "../list-view-find.js";
24
+ import { orderRows, parseSortKey } from "../list-view-order.js";
25
+ import { readFileList } from "../manifest.js";
26
+ import { openSession } from "../session.js";
27
+ import { daysLeftInTrash, TRASH_RETENTION_DAYS } from "../trash-sweep.js";
28
+ import { humanSize } from "../units.js";
29
+ /**
30
+ * How much of a trashed entry's thirty days is left, in the words beside its row.
31
+ *
32
+ * ⛔ A TRASHED ROW HAS TO SAY WHEN. "[trash]" on its own tells somebody the file is recoverable and
33
+ * not for how long, so the row that is about to lose its key looks exactly like the one thrown
34
+ * away this morning — and the entry, once dropped, takes with it this account's copy of the key
35
+ * that opens the file.
36
+ */
37
+ function trashWindow(trashedAtMs, nowMs) {
38
+ const left = daysLeftInTrash(trashedAtMs, nowMs);
39
+ if (left <= 0)
40
+ return `past ${TRASH_RETENTION_DAYS} days — \`nmts sweep\` drops it`;
41
+ return `${left} day${left === 1 ? "" : "s"} left`;
42
+ }
43
+ /**
44
+ * The order this command has printed since it existed: whole paths, ascending.
45
+ *
46
+ * ⛔ IT IS STILL THE DEFAULT, and `--sort` is what asks for the browser's order instead. A path
47
+ * order is the only one that draws the tree — every file sits under the folder that holds it —
48
+ * and that is what somebody reading a flat listing of a whole drive is reading it for. Changing
49
+ * the default would also silently rewrite what every script that already parses this output
50
+ * sees.
51
+ *
52
+ * ⚠ `--desc` reverses it exactly, the same way it reverses the other three keys.
53
+ */
54
+ function byPath(rows, dir) {
55
+ const sorted = [...rows].sort((a, b) => a.path.localeCompare(b.path));
56
+ return dir === "desc" ? sorted.reverse() : sorted;
57
+ }
58
+ export async function ls(options = {}) {
59
+ const say = options.write ?? ((line) => process.stdout.write(`${line}\n`));
60
+ const now = options.now ?? Date.now();
61
+ const dir = options.desc === true ? "desc" : "asc";
62
+ const sort = options.sort === undefined ? null : parseSortKey(options.sort);
63
+ // ⛔ AN EMPTY QUERY IS REFUSED RATHER THAN IGNORED. `--find "$THING"` with nothing in `THING` is
64
+ // a script that lost its variable, and both quiet answers are wrong: listing everything hands
65
+ // back a drive that was never asked for, and listing nothing reports an empty account.
66
+ const needle = options.find === undefined ? null : needleOf(options.find);
67
+ if (needle === "") {
68
+ throw new NmtsError(`--find was given nothing to look for.`, {
69
+ exitCode: 2,
70
+ nextStep: `Nothing was listed. Give it part of a file's name, as in \`--find report\`.`,
71
+ });
72
+ }
73
+ // ⛔ THE REFUSALS ARE ONE TEXT, so they are resolved in one place. This command used to carry its
74
+ // own copy of the "no API key" wording — the sentence a new user is most likely to see — and
75
+ // it had already drifted from the other three (2026-08-23).
76
+ const session = await openSession({ server: options.server, network: options.network });
77
+ const list = await readFileList(session.server, session.apiKey, session.code, session.accountId);
78
+ if (list.manifest === null) {
79
+ if (options.json) {
80
+ say(JSON.stringify({ state: "absent", entries: [] }));
81
+ return 0;
82
+ }
83
+ say(`This account has no file list yet. Nothing has been uploaded from any device.`);
84
+ return 0;
85
+ }
86
+ // ⛔ "IN THE TRASH" IS INHERITED. Trashing a folder marks the folder and nothing under it, so a
87
+ // filter on `e.deletedAt` alone leaves every file inside it listed as live — while the server,
88
+ // which was told to drop those rows, refuses their bytes. That is the one state this tool is
89
+ // built to avoid, and it shipped: `hiddenTrashed` said 1 while two unreachable files were
90
+ // printed as live (2026-08-23).
91
+ const index = buildIndex(list.manifest.entries);
92
+ const listed = options.all
93
+ ? list.manifest.entries
94
+ : list.manifest.entries.filter((e) => isLive(index, e));
95
+ const hidden = list.manifest.entries.length - listed.length;
96
+ // The query runs over what was going to be shown, so `--find` and `--all` compose instead of one
97
+ // quietly widening the other: a search without `--all` searches the drive, not the trash.
98
+ const keep = needle === null ? null : idsForQuery(index, listed, needle);
99
+ const shown = keep === null ? listed : listed.filter((e) => keep.has(e.id));
100
+ const filteredOutFiles = keep === null ? 0 : listed.filter((e) => e.kind !== KIND_FOLDER && !keep.has(e.id)).length;
101
+ const mapped = shown.map((e) => ({
102
+ id: e.id,
103
+ path: fullPathOf(index, e),
104
+ name: e.name,
105
+ kind: e.kind,
106
+ size: e.size,
107
+ createdAt: e.createdAt,
108
+ updatedAt: e.updatedAt,
109
+ trashed: !isLive(index, e),
110
+ // ⚠ When the thirty days run out. Present only for the trash, and only where the tool can
111
+ // say it: an entry trashed by inheritance carries its ancestor's instant, not its own.
112
+ trashedAt: trashedAt(index, e),
113
+ marks: marksOf(e),
114
+ ...(e.pinned === true ? { pinned: true } : {}),
115
+ }));
116
+ const rows = sort === null ? byPath(mapped, dir) : orderRows(mapped, sort, dir);
117
+ if (options.json) {
118
+ say(JSON.stringify({
119
+ state: "present",
120
+ seq: list.seq,
121
+ serverSeqDisagreed: list.serverSeqDisagreed ?? null,
122
+ firstTimeOnThisMachine: list.firstTimeOnThisMachine,
123
+ hiddenTrashed: options.all ? 0 : hidden,
124
+ // What `--find` was given and how many files it dropped, so a caller can tell a narrowed
125
+ // listing from a whole one instead of comparing counts against a drive it cannot see.
126
+ query: options.find ?? null,
127
+ hiddenByQuery: filteredOutFiles,
128
+ entries: rows.map((row) => ({
129
+ id: row.id,
130
+ path: row.path,
131
+ name: row.name,
132
+ kind: row.kind === KIND_FOLDER ? "folder" : "file",
133
+ size: row.size,
134
+ createdAt: row.createdAt,
135
+ updatedAt: row.updatedAt,
136
+ trashed: row.trashed,
137
+ trashedAt: row.trashedAt,
138
+ marks: row.marks,
139
+ })),
140
+ }));
141
+ return 0;
142
+ }
143
+ if (rows.length === 0) {
144
+ if (needle === null) {
145
+ say(`The file list is empty${hidden > 0 ? ` (${hidden} in the trash, hidden)` : ""}.`);
146
+ }
147
+ else {
148
+ say(`No file in this account has "${options.find}" in its name` +
149
+ (hidden > 0 ? ` (${hidden} in the trash, not searched — \`--all\` searches those too)` : ``) +
150
+ `.`);
151
+ }
152
+ }
153
+ else {
154
+ const width = Math.max(...rows.map((r) => r.path.length));
155
+ for (const row of rows) {
156
+ const size = row.kind === KIND_FOLDER ? "" : humanSize(row.size);
157
+ const mark = row.trashed
158
+ ? ` [trash${row.trashedAt === null ? "" : `, ${trashWindow(row.trashedAt, now)}`}]`
159
+ : "";
160
+ say(`${row.path.padEnd(width)} ${size.padStart(9)}${mark}${markSuffix(row.marks)}`);
161
+ }
162
+ say(``);
163
+ const files = rows.filter((r) => r.kind !== KIND_FOLDER).length;
164
+ const total = rows.filter((r) => r.kind !== KIND_FOLDER).reduce((n, r) => n + r.size, 0);
165
+ say(`${files} file${files === 1 ? "" : "s"} · ${humanSize(total)}` +
166
+ (needle === null ? `` : ` matching "${options.find}"`) +
167
+ (hidden > 0
168
+ ? needle === null
169
+ ? ` · ${hidden} in the trash, hidden (--all shows them)`
170
+ : ` · ${hidden} in the trash, not searched (--all searches those too)`
171
+ : ``));
172
+ // ⛔ THE TABLE SAYS WHAT IT LEFT OUT. Under a query the rows above are not the drive, and the
173
+ // two ways that misleads are both quiet ones: files that did not match are simply absent,
174
+ // and so is every folder holding none of them — including a folder whose own name contains
175
+ // the text, because the query is matched against file names only.
176
+ if (needle !== null) {
177
+ say(``);
178
+ if (filteredOutFiles > 0) {
179
+ // Counted against what this same listing would have shown WITHOUT the query, so the
180
+ // sentence stays true under `--all` as well: it never claims to have counted the trash.
181
+ say(` Without --find this listing would also have shown ${filteredOutFiles} other ` +
182
+ `file${filteredOutFiles === 1 ? "" : "s"}, which did not match.`);
183
+ }
184
+ say(` Only file names are matched, so a folder is never a match of its own — the folders`);
185
+ say(` above are the ones that hold a match, and folders holding none are not listed.`);
186
+ }
187
+ }
188
+ // ⛔ Said out loud rather than left as a footnote: on a first run there was nothing on this
189
+ // machine to compare the version against, so a rolled-back list would have looked normal.
190
+ if (list.firstTimeOnThisMachine) {
191
+ say(``);
192
+ say(` First listing on this machine, so nothing here could have caught an older list being`);
193
+ say(` served in place of the current one. From now on this tool refuses a version lower than`);
194
+ say(` the highest it has seen for this account.`);
195
+ }
196
+ if (list.serverSeqDisagreed !== undefined) {
197
+ say(``);
198
+ say(` The server said version ${list.serverSeqDisagreed}; the sealed list says ${list.seq}.`);
199
+ say(` The sealed number is the one that is authenticated, so it is the one used.`);
200
+ }
201
+ return 0;
202
+ }