@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,233 @@
1
+ // Locking the account code with a passphrase, so what is on disk is not the code.
2
+ //
3
+ // WHY THIS EXISTS. `nmts login` used to write the account code in the clear at mode 600, which is
4
+ // what `gh`, `aws` and `docker login` do. Mode 600 answers exactly one question — "can another
5
+ // user on this machine read it" — and answers nothing about a backup, a copied home directory,
6
+ // a synced folder, a container image layer, or a disk pulled out of a laptop. A passphrase
7
+ // answers those, and only those.
8
+ //
9
+ // ⛔ WHAT IT DOES NOT DO, SAID FIRST. It does not protect the code from anything running AS YOU
10
+ // while you are using the tool: whatever supplies the passphrase can be read the same way the
11
+ // passphrase is. On a machine where an unattended agent runs, the passphrase has to come from
12
+ // somewhere the agent can reach, and at that point this is a lock whose key is taped beside it.
13
+ // That is not a reason to leave it out — it is the reason the tool ALSO offers a secret file,
14
+ // which is the right answer for an unattended agent — but it is the reason nothing here claims
15
+ // the code is "safe".
16
+ //
17
+ // ⛔ NODE BUILT-INS ONLY. `scrypt` and AES-256-GCM are in `node:crypto`; a password-hashing
18
+ // dependency would put somebody else's code on the path the account code travels, for a
19
+ // function the platform already ships. This is local storage, not the NMTS crypto format —
20
+ // NCF-3 governs what leaves this machine, and nothing here does.
21
+ import { createCipheriv, createDecipheriv, randomBytes, scryptSync, timingSafeEqual, } from "node:crypto";
22
+ import { NmtsError } from "./errors.js";
23
+ /**
24
+ * scrypt's cost: N=2^16, r=8, p=2 — one of the sets OWASP's Password Storage Cheat Sheet gives as
25
+ * equivalent to its headline N=2^17, r=8, p=1.
26
+ *
27
+ * ⛔ THE EQUIVALENT SET WAS CHOSEN FOR ITS MEMORY, NOT ITS SPEED. Work is proportional to N·r·p,
28
+ * so these two cost the same processor time; peak memory is proportional to N·r, so this one
29
+ * needs 64 MiB where the other needs 128 MiB. This tool is meant to run inside containers, and
30
+ * a container with a 128 MiB limit would have the whole process killed — which arrives as a
31
+ * dead command with no message rather than as a wrong passphrase.
32
+ *
33
+ * ⚠ Measured on the machine this was written on: about a third of a second per unlock. It is paid
34
+ * once per command that needs the code, and the tool says so during `login` rather than letting
35
+ * somebody discover it on their fiftieth upload. Both halves of the cost are the point — memory
36
+ * is what stops a GPU being better at this than a laptop.
37
+ */
38
+ const N = 1 << 16;
39
+ const R = 8;
40
+ const P = 2;
41
+ /**
42
+ * ⛔ SCRYPT'S OWN MEMORY CEILING HAS TO BE RAISED BY HAND. Node's default `maxmem` is 32 MiB and
43
+ * these parameters need `128 × N × r` = 64 MiB, so the call throws "Invalid scrypt param"
44
+ * without this — a failure that looks like a bug in the passphrase rather than a limit.
45
+ */
46
+ const MAXMEM = 96 * 1024 * 1024;
47
+ /**
48
+ * ⛔ A STORED FILE MAY NOT ASK FOR MORE THAN THIS VERSION ITSELF WRITES.
49
+ *
50
+ * The parameters travel in the file so an old file still opens after the cost is raised — but a
51
+ * file is something an attacker can edit, and every one of the three is a lever on how long this
52
+ * process spends before the tag is checked and the guess is rejected. An adversarial review
53
+ * measured it: `n` alone was bounded, and an edited `p` of 81,918 — the largest OpenSSL would
54
+ * accept — made every command grind for **39 minutes** before answering "wrong passphrase".
55
+ *
56
+ * So the ceiling is what THIS version would produce, on both axes that matter:
57
+ * · memory ∝ n·r — capped at exactly ours, so a file cannot make a small container
58
+ * OOM-kill the process, which arrives as a command that died saying
59
+ * nothing rather than as a refusal;
60
+ * · work ∝ n·r·p — capped at exactly ours, so the worst a hostile file can buy is the
61
+ * same fraction of a second an honest one costs.
62
+ *
63
+ * ⚠ THE PRICE IS FORWARD COMPATIBILITY, AND IT IS PAID ON PURPOSE. A future version that raises
64
+ * the cost writes files this one refuses. That is what the `v` field is for: raising the cost
65
+ * is a code change AND a version bump, not a number somebody edits in a file.
66
+ *
67
+ * The floor matters for the opposite reason: a file rewritten with n=2 would unlock in
68
+ * microseconds if the passphrase were guessed, so we would have done the guessing cheaply on the
69
+ * attacker's behalf.
70
+ */
71
+ const MIN_N = 1 << 14;
72
+ const MAX_MEMORY_UNITS = N * R;
73
+ const MAX_WORK_UNITS = N * R * P;
74
+ const SALT_BYTES = 16;
75
+ const NONCE_BYTES = 12;
76
+ const KEY_BYTES = 32;
77
+ const TAG_BYTES = 16;
78
+ /** Thrown when the passphrase does not open the file. ⛔ Never says how close it was. */
79
+ export class WrongPassphraseError extends NmtsError {
80
+ constructor() {
81
+ super("That passphrase does not open the stored account code.", {
82
+ exitCode: 3,
83
+ nextStep: "Try again. If the passphrase is lost, the stored copy cannot be recovered — sign in " +
84
+ "again with the account code itself.",
85
+ });
86
+ this.name = "WrongPassphraseError";
87
+ }
88
+ }
89
+ /**
90
+ * Bind the parameters to the ciphertext.
91
+ *
92
+ * Editing `n` in the file then changes what the tag is checked against, so a tampered parameter
93
+ * fails as a wrong passphrase rather than as a successful decryption of something else. It costs
94
+ * one string and removes a whole class of question about what an edited file can do.
95
+ */
96
+ function aad(locked) {
97
+ return Buffer.from(`nmts-cli/code-vault/${locked.v}/${locked.kdf}/${locked.n}/${locked.r}/${locked.p}`, "utf8");
98
+ }
99
+ /**
100
+ * ⛔ CHECKED BEFORE ONE BYTE OF WORK IS DONE. Every branch here is reached in microseconds; the
101
+ * tag that would reject a tampered file is checked at the END of the derivation, so a bound
102
+ * applied afterwards is not a bound at all.
103
+ */
104
+ function refuseCost(locked) {
105
+ const { n, r, p } = locked;
106
+ const whole = (v) => Number.isInteger(v) && v >= 1;
107
+ const refuse = () => {
108
+ throw new NmtsError("The stored account code names a key-derivation cost this version refuses.", {
109
+ exitCode: 1,
110
+ nextStep: "The file has been edited, or was written by a newer version of this tool. Sign in again " +
111
+ "with the account code itself.",
112
+ });
113
+ };
114
+ if (!whole(n) || !whole(r) || !whole(p))
115
+ refuse();
116
+ // scrypt's own requirement, and the reason a non-power-of-two is refused rather than rounded.
117
+ if ((n & (n - 1)) !== 0 || n < MIN_N)
118
+ refuse();
119
+ // ⛔ SCRYPT'S OWN CONSTRAINT, CHECKED HERE SO THE REFUSAL IS OURS. RFC 7914 requires
120
+ // `N < 2^(128·r/8)`, so a file naming r=1 beside our n=2^16 is not merely expensive — it is
121
+ // invalid, and OpenSSL's own message for it ("Invalid scrypt param") says nothing a person
122
+ // could act on. Found by a test that expected r=1 to be a CHEAPER file and got a different
123
+ // error than the one it was written to see.
124
+ if (n >= 2 ** (16 * r))
125
+ refuse();
126
+ if (n * r > MAX_MEMORY_UNITS)
127
+ refuse();
128
+ // ⛔ Multiplied in this order and compared against a constant this process can hold: the three
129
+ // are already bounded above by the memory check and by each other, so no product here can
130
+ // leave the safe-integer range before it is compared.
131
+ if (n * r * p > MAX_WORK_UNITS)
132
+ refuse();
133
+ }
134
+ function deriveKey(passphrase, salt, n, r, p) {
135
+ try {
136
+ return scryptSync(Buffer.from(passphrase, "utf8"), salt, KEY_BYTES, { N: n, r, p, maxmem: MAXMEM });
137
+ }
138
+ catch (error) {
139
+ throw new NmtsError("The stored account code asks for a key derivation this machine refused.", {
140
+ exitCode: 1,
141
+ nextStep: `Cause: ${error instanceof Error ? error.message : String(error)}`,
142
+ });
143
+ }
144
+ }
145
+ /** Seal the code under a passphrase. The result is safe to write to a file. */
146
+ export function lockCode(code, passphrase) {
147
+ if (passphrase.length === 0)
148
+ throw new NmtsError("An empty passphrase locks nothing.", { exitCode: 2 });
149
+ const salt = randomBytes(SALT_BYTES);
150
+ const nonce = randomBytes(NONCE_BYTES);
151
+ const key = deriveKey(passphrase, salt, N, R, P);
152
+ const head = { v: 1, kdf: "scrypt", n: N, r: R, p: P };
153
+ try {
154
+ const cipher = createCipheriv("aes-256-gcm", key, nonce);
155
+ cipher.setAAD(aad(head));
156
+ const body = Buffer.concat([cipher.update(Buffer.from(code, "utf8")), cipher.final()]);
157
+ return {
158
+ ...head,
159
+ salt: salt.toString("base64"),
160
+ nonce: nonce.toString("base64"),
161
+ ct: Buffer.concat([body, cipher.getAuthTag()]).toString("base64"),
162
+ };
163
+ }
164
+ finally {
165
+ key.fill(0);
166
+ }
167
+ }
168
+ /**
169
+ * Open a sealed code.
170
+ *
171
+ * ⛔ The tag is checked before a single byte is returned — that is what `final()` does for GCM, and
172
+ * it is why a wrong passphrase cannot yield a plausible-looking wrong code.
173
+ */
174
+ export function unlockCode(locked, passphrase) {
175
+ refuseCost(locked);
176
+ const n = locked.n;
177
+ const salt = Buffer.from(locked.salt, "base64");
178
+ const nonce = Buffer.from(locked.nonce, "base64");
179
+ const whole = Buffer.from(locked.ct, "base64");
180
+ if (salt.length !== SALT_BYTES || nonce.length !== NONCE_BYTES || whole.length <= TAG_BYTES) {
181
+ throw new WrongPassphraseError();
182
+ }
183
+ const body = whole.subarray(0, whole.length - TAG_BYTES);
184
+ const tag = whole.subarray(whole.length - TAG_BYTES);
185
+ const key = deriveKey(passphrase, salt, n, locked.r, locked.p);
186
+ try {
187
+ const decipher = createDecipheriv("aes-256-gcm", key, nonce);
188
+ decipher.setAAD(aad(locked));
189
+ decipher.setAuthTag(tag);
190
+ const plain = Buffer.concat([decipher.update(body), decipher.final()]);
191
+ try {
192
+ return plain.toString("utf8");
193
+ }
194
+ finally {
195
+ plain.fill(0);
196
+ }
197
+ }
198
+ catch (error) {
199
+ if (error instanceof NmtsError)
200
+ throw error;
201
+ throw new WrongPassphraseError();
202
+ }
203
+ finally {
204
+ key.fill(0);
205
+ }
206
+ }
207
+ /** Shape check for something read off disk. ⛔ A parser, not an assertion: the file is input. */
208
+ export function isLockedCode(value) {
209
+ if (typeof value !== "object" || value === null)
210
+ return false;
211
+ const v = value;
212
+ return (v["v"] === 1 &&
213
+ v["kdf"] === "scrypt" &&
214
+ typeof v["n"] === "number" &&
215
+ typeof v["r"] === "number" &&
216
+ typeof v["p"] === "number" &&
217
+ typeof v["salt"] === "string" &&
218
+ typeof v["nonce"] === "string" &&
219
+ typeof v["ct"] === "string");
220
+ }
221
+ /**
222
+ * Are these two passphrases the same? Used only to catch a typo when one is being set.
223
+ *
224
+ * ⚠ Constant-time because it costs nothing to be. Neither value is secret to this process, but a
225
+ * comparison that short-circuits is a habit worth not having near a passphrase.
226
+ */
227
+ export function samePassphrase(a, b) {
228
+ const x = Buffer.from(a, "utf8");
229
+ const y = Buffer.from(b, "utf8");
230
+ if (x.length !== y.length)
231
+ return false;
232
+ return timingSafeEqual(x, y);
233
+ }
@@ -0,0 +1,133 @@
1
+ // What this machine does when an upload's name is already in use.
2
+ //
3
+ // ⛔ ASKED ONCE, AT SETUP, BECAUSE THERE IS NOBODY TO ASK LATER. This tool is run by backup jobs
4
+ // and by agents; a prompt in the middle of one is a prompt nobody sees, and a job that stops to
5
+ // wait for an answer has stopped. So the question is put where a person is definitely present
6
+ // -- signing in -- and the answer is kept.
7
+ //
8
+ // ⛔ THE DEFAULT IS TO RENAME, AND IT IS THE DEFAULT ON PURPOSE. Renaming loses nothing: the old
9
+ // file stays and the new one arrives beside it. Overwriting takes a file away from where the
10
+ // person put it, so the direction chosen when nobody has chosen is the one that moves nothing.
11
+ //
12
+ // ⚠ AND OVERWRITING MEANS SOMETHING WEAKER HERE THAN IN THE BROWSER. There it is final: NMTS keeps
13
+ // no previous versions, and the browser can destroy the stored row. This tool cannot -- the
14
+ // endpoint that does is closed to an API key on purpose -- so what it can do is put the old file
15
+ // in the trash, which `nmts restore` undoes for thirty days. ⛔ Every line this tool prints about
16
+ // overwriting has to say that, and none of them may say "gone" or "cannot be brought back".
17
+ //
18
+ // ⛔ AN AGENT MAY NOT PICK OVERWRITE UNLESS A MODE SAYS IT MAY (owner, 2026-08-25: unless YOLO or
19
+ // auto mode is on, the agent picks rename; with one on it decides for itself). That rule is
20
+ // enforced here rather than written down in the instructions and hoped for: `chosenBy` refuses
21
+ // to return `overwrite` for an agent while autonomy is off.
22
+ //
23
+ // ⚠ WHAT THIS CANNOT DO is tell an agent from a person. Nothing on a command line can. What it can
24
+ // do is make the destructive answer require a setting that was turned on deliberately, and say
25
+ // which setting decided.
26
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync, chmodSync } from "node:fs";
27
+ import { join } from "node:path";
28
+ import { currentMode } from "./autonomy.js";
29
+ import { NmtsError } from "./errors.js";
30
+ import { BINARY_NAME } from "./product.js";
31
+ import { configDir, modesAreEnforced } from "./credentials.js";
32
+ export const COLLISION_CHOICES = ["rename", "overwrite"];
33
+ /** What each choice does, in the words the tool prints. One line each. */
34
+ export const COLLISION_MEANS = {
35
+ rename: "Store it beside the old one as `name (2).ext`. Nothing is lost. This is the default.",
36
+ overwrite: "Store this one and put the file that is there in the trash, restorable for 30 days.",
37
+ };
38
+ /**
39
+ * What a run asked for on the command line, or undefined for "use this machine's setting".
40
+ *
41
+ * ⛔ AN UNKNOWN WORD IS REFUSED, NOT ROUNDED DOWN. Silently reading `--on-collision overwite` as
42
+ * the safe answer would look like it worked, and the person would find out from the drive.
43
+ */
44
+ export function parseAsked(typed) {
45
+ if (typed === undefined)
46
+ return undefined;
47
+ const word = typed.trim().toLowerCase();
48
+ if (isChoice(word))
49
+ return word;
50
+ throw new NmtsError(`\`--on-collision\` takes ${COLLISION_CHOICES.join(" or ")}, not "${typed}".`, {
51
+ nextStep: `Leave it out to use what this machine is set to: ${BINARY_NAME} on-collision`,
52
+ });
53
+ }
54
+ /** What is written down when nobody has chosen. */
55
+ export const DEFAULT_COLLISION = "rename";
56
+ function path() {
57
+ return join(configDir(), "collision.json");
58
+ }
59
+ function isChoice(value) {
60
+ return typeof value === "string" && COLLISION_CHOICES.includes(value);
61
+ }
62
+ /**
63
+ * What this machine is set to.
64
+ *
65
+ * ⛔ Unreadable counts as `rename`, for the same reason autonomy unreadable counts as off: the
66
+ * fail-safe direction for "I do not know" is the one that destroys nothing.
67
+ */
68
+ export function currentChoice() {
69
+ try {
70
+ const parsed = JSON.parse(readFileSync(path(), "utf8"));
71
+ if (typeof parsed !== "object" || parsed === null)
72
+ return DEFAULT_COLLISION;
73
+ const choice = Reflect.get(parsed, "onCollision");
74
+ return isChoice(choice) ? choice : DEFAULT_COLLISION;
75
+ }
76
+ catch {
77
+ return DEFAULT_COLLISION;
78
+ }
79
+ }
80
+ /** Has anybody answered on this machine? Used to know whether setup still has to ask. */
81
+ export function hasChosen() {
82
+ return existsSync(path());
83
+ }
84
+ /** Write the choice down, with the date and the version that asked. */
85
+ export function setChoice(choice, version, now) {
86
+ mkdirSync(configDir(), { recursive: true, mode: 0o700 });
87
+ const body = { onCollision: choice, setAt: now.toISOString(), byVersion: version };
88
+ writeFileSync(path(), `${JSON.stringify(body, null, 2)}\n`, { mode: 0o600 });
89
+ if (modesAreEnforced())
90
+ chmodSync(path(), 0o600);
91
+ }
92
+ /** Forget the answer, so setup asks again. */
93
+ export function forgetChoice() {
94
+ if (existsSync(path()))
95
+ rmSync(path(), { force: true });
96
+ }
97
+ /**
98
+ * What to do with this collision, and what settled it.
99
+ *
100
+ * ⛔ A STORED ANSWER IS A PERSON'S ANSWER AND IS NOT SECOND-GUESSED. Setup asks while somebody is
101
+ * definitely there; overriding that later because autonomy happens to be off would mean the
102
+ * tool ignoring the one answer it actually has from a person.
103
+ *
104
+ * ⛔ WHAT THE MODES GATE IS THE OTHER THING: an agent deciding, for THIS run, to overwrite when
105
+ * nobody said so. That is the case the owner ruled on -- without a mode the agent picks rename
106
+ * -- and it is enforced here rather than written in the instructions and hoped for.
107
+ *
108
+ * ⛔ THE OVERRIDE IS ONE-WAY. A mode can let `overwrite` through; nothing here turns a `rename`
109
+ * into an `overwrite`.
110
+ */
111
+ export function decide(
112
+ /** What this run asked for, if anything. `undefined` means "use what this machine is set to". */
113
+ askedFor, setting = currentChoice(), mode = currentMode()) {
114
+ if (askedFor === undefined)
115
+ return { choice: setting, by: "setting" };
116
+ if (askedFor === "rename")
117
+ return { choice: "rename", by: "asked-for" };
118
+ if (mode === "off")
119
+ return { choice: "rename", by: "agent-refused" };
120
+ return { choice: "overwrite", by: "asked-for" };
121
+ }
122
+ /** How the two answers are numbered where setup asks. Kept here so the question and the reading agree. */
123
+ export const ANSWER_NUMBER = { rename: "1", overwrite: "2" };
124
+ /**
125
+ * What somebody typed at the setup question.
126
+ *
127
+ * ⛔ ONLY THE EXACT NUMBER FOR OVERWRITE COUNTS, and everything else is the safe answer. A typo, an
128
+ * empty line, a closed pipe, a stray space, `y`, `yes` — none of them mean "delete my files".
129
+ * The answer that destroys something has to be typed on purpose.
130
+ */
131
+ export function readAnswer(typed) {
132
+ return typed.trim() === ANSWER_NUMBER.overwrite ? "overwrite" : "rename";
133
+ }
@@ -0,0 +1,93 @@
1
+ // `nmts balance` — what this account can still pay for.
2
+ //
3
+ // ⛔ WHY IT EXISTS AT ALL. Until 2026-08-24 this tool could quote the price of an upload and then
4
+ // spend, and had no way to say "you have one credit left". The only route that reported a
5
+ // balance also carried the verb that erases the account, so a key could not reach the number
6
+ // without being able to reach that — and it correctly could not. The answer was a route that
7
+ // reads and can do nothing else; this is the command that calls it.
8
+ //
9
+ // ⚠ IT ASKS THE SERVER, UNLIKE `usage`. `usage` adds up the sealed file list, which is the
10
+ // account's own record of what exists. This is the ledger, which only the server holds. They
11
+ // answer different questions and neither substitutes for the other: one is "what do I have",
12
+ // the other is "what can I still buy".
13
+ //
14
+ // ⛔ IT DOES NOT READ THE CHAIN, so it does not say when stored files expire — it reports the
15
+ // number the server holds and names the command that does read the clock. Two commands
16
+ // printing a storage deadline from two different sources is how they come to disagree.
17
+ import { request } from "../api.js";
18
+ import { isRecord } from "../guards.js";
19
+ import { NmtsError } from "../errors.js";
20
+ import { BINARY_NAME } from "../product.js";
21
+ import { openSession } from "../session.js";
22
+ import { humanSize } from "../units.js";
23
+ function num(value, field) {
24
+ if (typeof value !== "number" || !Number.isFinite(value)) {
25
+ throw new NmtsError(`The server's answer had no usable \`${field}\`.`, {
26
+ nextStep: "Update this tool, or read the account screen in a browser.",
27
+ });
28
+ }
29
+ return value;
30
+ }
31
+ function asSummary(value) {
32
+ if (!isRecord(value))
33
+ throw new NmtsError("The server's answer was not an object.");
34
+ const credits = value["credits"];
35
+ const quota = value["quota"];
36
+ const storage = value["storage"];
37
+ const terms = value["terms"];
38
+ if (!isRecord(credits) || !isRecord(quota) || !isRecord(storage)) {
39
+ throw new NmtsError("The server described this account in a shape this version cannot read.", {
40
+ nextStep: `Update this tool — \`npm install -g ${BINARY_NAME}\` — or read it in a browser.`,
41
+ });
42
+ }
43
+ const expiry = credits["soonest_expiry"];
44
+ const epoch = storage["earliest_expiry_epoch"];
45
+ return {
46
+ credits: {
47
+ remaining: num(credits["remaining"], "credits.remaining"),
48
+ soonest_expiry: typeof expiry === "string" ? expiry : null,
49
+ file_cap: num(credits["file_cap"], "credits.file_cap"),
50
+ daily_cap: num(credits["daily_cap"], "credits.daily_cap"),
51
+ },
52
+ quota: { granted: num(quota["granted"], "quota.granted"), used: num(quota["used"], "quota.used") },
53
+ storage: {
54
+ parts: num(storage["parts"], "storage.parts"),
55
+ earliest_expiry_epoch: typeof epoch === "number" ? epoch : null,
56
+ },
57
+ terms: { acceptance_required: isRecord(terms) && terms["acceptance_required"] === true },
58
+ };
59
+ }
60
+ function plural(n, one, many) {
61
+ return `${n} ${n === 1 ? one : many}`;
62
+ }
63
+ export async function balance(options = {}) {
64
+ const say = options.write ?? ((line) => process.stdout.write(`${line}\n`));
65
+ const session = await openSession({ server: options.server, network: options.network });
66
+ const summary = asSummary(await request(session.server, "/v1/account/summary", { token: session.apiKey }));
67
+ if (options.json === true) {
68
+ say(JSON.stringify(summary));
69
+ return 0;
70
+ }
71
+ const { credits, quota, storage } = summary;
72
+ say(`credits ${plural(credits.remaining, "credit", "credits")}`);
73
+ // ⛔ SAID AS BYTES TOO, because "one credit" means nothing until you know what it buys. It is the
74
+ // same number, not a second one — the server derives it from the same ledger read.
75
+ say(` = about ${humanSize(quota.granted)} for one lease period`);
76
+ if (credits.soonest_expiry !== null) {
77
+ // ⚠ THIS IS THE CREDITS EXPIRING, NOT THE FILES. A granted credit has its own life; storage
78
+ // already bought is not touched when one lapses.
79
+ say(` soonest to lapse unused: ${credits.soonest_expiry}`);
80
+ }
81
+ say(`ceilings ${credits.file_cap} per file · ${credits.daily_cap} per day`);
82
+ say(`holding ${humanSize(quota.used)} across ${plural(storage.parts, "stored piece", "stored pieces")}`);
83
+ if (storage.earliest_expiry_epoch !== null) {
84
+ say(` the earliest lease ends at storage period ${storage.earliest_expiry_epoch} — ` +
85
+ `\`${BINARY_NAME} expiring\` reads the clock and says when that is`);
86
+ }
87
+ if (summary.terms.acceptance_required) {
88
+ say(``);
89
+ say(`⛔ New terms are in force and this account has not accepted them.`);
90
+ say(` A person has to read and accept them in a browser; nothing here can do it.`);
91
+ }
92
+ return 0;
93
+ }
@@ -0,0 +1,77 @@
1
+ // `nmts consent` — see what this machine has agreed to, and change it.
2
+ //
3
+ // ⛔ IT IS A COMMAND AND NOT A PROMPT. A yes/no question in the middle of another command cannot
4
+ // be answered by anything that is not a terminal, which is most of the places this tool runs:
5
+ // a container, a build step, an agent's subprocess. Making agreement its own command means the
6
+ // same answer works everywhere, is recorded with a date, and can be looked at afterwards —
7
+ // none of which is true of a keystroke.
8
+ import { CONSENTS, CONSENT_KEYS, grant, grantedAt, revoke } from "../consent.js";
9
+ import { NmtsError } from "../errors.js";
10
+ import { BINARY_NAME, SUPPORT_EMAIL, VERSION } from "../product.js";
11
+ function isKey(value) {
12
+ return CONSENT_KEYS.includes(value);
13
+ }
14
+ function keyOrFail(raw) {
15
+ if (raw === undefined || raw === "") {
16
+ throw new NmtsError("Say which one.", {
17
+ exitCode: 2,
18
+ nextStep: `One of: ${CONSENT_KEYS.join(" · ")}`,
19
+ });
20
+ }
21
+ if (!isKey(raw)) {
22
+ throw new NmtsError(`There is nothing called "${raw}" to agree to.`, {
23
+ exitCode: 2,
24
+ nextStep: `One of: ${CONSENT_KEYS.join(" · ")}`,
25
+ });
26
+ }
27
+ return raw;
28
+ }
29
+ export function consent(action, target, options = {}) {
30
+ const say = options.write ?? ((line) => process.stdout.write(`${line}\n`));
31
+ const now = options.now ?? (() => new Date());
32
+ if (action === undefined || action === "" || action === "list") {
33
+ if (options.json) {
34
+ say(JSON.stringify(CONSENT_KEYS.map((key) => ({
35
+ key,
36
+ granted: grantedAt(key) !== null,
37
+ grantedAt: grantedAt(key),
38
+ what: CONSENTS[key].what,
39
+ risk: CONSENTS[key].risk,
40
+ }))));
41
+ return 0;
42
+ }
43
+ for (const key of CONSENT_KEYS) {
44
+ const at = grantedAt(key);
45
+ say(`${at === null ? " not agreed" : " agreed "} ${key}`);
46
+ say(` ${CONSENTS[key].what}`);
47
+ if (at !== null)
48
+ say(` agreed on this machine ${at}`);
49
+ say(``);
50
+ }
51
+ say(` ${BINARY_NAME} consent grant <name> agree, on this machine, once`);
52
+ say(` ${BINARY_NAME} consent revoke <name> take it back`);
53
+ say(``);
54
+ say(` Agreeing here does not change what the published Terms say. NMTS is not responsible`);
55
+ say(` for what any program on this machine does with this account, an AI agent included.`);
56
+ say(` Something wrong or confusing? ${SUPPORT_EMAIL}`);
57
+ return 0;
58
+ }
59
+ if (action === "grant") {
60
+ const key = keyOrFail(target);
61
+ grant(key, VERSION, now());
62
+ say(`agreed: ${key}`);
63
+ say(` ${CONSENTS[key].what}`);
64
+ say(` ${CONSENTS[key].limit}`);
65
+ return 0;
66
+ }
67
+ if (action === "revoke") {
68
+ const key = keyOrFail(target);
69
+ revoke(key);
70
+ say(`taken back: ${key}`);
71
+ return 0;
72
+ }
73
+ throw new NmtsError(`Unknown: ${BINARY_NAME} consent ${action}`, {
74
+ exitCode: 2,
75
+ nextStep: `Try \`${BINARY_NAME} consent\`, \`${BINARY_NAME} consent grant <name>\`, or \`${BINARY_NAME} consent revoke <name>\`.`,
76
+ });
77
+ }