@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/exit.js ADDED
@@ -0,0 +1,107 @@
1
+ // How this program ends.
2
+ //
3
+ // ⛔ SPLIT OUT OF `main.ts` RATHER THAN INVENTED. Each of these was written there and moved here
4
+ // unchanged, on the day that file passed the length gate — the rule in this repository is that
5
+ // a file gets divided when it is being worked on. What belongs together here is "the shape of
6
+ // the ending": everything in `main.ts` decides WHAT to do, and everything here decides how the
7
+ // process stops saying so — the code it leaves behind, the one failure that must not become
8
+ // one, whether it was the program at all, and the last line it prints.
9
+ import { realpathSync } from "node:fs";
10
+ import { parseArgs } from "./args.js";
11
+ import { NmtsError, UNKNOWN_FAILURE_EXIT } from "./errors.js";
12
+ /** The exit code a failure asks for, or the generic one. */
13
+ export function exitCodeFor(error) {
14
+ if (error instanceof NmtsError)
15
+ return error.exitCode;
16
+ return UNKNOWN_FAILURE_EXIT;
17
+ }
18
+ /**
19
+ * Stop a closed pipe from becoming a crash.
20
+ *
21
+ * ⛔ `nmts ls | head` IS AN ORDINARY THING TO DO, and without this it prints a ten-line stack
22
+ * trace instead of the answer. `head` closes the pipe once it has its lines; the next write
23
+ * raises EPIPE, and Node turns an unhandled stream error into a fatal one. Every shell tool is
24
+ * expected to end quietly there — that is what SIGPIPE does for programs that do not intercept
25
+ * it — and an agent piping this into anything would otherwise read a crash and conclude the
26
+ * tool is broken.
27
+ *
28
+ * ⚠ ONLY EPIPE. A write that fails for any other reason is still a real failure and still throws;
29
+ * swallowing all stream errors would hide a full disk behind silence.
30
+ */
31
+ export function endQuietlyOnClosedPipe() {
32
+ for (const stream of [process.stdout, process.stderr]) {
33
+ stream.on("error", (error) => {
34
+ if (error.code === "EPIPE") {
35
+ process.exitCode = 0;
36
+ return;
37
+ }
38
+ throw error;
39
+ });
40
+ }
41
+ }
42
+ /**
43
+ * Is the module at `moduleFilename` the program, or is something importing it?
44
+ *
45
+ * ⛔ THE FILENAME IS PASSED IN AND NOT READ HERE. `import.meta.filename` inside this module
46
+ * would name THIS file, which is never the program; the caller's own is the question.
47
+ *
48
+ * ⛔ THE COMPARISON IS BETWEEN REAL PATHS, AND THAT IS THE WHOLE POINT. `npm install -g` does not
49
+ * copy the command onto your PATH — it puts a SYMLINK there, pointing at `dist/main.js` inside
50
+ * the installed package. Node then loads the module by its real
51
+ * path, so the module's own name is `.../dist/main.js` while `process.argv[1]` is
52
+ * `.../bin/nmts`. Comparing those two directly is never true for an installed command: `nmts --version` printed NOTHING and exited 0, and
53
+ * so did every other command. The whole tool did nothing at all, silently, and only when
54
+ * installed — which is the one way a person who is not us runs it.
55
+ *
56
+ * ⚠ `realpathSync` THROWS on a path that is not there. `process.argv[1]` normally exists, but a
57
+ * caller is free to hand Node something else, and a crash before the first line of output would
58
+ * be a worse answer than not running. Not resolving is treated as "not the program", which is
59
+ * the same answer this was giving before symlinks were considered at all.
60
+ */
61
+ export function invokedDirectly(moduleFilename) {
62
+ const invokedAs = process.argv[1];
63
+ if (invokedAs === undefined)
64
+ return false;
65
+ if (invokedAs === moduleFilename)
66
+ return true;
67
+ try {
68
+ return realpathSync(invokedAs) === moduleFilename;
69
+ }
70
+ catch {
71
+ return false;
72
+ }
73
+ }
74
+ /**
75
+ * Commands the version notice stays out of.
76
+ *
77
+ * ⛔ `update` BECAUSE IT IS THE NOTICE'S OWN SUBJECT — it has just said more about versions than
78
+ * one line could, and repeating a shorter version of it underneath would be noise.
79
+ * ⛔ `mcp` BECAUSE IT DOES NOT END. It is a server on a pipe, and its stderr is read by whatever
80
+ * started it; a line arriving there whenever the process happens to stop is not a notice, it is
81
+ * an unexplained log entry.
82
+ */
83
+ const NO_NOTICE = ["update", "mcp"];
84
+ /**
85
+ * Say so if a newer release is already known about, and refresh what is known for next time.
86
+ *
87
+ * ⛔ AFTER THE COMMAND, NOT BEFORE IT. Whatever was asked for has already been printed and the
88
+ * exit code is already decided; nothing here can change either. ⛔ AND ONLY FROM `main`: the
89
+ * tests drive `run` directly, so no test and no embedded caller ever reaches a network.
90
+ */
91
+ export async function noteUpdateAfter(argv, version) {
92
+ let command;
93
+ try {
94
+ command = parseArgs(argv).command;
95
+ }
96
+ catch {
97
+ // A command line this could not read is one that failed above. Nothing to add to it.
98
+ return;
99
+ }
100
+ if (command !== null && NO_NOTICE.includes(command))
101
+ return;
102
+ // ⛔ THE SWITCH IS NOT CHECKED HERE AS WELL. `noteUpdate` reads it, and reading it twice would
103
+ // put one rule in two files — the shape that ends with one of them changing. There is nothing
104
+ // to save by checking early: the module holding the switch is the module being loaded.
105
+ const { noteUpdate } = await import("./update-check.js");
106
+ await noteUpdate({ running: version });
107
+ }
package/dist/expiry.js ADDED
@@ -0,0 +1,115 @@
1
+ // When bought storage runs out — the arithmetic, with no network in it.
2
+ //
3
+ // ⛔ A DAY COUNT IS A FLOOR, NOT A MEASUREMENT, UNLESS THE NETWORK SAID WHEN THIS EPOCH BEGAN.
4
+ // The storage network always reports which epoch it is in and usually nothing about how far
5
+ // into it we are. Counting `(end - current)` whole epochs therefore assumes the current epoch
6
+ // has not started yet — on a network whose epoch is fourteen days that overstates the runway by
7
+ // up to fourteen days, and a file with 23 days left gets told it has 28. This tool is allowed
8
+ // to warn early and is not allowed to warn late, so without an anchor the answer is
9
+ // `(end - current - 1)` epochs and `exact` is false, which is what makes the caller print
10
+ // "or more" instead of a flat number.
11
+ //
12
+ // ⛔ AND THE EPOCH COMPARISON DECIDES WHETHER A TERM HAS ENDED, never the day count. A lease that
13
+ // ends AT the current epoch has no epoch left to be read in, while an unanchored clock can
14
+ // still put a positive number of days beside it.
15
+ //
16
+ // ⛔ AN UNRECORDED PERIOD IS NOT A SHORT ONE. `expiry_epoch` is 0 for a file whose uploader could
17
+ // not read the epoch clock, and 0 sorts below every real deadline — so a listing that ranked by
18
+ // the number alone would put "we do not know" at the top of a list headed "about to be lost".
19
+ // `stageOf` answers `unrecorded` for those and the caller keeps them apart.
20
+ //
21
+ // ⛔ THE ARITHMETIC IS NOT WRITTEN HERE ANY MORE (2026-08-24). It used to be a second
22
+ // implementation of the browser's, with only the four thresholds machine-compared — and a
23
+ // compared VALUE does not stop two programs computing different answers from it. The browser's
24
+ // copy lived in a file that also imports its wallet client, which cannot follow into a
25
+ // command-line package, so the pure half was split out there and is copied here byte for byte
26
+ // (`shared/lib/extend/epochs.ts`). What is left in this file is the part the browser has no use
27
+ // for: a validated clock, the stage a file is in, the cutoff to ask the server about, and the
28
+ // words a person reads.
29
+ //
30
+ // PURE: no network, no SDK, no clock of its own — `nowMs` is always passed in. That is what lets
31
+ // `node --test` drive the branches a real network only reaches during an epoch change.
32
+ import { daysLeftUntilEpoch as sharedDaysLeftUntilEpoch, NOTICE_DAYS, NOTICE_EPOCHS, URGENT_DAYS, URGENT_EPOCHS, warningEpochs as sharedWarningEpochs, } from "./shared/lib/extend/epochs.js";
33
+ // ⛔ Re-exported under the same names: a caller has no reason to know which of the two files a
34
+ // threshold came from, and making it know would be a second thing to keep in step.
35
+ export { NOTICE_DAYS, NOTICE_EPOCHS, URGENT_DAYS, URGENT_EPOCHS };
36
+ /**
37
+ * A clock, or null when what the network answered cannot be counted with.
38
+ *
39
+ * ⛔ THE INVARIANT LIVES IN THE TYPE, so every function below can divide by `durationMs` without
40
+ * asking again. A zero or a NaN out of a chain read would otherwise become an Infinity printed
41
+ * as a number of days, and there would be no single place to have caught it.
42
+ */
43
+ export function epochClock(current, durationMs, startedMs) {
44
+ if (!Number.isSafeInteger(current) || current < 0)
45
+ return null;
46
+ if (!Number.isFinite(durationMs) || durationMs <= 0)
47
+ return null;
48
+ if (startedMs !== null && !Number.isFinite(startedMs))
49
+ return null;
50
+ return { current, durationMs, startedMs };
51
+ }
52
+ /**
53
+ * How far ahead a warning stage reaches, in epochs: a floor in days and a floor in epochs, wider
54
+ * one wins.
55
+ *
56
+ * The day count rounds UP, because arriving a tick late is the quiet failure the whole surface
57
+ * exists to prevent.
58
+ */
59
+ export function warningEpochs(clock, days, floorEpochs) {
60
+ return sharedWarningEpochs(days, floorEpochs, clock.durationMs);
61
+ }
62
+ /**
63
+ * Whole days from `nowMs` until `epoch` is reached, by the network's own clock.
64
+ *
65
+ * With an anchor both ends collapse onto one moment. Without one, the honest answer is the
66
+ * earliest the epoch can arrive — see the header for why the other edge is never used.
67
+ */
68
+ export function daysLeftUntilEpoch(clock, epoch, nowMs) {
69
+ const left = sharedDaysLeftUntilEpoch(epoch, clock, nowMs);
70
+ // ⛔ THE SHARED FUNCTION ANSWERS `null` FOR A CLOCK IT CANNOT DIVIDE BY, and this one cannot
71
+ // hand that back: every caller here is already past `epochClock`, which refuses such a clock
72
+ // at the boundary — that is the whole reason `EpochClock` is a type you cannot assemble by
73
+ // hand. So `null` here would mean the validator and the arithmetic disagree about what a
74
+ // usable clock is, and a wrong number of days on a screen about deletion is worse than a stop.
75
+ if (left === null) {
76
+ throw new Error("the storage network's clock passed validation and then could not be counted with");
77
+ }
78
+ return left;
79
+ }
80
+ export function stageOf(clock, expiryEpoch, nowMs) {
81
+ // 0 is the column's "nothing was recorded" — see the header. Anything below it is a value no
82
+ // network produced, and guessing at what it meant would be the same mistake in a smaller print.
83
+ if (!Number.isFinite(expiryEpoch) || expiryEpoch <= 0)
84
+ return "unrecorded";
85
+ if (expiryEpoch <= clock.current)
86
+ return "lapsed";
87
+ if (expiryEpoch <= clock.current + warningEpochs(clock, URGENT_DAYS, URGENT_EPOCHS))
88
+ return "urgent";
89
+ if (expiryEpoch < clock.current + warningEpochs(clock, NOTICE_DAYS, NOTICE_EPOCHS))
90
+ return "soon";
91
+ return "later";
92
+ }
93
+ /**
94
+ * The epoch to ask the server about: everything ending before this is inside the warning window.
95
+ *
96
+ * The cutoff is computed HERE and sent as an absolute number because the server does not read the
97
+ * chain and cannot work it out — `GET /v1/items/expiring` takes the answer, not the question.
98
+ */
99
+ export function warningCutoffEpoch(clock) {
100
+ return clock.current + warningEpochs(clock, NOTICE_DAYS, NOTICE_EPOCHS);
101
+ }
102
+ /**
103
+ * How long is left, said the way a person reads it.
104
+ *
105
+ * ⚠ "or more" is not hedging: it is the difference between a measurement and a floor, and dropping
106
+ * it would turn a lower bound into a promise about a deletion date.
107
+ */
108
+ export function daysLeftInWords(left) {
109
+ if (left.days < 0)
110
+ return left.exact ? "already ended" : "may already have ended";
111
+ if (left.days < 1)
112
+ return left.exact ? "ends today" : "could end today";
113
+ const days = `${left.days} day${left.days === 1 ? "" : "s"}`;
114
+ return left.exact ? `${days} left` : `${days} or more left`;
115
+ }
@@ -0,0 +1,137 @@
1
+ // The chain reads behind `nmts extend`: what a lease says, how far ahead the network sells, and
2
+ // what more epochs cost.
3
+ //
4
+ // ⛔ EVERYTHING HERE IS A READ. Nothing in this file builds a transaction, holds a key or moves an
5
+ // asset — the signature lives next door in `extend-sign.ts`, alone, so that the file which can
6
+ // spend is the smallest one in this tool. What is shared is the client builder below, because
7
+ // the two must talk to the same chain: a quote read from one network and a payment made on
8
+ // another is a payment for nothing.
9
+ //
10
+ // ⛔ SPLIT FROM THE COMMAND SO THE COMMAND CAN BE TESTED. `extend-plan.ts` decides what an
11
+ // extension buys and `commands/extend.ts` decides what to say about it; both are driven by
12
+ // `node --test` against fakes. A test that needed a live storage network could not run offline,
13
+ // would answer differently every fortnight, and could never be asked to fail on demand.
14
+ //
15
+ // ⛔ AND NOTHING IS DEFAULTED TO ZERO. A lease that cannot be read, a shape the Move struct does
16
+ // not have, a price the system object will not give — each throws with its reason. A cost of
17
+ // zero on a screen that asks for money is the one wrong answer nobody would question.
18
+ import { SuiJsonRpcClient } from "@mysten/sui/jsonRpc";
19
+ import { walrus } from "@mysten/walrus";
20
+ import { NmtsError } from "./errors.js";
21
+ import { epochClock } from "./expiry.js";
22
+ import { isRecord } from "./guards.js";
23
+ import { epochStartedMs } from "./walrus-write.js";
24
+ import { suiRpcHost } from "./walrus.js";
25
+ /** How long one chain question gets. A read that stalls is a read that failed. */
26
+ export const EXTEND_READ_TIMEOUT_MS = 20_000;
27
+ /**
28
+ * A Walrus-aware client for one network.
29
+ *
30
+ * ⛔ THE NETWORK NAME REACHES THE SDK AS WELL AS THE URL, exactly as it does for an upload. A
31
+ * mirror pointed at the wrong chain would otherwise be discovered as a refusal from the storage
32
+ * contract — after a transaction had been signed.
33
+ */
34
+ export function walrusClient(network) {
35
+ return build(network);
36
+ }
37
+ function build(network) {
38
+ return new SuiJsonRpcClient({
39
+ network: network === "mainnet" ? "mainnet" : "testnet",
40
+ url: suiRpcHost(network),
41
+ }).$extend(walrus({}));
42
+ }
43
+ /** A number off the wire, whether it arrived as a number or as one of Sui's 64-bit strings. */
44
+ function numberOf(value) {
45
+ if (typeof value === "number")
46
+ return Number.isFinite(value) ? value : null;
47
+ if (typeof value !== "string" || value.trim() === "")
48
+ return null;
49
+ const parsed = Number(value);
50
+ return Number.isFinite(parsed) ? parsed : null;
51
+ }
52
+ /**
53
+ * One blob's lease, read from the Blob object itself.
54
+ *
55
+ * ⛔ NARROWED FROM `unknown`, NOT FROM THE SDK'S TYPE. `MoveStruct` is a three-way union whose
56
+ * members do not all carry an index signature, so reading a field off it means asserting — and
57
+ * an assertion compiles whether or not the check above it is right. What matters is the shape
58
+ * on the wire, and the predicate in `guards.ts` is what carries that narrowing.
59
+ */
60
+ export async function readBlobLease(client, objectId) {
61
+ const response = await client.getObject({
62
+ id: objectId,
63
+ options: { showContent: true },
64
+ });
65
+ const data = isRecord(response) ? response["data"] : undefined;
66
+ const content = isRecord(data) ? data["content"] : undefined;
67
+ if (!isRecord(content) || content["dataType"] !== "moveObject") {
68
+ throw new NmtsError(`The storage object ${objectId} could not be read from the chain.`, {
69
+ exitCode: 1,
70
+ nextStep: "Nothing was signed and nothing was charged. The blob may already have been deleted, or " +
71
+ "this tool may be pointed at the wrong network — `nmts env` says which one it is using.",
72
+ });
73
+ }
74
+ const fields = content["fields"];
75
+ const storage = isRecord(fields) ? fields["storage"] : undefined;
76
+ const storageFields = isRecord(storage) ? storage["fields"] : undefined;
77
+ const size = numberOf(isRecord(fields) ? fields["size"] : undefined);
78
+ const endEpoch = numberOf(isRecord(storageFields) ? storageFields["end_epoch"] : undefined);
79
+ if (size === null || endEpoch === null) {
80
+ // ⛔ A shape change in the Move struct surfaces HERE, not as a wrong price downstream.
81
+ throw new NmtsError(`The storage object ${objectId} is not shaped the way this version reads.`, {
82
+ exitCode: 1,
83
+ nextStep: "Nothing was signed and nothing was charged. Update this tool.",
84
+ });
85
+ }
86
+ return { objectId, size, endEpoch };
87
+ }
88
+ /**
89
+ * The reads one `nmts extend` run needs, bound to one network.
90
+ *
91
+ * ⚠ THE WINDOW IS `null` RATHER THAN A GUESS when the network cannot be read, exactly as it is for
92
+ * `nmts expiring`: the caller stops. An unread clock and a file in no danger look identical from
93
+ * outside and are the opposite of each other.
94
+ */
95
+ export function extendReads(network) {
96
+ const client = build(network);
97
+ return {
98
+ async readWindow() {
99
+ try {
100
+ const [system, staking] = await Promise.all([
101
+ client.walrus.systemState(),
102
+ client.walrus.stakingState(),
103
+ ]);
104
+ const clock = epochClock(Number(system.committee.epoch), Number(staking.epoch_duration), epochStartedMs(staking.epoch_state));
105
+ if (clock === null)
106
+ return null;
107
+ // How far ahead a lease may reach: the protocol's future-accounting ring length, which is
108
+ // exactly what `storage_accounting::max_epochs_ahead` returns. Read, never assumed — it
109
+ // is the difference between offering a length the network will sell and one it refuses
110
+ // on-chain after the transaction is signed.
111
+ const maxAhead = numberOf(system.future_accounting.length);
112
+ if (maxAhead === null || maxAhead <= 0)
113
+ return null;
114
+ return { clock, maxAhead };
115
+ }
116
+ catch {
117
+ return null;
118
+ }
119
+ },
120
+ readLeases(objectIds) {
121
+ return Promise.all(objectIds.map((id) => readBlobLease(client, id)));
122
+ },
123
+ /**
124
+ * What extending every one of these blobs by `epochs` costs, in FROST (WAL base units).
125
+ *
126
+ * ⭐ STORAGE ONLY. Extending pays for space over time; the write cost was paid once when the
127
+ * blob was registered and is not charged again. That is why extending is markedly cheaper
128
+ * than uploading the same bytes afresh, and why `totalCost` is the wrong field here.
129
+ */
130
+ async quote(leases, epochs) {
131
+ if (epochs <= 0 || leases.length === 0)
132
+ return 0n;
133
+ const costs = await Promise.all(leases.map((lease) => client.walrus.storageCost(lease.size, epochs)));
134
+ return costs.reduce((sum, cost) => sum + BigInt(cost.storageCost), 0n);
135
+ },
136
+ };
137
+ }
@@ -0,0 +1,149 @@
1
+ // What extending a file's storage would buy, worked out before anything is signed.
2
+ //
3
+ // ⛔ NO NETWORK, NO SDK, NO KEY. Everything here is arithmetic over numbers somebody else read, so
4
+ // `node --test` can drive every branch — including the ones a live storage network only reaches
5
+ // by being at its ceiling, or by having sold a lease that already ran out. The reads live in
6
+ // `extend-chain.ts`, the signature in `extend-sign.ts`, and neither can be reached from here.
7
+ //
8
+ // ⛔ THE CHAIN IS THE AUTHORITY ON WHEN A LEASE ENDS, not the server's `expiry_epoch`. That column
9
+ // is client-reported and advisory — it is what `nmts expiring` ranks by, because ranking is all
10
+ // it does — and a command that spends money reads the Blob object itself. The server's answer
11
+ // is used for exactly one thing: knowing WHICH blobs to ask the chain about.
12
+ //
13
+ // ⛔ AND AN EXTENSION IS ADDED TO WHAT IS LEFT, never counted from today. Extending early loses
14
+ // nothing, which is why this tool is allowed to offer it at all; the new end is
15
+ // `endEpoch + epochs`, and that is the number every day count below is measured from.
16
+ import { NmtsError } from "./errors.js";
17
+ import { isRecord } from "./guards.js";
18
+ import { BINARY_NAME } from "./product.js";
19
+ /**
20
+ * The longest extension the NMTS server will RECORD, in epochs.
21
+ *
22
+ * ⛔ IT IS THE SERVER'S NUMBER AND IT IS CHECKED HERE ANYWAY, before the money moves. `api`'s
23
+ * `MAX_EXTEND_EPOCHS` (routes/storage.rs) refuses to record anything longer — and the recording
24
+ * happens AFTER the signature, so a length the chain would happily sell and the server would
25
+ * refuse to write down produces the worst outcome this command has: storage that is really
26
+ * extended, paid for, and a drive that goes on saying the old date.
27
+ *
28
+ * ⚠ A SECOND COPY, deliberately. The alternative is discovering the limit from a 400 after
29
+ * spending. `check:cli-routes` holds the addresses level; nothing holds this number level, so it
30
+ * is named with its origin and the refusal it produces says what the server would have said.
31
+ */
32
+ export const MAX_RECORDABLE_EPOCHS = 104;
33
+ /**
34
+ * How long an extension is when nobody says.
35
+ *
36
+ * The same term one credit buys on the upload rail (`upload-price.ts`, `UPLOAD_EPOCHS`) and the
37
+ * cheapest rung the browser's picker offers. ⚠ It is a number of EPOCHS, not of days: two epochs
38
+ * is two days on testnet and twenty-eight on mainnet, so every surface that prints it prints the
39
+ * days beside it, read from the network's own clock.
40
+ */
41
+ export const DEFAULT_EXTEND_EPOCHS = 2;
42
+ /**
43
+ * The preview, or a refusal.
44
+ *
45
+ * ⛔ A TARGET THIS CANNOT READ IS A REFUSAL, NOT A TARGET TO SKIP. Skipping one would produce a
46
+ * transaction that extends some of a file's blobs and leaves the others to expire — and the
47
+ * file is unreadable if any single one of them goes, so the money would buy nothing.
48
+ */
49
+ export function asExtendPreview(value) {
50
+ const unreadable = () => {
51
+ throw new NmtsError("The server answered with an extension plan this version cannot read.", {
52
+ nextStep: "Update this tool. Nothing was signed, and nothing was charged.",
53
+ });
54
+ };
55
+ if (!isRecord(value))
56
+ return unreadable();
57
+ const raw = value["targets"];
58
+ if (!Array.isArray(raw))
59
+ return unreadable();
60
+ const targets = [];
61
+ for (const item of raw) {
62
+ if (!isRecord(item))
63
+ return unreadable();
64
+ const objectId = item["sui_object_id"];
65
+ const shared = item["shared_items"];
66
+ if (typeof objectId !== "string" || objectId === "")
67
+ return unreadable();
68
+ if (typeof shared !== "number" || !Number.isFinite(shared))
69
+ return unreadable();
70
+ targets.push({ objectId, sharedItems: shared });
71
+ }
72
+ const treasury = value["treasury_parts"];
73
+ const untracked = value["untracked_parts"];
74
+ if (typeof treasury !== "number" || typeof untracked !== "number")
75
+ return unreadable();
76
+ return { targets, treasuryParts: treasury, untrackedParts: untracked };
77
+ }
78
+ /**
79
+ * When the file actually runs out: the SOONEST end epoch across the blobs it rides on.
80
+ *
81
+ * ⛔ SOONEST, NOT FURTHEST. One expired blob is enough to make the file unreadable, so the file's
82
+ * deadline is the first of them. Null when there is no lease to read, which is a real state
83
+ * (every part on treasury-paid storage) and is never drawn as "now".
84
+ */
85
+ export function soonestEnd(leases) {
86
+ if (leases.length === 0)
87
+ return null;
88
+ return Math.min(...leases.map((l) => l.endEpoch));
89
+ }
90
+ /**
91
+ * The largest number of epochs these leases can ALL be extended by.
92
+ *
93
+ * The ceiling is per-blob — a lease may not end more than `maxAhead` epochs past the current one —
94
+ * so the blob that already reaches furthest into the future is the binding one. 0 is a real
95
+ * answer ("already paid as far ahead as the network allows"), not a failure.
96
+ */
97
+ export function headroom(leases, current, maxAhead) {
98
+ if (leases.length === 0)
99
+ return 0;
100
+ const furthest = Math.max(...leases.map((l) => l.endEpoch));
101
+ return Math.max(0, current + maxAhead - furthest);
102
+ }
103
+ /**
104
+ * How many epochs to buy: what was asked for, or the default, checked against both ceilings.
105
+ *
106
+ * ⛔ IT REFUSES RATHER THAN CLAMPS. Quietly buying fewer epochs than somebody asked for spends
107
+ * their money on something they did not ask for, and quietly buying more spends more of it.
108
+ * Each refusal names the ceiling that produced it, because the two have different remedies:
109
+ * the network's is a wait, the server's is a shorter extension repeated later.
110
+ */
111
+ export function chooseEpochs(asked, available) {
112
+ if (available <= 0) {
113
+ throw new NmtsError("This file's storage is already paid as far ahead as the network allows.", {
114
+ exitCode: 4,
115
+ nextStep: "Nothing was signed and nothing was charged. The storage network refuses a lease that " +
116
+ "ends further ahead than its own ceiling; extending again becomes possible as the " +
117
+ "network's epoch moves forward.",
118
+ });
119
+ }
120
+ const epochs = asked === undefined ? DEFAULT_EXTEND_EPOCHS : parseEpochs(asked);
121
+ if (epochs > available) {
122
+ throw new NmtsError(`The storage network will sell at most ${available} more epoch${available === 1 ? "" : "s"} on this file.`, {
123
+ exitCode: 4,
124
+ nextStep: `Nothing was signed and nothing was charged. A lease may not end further ahead than the ` +
125
+ `network's own ceiling. Ask for ${available} or fewer: \`${BINARY_NAME} extend --epochs ${available}\`.`,
126
+ });
127
+ }
128
+ if (epochs > MAX_RECORDABLE_EPOCHS) {
129
+ throw new NmtsError(`This tool extends by at most ${MAX_RECORDABLE_EPOCHS} epochs at a time.`, {
130
+ exitCode: 4,
131
+ nextStep: `Nothing was signed and nothing was charged. The NMTS server refuses to record a longer ` +
132
+ `one, so the storage would really be extended and the drive would go on showing the old ` +
133
+ `date. Extend by ${MAX_RECORDABLE_EPOCHS} or fewer, more than once if you need to.`,
134
+ });
135
+ }
136
+ return epochs;
137
+ }
138
+ /** A whole positive number of epochs, or the one refusal for anything else. */
139
+ function parseEpochs(asked) {
140
+ const epochs = typeof asked === "number" ? asked : Number(asked.trim());
141
+ if (!Number.isSafeInteger(epochs) || epochs <= 0) {
142
+ throw new NmtsError(`An extension is a whole number of epochs: ${String(asked)}.`, {
143
+ exitCode: 2,
144
+ nextStep: `Nothing was signed and nothing was charged. \`--epochs 4\` buys four more of the storage ` +
145
+ `network's epochs — one day each on testnet, fourteen on mainnet.`,
146
+ });
147
+ }
148
+ return epochs;
149
+ }
@@ -0,0 +1,130 @@
1
+ // ⛔ THE ONE FILE IN THIS TOOL THAT SIGNS. Everything it can do moves real assets and cannot be
2
+ // reversed by anybody, including NMTS.
3
+ //
4
+ // ⛔ IT IS REACHED FROM EXACTLY ONE PLACE: `commands/extend.ts`, after `requireConsent("wallet")`
5
+ // and after the price has been read and printed. Nothing else imports it, and it is loaded
6
+ // lazily so that a run which does not spend never even brings the code into memory.
7
+ //
8
+ // ⛔ NO KEY LEAVES THIS FILE. `wallet.ts` states the rule for the whole tool — no function returns
9
+ // a seed, a private key or a keypair — and this file is the one exception to the reason for it
10
+ // (something has to hold a key to sign) rather than to the rule itself: the keypair is built
11
+ // inside a function, asked one question, and dropped. ⚠ `@mysten/sui` keeps the secret inside
12
+ // its own object with no method that clears it, exactly as it does in the browser's worker; what
13
+ // is controlled here is lifetime. The buffers this file makes — the derived material, the wallet
14
+ // root, the seed — are wiped on every path out, failures included.
15
+ //
16
+ // ⛔ THE DERIVATION IS THE ENGINE'S, AT THE INDEX THE BROWSER USES. A second derivation written
17
+ // here would be free to drift from the one `nmts wallet address` prints, and the way that
18
+ // surfaces is a transaction signed by a wallet the person has never funded.
19
+ import { Ed25519Keypair } from "@mysten/sui/keypairs/ed25519";
20
+ import { Transaction } from "@mysten/sui/transactions";
21
+ import { assertUsableCode } from "./account.js";
22
+ import { DERIVED, loadCrypto } from "./crypto.js";
23
+ import { NmtsError } from "./errors.js";
24
+ import { walrusClient } from "./extend-chain.js";
25
+ import { isRecord } from "./guards.js";
26
+ import { BUILT_IN_WALLET_INDEX } from "./wallet.js";
27
+ /**
28
+ * The keypair the account code derives.
29
+ *
30
+ * ⛔ NOT EXPORTED. A caller that could hold this could sign anything, and the whole of this tool's
31
+ * story about the wallet is that one command signs one shape of transaction.
32
+ */
33
+ async function keypairFor(code) {
34
+ // The one refusal text for a malformed code lives in `account.ts`, so a typo fails here the same
35
+ // way it fails everywhere else in this tool rather than as an engine error.
36
+ await assertUsableCode(code);
37
+ const glue = await loadCrypto();
38
+ let bytes = null;
39
+ let derived = null;
40
+ let root = null;
41
+ let seed = null;
42
+ try {
43
+ bytes = glue.account_code_parse(code);
44
+ // ⛔ THIS BUFFER IS EVERY KEY IN THE ACCOUNT, not just the wallet root: the sign-in secret, the
45
+ // key that opens the files, the key that opens the file list.
46
+ derived = glue.kdf_derive(bytes);
47
+ const [from, to] = DERIVED.walletRoot;
48
+ root = derived.slice(from, to);
49
+ seed = glue.wallet_seed_for(root, BUILT_IN_WALLET_INDEX);
50
+ return Ed25519Keypair.fromSecretKey(seed);
51
+ }
52
+ catch (error) {
53
+ if (error instanceof NmtsError)
54
+ throw error;
55
+ // ⛔ An engine message about a code can carry the code (`errors.ts`), so it is never passed on.
56
+ throw new NmtsError("The account code could not be read on this machine.", { exitCode: 1 });
57
+ }
58
+ finally {
59
+ seed?.fill(0);
60
+ root?.fill(0);
61
+ derived?.fill(0);
62
+ bytes?.fill(0);
63
+ }
64
+ }
65
+ /**
66
+ * The address this tool would sign as.
67
+ *
68
+ * ⛔ IT EXISTS TO BE HELD AGAINST `walletAddress`. That function is what `nmts wallet address`
69
+ * prints and what somebody funds; this one is what a transaction would actually be signed by.
70
+ * Nothing else in this package proves the two are the same wallet, and the failure if they ever
71
+ * part is silent — a signature from an address with nothing in it, or worse, money sent to an
72
+ * address that signs nothing. A test compares them, offline, for free.
73
+ */
74
+ export async function signerAddress(code) {
75
+ return (await keypairFor(code)).toSuiAddress();
76
+ }
77
+ /**
78
+ * Extend every listed blob by `epochs`, in ONE transaction, signed by the account's own wallet.
79
+ *
80
+ * ONE SIGNATURE FOR ALL OF IT: `extendBlob` returns a transaction fragment, so every blob a file
81
+ * sits on goes into the same transaction. A multi-part file is one payment and one gas fee, not
82
+ * five — and a partial extension would buy nothing, because one expired blob is enough to make the
83
+ * file unreadable.
84
+ *
85
+ * ⛔ THE IDS ARE DE-DUPLICATED. Naming the same blob twice pays for the same epochs twice.
86
+ *
87
+ * ⚠ A FAILURE HERE IS NOT PROOF THAT NOTHING HAPPENED. A refusal from the node, a timeout, a
88
+ * connection that dropped after the bytes went out — none of them say whether the transaction
89
+ * was executed. The caller re-reads the chain rather than offering a second attempt against
90
+ * numbers it read before.
91
+ */
92
+ export const signExtension = async ({ network, code, objectIds, epochs }) => {
93
+ const unique = [...new Set(objectIds)];
94
+ if (unique.length === 0) {
95
+ throw new NmtsError("There is nothing on this file that can be extended.", { exitCode: 4 });
96
+ }
97
+ if (!Number.isSafeInteger(epochs) || epochs <= 0) {
98
+ throw new NmtsError("An extension must be a positive whole number of epochs.", { exitCode: 2 });
99
+ }
100
+ const client = walrusClient(network);
101
+ const keypair = await keypairFor(code);
102
+ const tx = new Transaction();
103
+ // The sender must be set before the fragments resolve: paying with the SDK's default coin
104
+ // selection picks the WAL coins from the sender's own address.
105
+ tx.setSender(keypair.toSuiAddress());
106
+ for (const blobObjectId of unique) {
107
+ tx.add(client.walrus.extendBlob({ blobObjectId, epochs }));
108
+ }
109
+ const result = await client.signAndExecuteTransaction({
110
+ transaction: tx,
111
+ signer: keypair,
112
+ options: { showEffects: true },
113
+ });
114
+ // ⛔ A DIGEST IS NOT A SUCCESS. A transaction that was executed and FAILED still has one, and the
115
+ // gas for it is still gone; recording it as an extension would move the drive's expiry date
116
+ // over storage nobody bought. The status is asked for above and read here.
117
+ const effects = result.effects;
118
+ const status = isRecord(effects) ? effects["status"] : undefined;
119
+ const outcome = isRecord(status) ? status["status"] : undefined;
120
+ if (outcome === "failure") {
121
+ const why = isRecord(status) ? status["error"] : undefined;
122
+ throw new NmtsError(`The storage network refused the extension: ${typeof why === "string" ? why : "no reason was given"}.`, {
123
+ exitCode: 1,
124
+ nextStep: `The storage was NOT extended and the file still ends when it did. The transaction fee ` +
125
+ `was spent. The usual causes are too little WAL or SUI in this account's wallet — ` +
126
+ `\`nmts wallet\` shows both — or a length the network will no longer sell.`,
127
+ });
128
+ }
129
+ return result.digest;
130
+ };
package/dist/guards.js ADDED
@@ -0,0 +1,15 @@
1
+ // Narrowing `unknown` without asserting.
2
+ //
3
+ // ⛔ WHY A MODULE FOR THIS. Everything that arrives from outside this program — a server answer, a
4
+ // tool call's arguments, a file somebody wrote — starts as `unknown`, and the shortest way to
5
+ // use it is `value as Record<string, unknown>`. That assertion compiles whether or not the check
6
+ // above it is right, and it keeps compiling after somebody edits the check. A predicate makes
7
+ // the compiler carry the narrowing instead, so the two can never drift apart.
8
+ //
9
+ // ⚠ ARRAYS ARE NOT RECORDS HERE. `typeof [] === "object"` and `[] !== null`, so the obvious
10
+ // two-part check lets an array through as an object — which is how a JSON array ends up being
11
+ // read for named fields and quietly answering `undefined` to every one of them.
12
+ /** True for a plain JSON object: not null, not an array. */
13
+ export function isRecord(value) {
14
+ return typeof value === "object" && value !== null && !Array.isArray(value);
15
+ }