@needmoretruth/nmts-cli 0.38.1 → 0.40.0
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.
- package/AGENTS.md +14 -35
- package/CHANGELOG.md +16 -0
- package/README.ko.md +1 -1
- package/README.md +1 -1
- package/dist/artifact-about.d.ts +1 -1
- package/dist/commands/erase.js +12 -107
- package/dist/commands/extend.d.ts +3 -3
- package/dist/commands/extend.js +52 -161
- package/dist/commands/wallet-storage-ops.d.ts +2 -2
- package/dist/commands/wallet-storage-ops.js +108 -134
- package/dist/commands/wallet-storage.d.ts +3 -8
- package/dist/commands/wallet-storage.js +16 -37
- package/dist/drive-erase.d.ts +83 -0
- package/dist/drive-erase.js +161 -0
- package/dist/platform-sign.d.ts +9 -1
- package/dist/platform-sign.js +9 -1
- package/dist/product.d.ts +1 -1
- package/dist/product.js +1 -1
- package/dist/storage-control/extend.d.ts +124 -0
- package/dist/storage-control/extend.js +189 -0
- package/dist/storage-control/hints.d.ts +20 -0
- package/dist/storage-control/hints.js +11 -0
- package/dist/storage-control/list.d.ts +55 -0
- package/dist/storage-control/list.js +71 -0
- package/dist/storage-control/reshape.d.ts +120 -0
- package/dist/storage-control/reshape.js +188 -0
- package/dist/storage-control-chain.d.ts +1 -1
- package/dist/storage-control.d.ts +13 -0
- package/dist/storage-control.js +24 -0
- package/dist/wallet-storage-chain.d.ts +1 -1
- package/package.json +9 -1
package/AGENTS.md
CHANGED
|
@@ -42,25 +42,12 @@ once, for an unlock (`nmts help login`).
|
|
|
42
42
|
|
|
43
43
|
## What is built on it today
|
|
44
44
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
- **
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
NMTS key is held by the person (the product cannot read the files) or by the product
|
|
52
|
-
(`Nmts.managed`), and every SDK method works the same either way. The
|
|
53
|
-
[terms](https://nmts.me/terms) provide for it in 3.7, for commercial products too.
|
|
54
|
-
- **An S3 endpoint.** `@needmoretruth/nmts-sdk/gateway` speaks S3 with a bucket and a key pair per
|
|
55
|
-
customer, so django-storages, Rails Active Storage, rclone and backup tools store into NMTS
|
|
56
|
-
unchanged.
|
|
57
|
-
- **Files that come back without NMTS.** [nmts-recovery](https://github.com/needmoretruth/nmts-recovery)
|
|
58
|
-
rebuilds an account's files from Walrus with the NMTS key and the recovery list the person saved.
|
|
59
|
-
No NMTS server takes part.
|
|
60
|
-
|
|
61
|
-
An account pays with credits or straight from its own wallet. From the wallet, WAL goes to Walrus
|
|
62
|
-
and SUI to the chain, and NMTS takes no fee. NMTS does not inspect or certify products built with the SDK: who holds the
|
|
63
|
-
keys in such a product is that product's own statement.
|
|
45
|
+
- **Storage for an agent**, outliving a session or a machine: this command, or [its MCP server](#if-your-client-speaks-mcp).
|
|
46
|
+
- **Encrypted storage inside a product.** The [SDK](https://github.com/needmoretruth/nmts-sdk) (`npm install @needmoretruth/nmts-sdk`; it has an `AGENTS.md` of its own) opens an account per user. The user holds the NMTS key, or the product does (`Nmts.managed`); every method works either way, and [terms](https://nmts.me/terms) 3.7 provides for it, commercially too.
|
|
47
|
+
- **An S3 endpoint**, `@needmoretruth/nmts-sdk/gateway`: a bucket and a key pair per customer, so django-storages, Rails Active Storage, rclone and backup tools store into NMTS unchanged.
|
|
48
|
+
- **Files that come back without NMTS.** [nmts-recovery](https://github.com/needmoretruth/nmts-recovery) rebuilds them from Walrus with the NMTS key and the recovery list the person saved. No NMTS server takes part.
|
|
49
|
+
|
|
50
|
+
All of it works with what is published now. An account pays with credits or from its own wallet — WAL to Walrus, SUI to the chain, no fee to NMTS. NMTS does not inspect or certify products built with the SDK: who holds the keys in one is that product's own statement.
|
|
64
51
|
|
|
65
52
|
## What only a person can do, once
|
|
66
53
|
|
|
@@ -189,12 +176,10 @@ work around it. Setup and the sealed-code rule: `nmts help mcp`.
|
|
|
189
176
|
|
|
190
177
|
## Remembering this across sessions
|
|
191
178
|
|
|
192
|
-
You will lose this context. The person has to put it somewhere your next session reads: the file
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
you have not used*; or, for an MCP client, the server entry. The smallest thing that works is one
|
|
197
|
-
line pointing at this file.
|
|
179
|
+
You will lose this context. The person has to put it somewhere your next session reads: the file your client
|
|
180
|
+
loads from a repository root, holding the first section above and the two environment variable names; a saved
|
|
181
|
+
instruction saying *the `nmts` command manages files in an NMTS account; run `nmts env` first, then `nmts --help`,
|
|
182
|
+
and `nmts help <command>` before a command you have not used*; or, for an MCP client, the server entry. One line pointing at this file is the smallest thing that works.
|
|
198
183
|
|
|
199
184
|
## Reporting a problem or an idea
|
|
200
185
|
|
|
@@ -204,17 +189,11 @@ run at all, write to `nmts@nmts.me` with what you ran and what it said.
|
|
|
204
189
|
|
|
205
190
|
## Exit codes
|
|
206
191
|
|
|
207
|
-
`0` done · `1` something went wrong · `2` the command line was wrong · `3` not signed in ·
|
|
208
|
-
|
|
209
|
-
`130` cancelled.
|
|
192
|
+
`0` done · `1` something went wrong · `2` the command line was wrong · `3` not signed in · `4` the command exists
|
|
193
|
+
but could not do it · `5` waiting on the person's agreement · `130` cancelled.
|
|
210
194
|
|
|
211
|
-
## Licence
|
|
195
|
+
## Licence and source
|
|
212
196
|
|
|
213
197
|
Apache-2.0 (it was AGPL-3.0-only until 2026-08-30). **Calling this program from your own code puts
|
|
214
198
|
no obligation on your code at all.** See [LICENSING.md](https://github.com/needmoretruth/nmts-cli/blob/main/LICENSING.md) if you are asked.
|
|
215
|
-
|
|
216
|
-
## Source
|
|
217
|
-
|
|
218
|
-
<https://github.com/needmoretruth/nmts-cli> · The same operations as a library, for a program that calls
|
|
219
|
-
NMTS from its own code: `npm install @needmoretruth/nmts-sdk` · <https://github.com/needmoretruth/nmts-sdk>
|
|
220
|
-
(its own document for agents is `AGENTS.md` there).
|
|
199
|
+
Source: <https://github.com/needmoretruth/nmts-cli>.
|
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,22 @@
|
|
|
3
3
|
Each version's entry is what changed for the person or the program using `nmts`. The product's own
|
|
4
4
|
update history, which covers the site and the server too, is at https://nmts.me/updates.
|
|
5
5
|
|
|
6
|
+
## 0.40.0 — 2026-09-20
|
|
7
|
+
|
|
8
|
+
- The library surface has a new entry, `@needmoretruth/nmts-cli/storage-control`: what `nmts extend`
|
|
9
|
+
and `nmts wallet storage` (the listing, `split`, `merge`, `transfer`) do, without the prompts or the
|
|
10
|
+
printing. The commands are unchanged: the same reviews, the same `--yes`, the same unlock, the same
|
|
11
|
+
exit codes. `nmts-sdk` 0.6.0 is built on it.
|
|
12
|
+
|
|
13
|
+
## 0.39.0 — 2026-09-20
|
|
14
|
+
|
|
15
|
+
- The library surface has a new entry, `@needmoretruth/nmts-cli/drive-erase`: what `nmts erase` does,
|
|
16
|
+
without the prompt or the printing, so a program can erase files for good with the same order of
|
|
17
|
+
operations. `nmts erase` is unchanged: the same sentence to type, the same output, the same exit
|
|
18
|
+
codes. `nmts-sdk` 0.5.0 is built on it.
|
|
19
|
+
- A delegation token can carry a fifth scope, `files_erase`. The server still asks for the NMTS
|
|
20
|
+
key's own proof on the two erase requests.
|
|
21
|
+
|
|
6
22
|
## 0.38.1 — 2026-09-20
|
|
7
23
|
|
|
8
24
|
- The folder and trash functions behind `@needmoretruth/nmts-cli/drive-edit` no longer import
|
package/README.ko.md
CHANGED
|
@@ -50,7 +50,7 @@ nmts --help
|
|
|
50
50
|
|
|
51
51
|
```sh
|
|
52
52
|
npm install -g github:needmoretruth/nmts-cli # 기본 브랜치
|
|
53
|
-
npm install -g github:needmoretruth/nmts-cli#v0.
|
|
53
|
+
npm install -g github:needmoretruth/nmts-cli#v0.40.0 # 버전을 고정할 때
|
|
54
54
|
npm install -g https://github.com/needmoretruth/nmts-cli/releases/latest/download/nmts.tgz
|
|
55
55
|
```
|
|
56
56
|
|
package/README.md
CHANGED
|
@@ -51,7 +51,7 @@ default branch, from a pinned version, or from the tarball attached to the
|
|
|
51
51
|
|
|
52
52
|
```sh
|
|
53
53
|
npm install -g github:needmoretruth/nmts-cli # the default branch
|
|
54
|
-
npm install -g github:needmoretruth/nmts-cli#v0.
|
|
54
|
+
npm install -g github:needmoretruth/nmts-cli#v0.40.0 # a pinned version
|
|
55
55
|
npm install -g https://github.com/needmoretruth/nmts-cli/releases/latest/download/nmts.tgz
|
|
56
56
|
```
|
|
57
57
|
|
package/dist/artifact-about.d.ts
CHANGED
|
@@ -11,7 +11,7 @@ export declare const CRYPTO_SPEC_URL = "https://github.com/needmoretruth/nmts-re
|
|
|
11
11
|
* is the field's own contract — what the writer says about itself — and a person holding two
|
|
12
12
|
* copies of one account's artefacts can then tell which program made each.
|
|
13
13
|
*/
|
|
14
|
-
export declare const WRITTEN_BY = "nmts-cli 0.
|
|
14
|
+
export declare const WRITTEN_BY = "nmts-cli 0.40.0";
|
|
15
15
|
/** Which of the three artefacts a wrapper is. A reader holding several can sort them. */
|
|
16
16
|
export type ArtifactKind = "recovery-list" | "file-list" | "recovery-kit";
|
|
17
17
|
/**
|
package/dist/commands/erase.js
CHANGED
|
@@ -9,28 +9,21 @@
|
|
|
9
9
|
// with credits, `--release-storage` asks the server to destroy the treasury's storage under
|
|
10
10
|
// each file first, which it does on the chain one blob at a time and reports per file.
|
|
11
11
|
//
|
|
12
|
-
// ⛔ THE
|
|
13
|
-
//
|
|
14
|
-
//
|
|
12
|
+
// ⛔ THE ORDER, AND WHAT IT PROTECTS, ARE IN `drive-erase.ts` — the server first, the list last, a
|
|
13
|
+
// failed release erasing nothing behind it. This file is the terminal over that: the sentence
|
|
14
|
+
// that has to be typed, the lines a person reads, and the exit code. Nothing about what is
|
|
15
|
+
// destroyed is decided twice.
|
|
15
16
|
//
|
|
16
17
|
// ⛔ THE SENTENCE IS TYPED IN EVERY MODE BUT SKIP-PERMISSIONS, where the tier gate's `--reason`
|
|
17
18
|
// and `--yes` stand for it — the same rule as `delete-account`, because it is the same tier.
|
|
18
19
|
import { accountProofFor } from "../account-proof.js";
|
|
19
|
-
import { request, ServerError } from "../api.js";
|
|
20
20
|
import { currentMode } from "../autonomy.js";
|
|
21
21
|
import { CONFIRM_SENTENCE } from "./delete-account.js";
|
|
22
|
-
import {
|
|
22
|
+
import { eraseFiles, planErase } from "../drive-erase.js";
|
|
23
23
|
import { NmtsError } from "../errors.js";
|
|
24
|
-
import { readFileList } from "../manifest.js";
|
|
25
|
-
import { applyToList, batchTargets } from "../manifest-write.js";
|
|
26
24
|
import { BINARY_NAME } from "../product.js";
|
|
27
25
|
import { promptLine, stdinIsATerminal } from "../prompt.js";
|
|
28
26
|
import { openSession } from "../session.js";
|
|
29
|
-
import { filesUnder } from "../drive-edit.js";
|
|
30
|
-
/** The server takes at most this many ids in one erase (`ERASE_BATCH_MAX`). */
|
|
31
|
-
const BATCH = 200;
|
|
32
|
-
/** The refusal a no-deposit release gets when the balance cannot cover the doubled fee. */
|
|
33
|
-
const FEE_INSUFFICIENT = "DEPOSIT_FEE_INSUFFICIENT";
|
|
34
27
|
export async function erase(paths, options = {}) {
|
|
35
28
|
const say = options.write ?? ((line) => process.stdout.write(`${line}\n`));
|
|
36
29
|
if (paths.length === 0) {
|
|
@@ -48,20 +41,11 @@ export async function erase(paths, options = {}) {
|
|
|
48
41
|
});
|
|
49
42
|
}
|
|
50
43
|
const session = await openSession(options);
|
|
51
|
-
const
|
|
52
|
-
const
|
|
53
|
-
const index = buildIndex(entries);
|
|
54
|
-
const targets = batchTargets(entries, paths, { includeTrashed: true, nothingHappened: "Nothing was erased." });
|
|
55
|
-
const files = uniqueById(targets.flatMap((t) => (t.kind === KIND_FILE ? [t] : filesUnder(entries, t.id))));
|
|
56
|
-
const going = uniqueById([...targets, ...files]);
|
|
57
|
-
if (files.length === 0) {
|
|
58
|
-
throw new NmtsError(`Nothing named holds a file; empty folders are removed with \`${BINARY_NAME} rm\`.`, {
|
|
59
|
-
exitCode: 4,
|
|
60
|
-
});
|
|
61
|
-
}
|
|
44
|
+
const plan = await planErase(session, paths);
|
|
45
|
+
const files = plan.files;
|
|
62
46
|
say(`This erases ${files.length} file${files.length === 1 ? "" : "s"} for good. It cannot be undone, and not by the trash.`);
|
|
63
47
|
for (const f of files)
|
|
64
|
-
say(` ${
|
|
48
|
+
say(` ${f.path}`);
|
|
65
49
|
say(``);
|
|
66
50
|
say(` Erased: the server's record of each file and this account's key to it, and its shares.`);
|
|
67
51
|
if (options.releaseStorage === true) {
|
|
@@ -81,65 +65,12 @@ export async function erase(paths, options = {}) {
|
|
|
81
65
|
}
|
|
82
66
|
// ⛔ THE PROOF IS BUILT FOR THIS ONE RUN AND NOTHING KEEPS IT.
|
|
83
67
|
const accountProof = await accountProofFor({ code: session.code, source: session.source });
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
-
for (const f of files) {
|
|
87
|
-
const path = fullPathOf(index, f);
|
|
88
|
-
try {
|
|
89
|
-
const reply = await request(session.server, `/v1/items/${encodeURIComponent(f.id)}/release-storage`, {
|
|
90
|
-
method: "POST",
|
|
91
|
-
body: {},
|
|
92
|
-
token: session.apiKey,
|
|
93
|
-
accountProof,
|
|
94
|
-
});
|
|
95
|
-
releases.push({ path, refused: null, ...counts(reply), ...fee(reply) });
|
|
96
|
-
}
|
|
97
|
-
catch (error) {
|
|
98
|
-
// ⛔ THE ONE REFUSAL THAT STOPS THE RUN. A file with no deposit pays twice the chain fee
|
|
99
|
-
// out of the balance, and a balance that cannot cover it means the release did not
|
|
100
|
-
// happen — erasing behind it would destroy the account's key to bytes that are still
|
|
101
|
-
// being served and still being paid for. Nothing is erased, and the two numbers say
|
|
102
|
-
// exactly how far short the balance is.
|
|
103
|
-
if (error instanceof ServerError && error.code === FEE_INSUFFICIENT) {
|
|
104
|
-
throw new NmtsError(`${path}: releasing its storage costs ${amount(error, "needed_credits")} credits and this ` +
|
|
105
|
-
`account has ${amount(error, "balance_credits")}. It has no deposit, so the fee comes out of the balance.`, {
|
|
106
|
-
exitCode: 4,
|
|
107
|
-
nextStep: `Nothing was erased. Buy credits and run this again, or leave --release-storage off ` +
|
|
108
|
-
`to erase the file and let its storage run out on its own.`,
|
|
109
|
-
});
|
|
110
|
-
}
|
|
111
|
-
// ⚠ "Not ours to destroy" is an answer, not a failure: the storage was bought by the
|
|
112
|
-
// wallet, and the erase goes on. A refusal of the KEY or the proof, and anything the
|
|
113
|
-
// server could not do, stops the run before a row is touched.
|
|
114
|
-
if (error instanceof ServerError && error.status !== 401 && error.status !== 403 && error.status < 500) {
|
|
115
|
-
releases.push({ path, released: 0, alreadyReleased: 0, failed: 0, feeCredits: 0, fromDeposit: false, refused: error.message });
|
|
116
|
-
continue;
|
|
117
|
-
}
|
|
118
|
-
throw error;
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
let erased = 0;
|
|
123
|
-
for (let i = 0; i < files.length; i += BATCH) {
|
|
124
|
-
const reply = await request(session.server, "/v1/items/erase", {
|
|
125
|
-
method: "POST",
|
|
126
|
-
body: { item_ids: files.slice(i, i + BATCH).map((f) => f.id) },
|
|
127
|
-
token: session.apiKey,
|
|
128
|
-
accountProof,
|
|
129
|
-
});
|
|
130
|
-
erased += typeof reply === "object" && reply !== null && typeof Reflect.get(reply, "erased") === "number"
|
|
131
|
-
? Number(Reflect.get(reply, "erased"))
|
|
132
|
-
: 0;
|
|
133
|
-
}
|
|
134
|
-
// ⛔ THE LIST GOES LAST, and re-decided against the list as it is on this attempt: only the
|
|
135
|
-
// ids this run erased leave it, whatever another device wrote in between.
|
|
136
|
-
const ids = new Set(going.map((e) => e.id));
|
|
137
|
-
const result = await applyToList(session, (current) => {
|
|
138
|
-
const still = current.filter((e) => ids.has(e.id)).map((e) => e.id);
|
|
139
|
-
return still.length === 0 ? null : { op: "purge", ids: still };
|
|
68
|
+
const outcome = await eraseFiles({ ...session, accountProof }, plan, {
|
|
69
|
+
releaseStorage: options.releaseStorage === true,
|
|
140
70
|
});
|
|
71
|
+
const { erased, releases } = outcome;
|
|
141
72
|
if (options.json) {
|
|
142
|
-
say(JSON.stringify({ erased, files: files
|
|
73
|
+
say(JSON.stringify({ erased, files: outcome.files, releases, seq: outcome.seq }));
|
|
143
74
|
return 0;
|
|
144
75
|
}
|
|
145
76
|
say(`Erased ${erased} file${erased === 1 ? "" : "s"}. Their entries are out of the file list.`);
|
|
@@ -165,29 +96,3 @@ function feeLine(r) {
|
|
|
165
96
|
? `${credits} taken from that file's deposit — nothing came out of the balance`
|
|
166
97
|
: `${credits} taken from the balance — that file had no deposit, so the fee is doubled`;
|
|
167
98
|
}
|
|
168
|
-
/** The three counts a release answers with, read defensively. */
|
|
169
|
-
function counts(reply) {
|
|
170
|
-
const n = (name) => {
|
|
171
|
-
const v = typeof reply === "object" && reply !== null ? Reflect.get(reply, name) : undefined;
|
|
172
|
-
return typeof v === "number" ? v : 0;
|
|
173
|
-
};
|
|
174
|
-
return { released: n("released"), alreadyReleased: n("already_released"), failed: n("failed") };
|
|
175
|
-
}
|
|
176
|
-
/** What it cost, read the same defensive way. An older server says neither, which reads as 0. */
|
|
177
|
-
function fee(reply) {
|
|
178
|
-
const at = (name) => typeof reply === "object" && reply !== null ? Reflect.get(reply, name) : undefined;
|
|
179
|
-
const charged = at("fee_credits");
|
|
180
|
-
return {
|
|
181
|
-
feeCredits: typeof charged === "number" ? charged : 0,
|
|
182
|
-
fromDeposit: at("from_deposit") === true,
|
|
183
|
-
};
|
|
184
|
-
}
|
|
185
|
-
/** One credit amount out of a refusal's details, or `?` when the server did not name it. */
|
|
186
|
-
function amount(error, field) {
|
|
187
|
-
const value = error.details[field];
|
|
188
|
-
return typeof value === "number" ? String(value) : "?";
|
|
189
|
-
}
|
|
190
|
-
function uniqueById(list) {
|
|
191
|
-
const seen = new Set();
|
|
192
|
-
return list.filter((e) => (seen.has(e.id) ? false : (seen.add(e.id), true)));
|
|
193
|
-
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { ExtendReads, SignExtension } from "../extend-plan.ts";
|
|
2
2
|
import type { StandingTipInput } from "../standing-tip.ts";
|
|
3
3
|
export interface ExtendOptions {
|
|
4
4
|
server?: string | undefined;
|
|
@@ -14,8 +14,8 @@ export interface ExtendOptions {
|
|
|
14
14
|
/**
|
|
15
15
|
* The chain reads.
|
|
16
16
|
*
|
|
17
|
-
* ⚠ A SEAM, NOT AN OPTION — no flag reaches it.
|
|
18
|
-
*
|
|
17
|
+
* ⚠ A SEAM, NOT AN OPTION — no flag reaches it. A test that talked to a live storage network
|
|
18
|
+
* could not run offline and could never be asked to be at its own ceiling.
|
|
19
19
|
*/
|
|
20
20
|
readChain?: (network: string) => Promise<ExtendReads> | ExtendReads;
|
|
21
21
|
/**
|
package/dist/commands/extend.js
CHANGED
|
@@ -6,9 +6,9 @@
|
|
|
6
6
|
// nobody — NMTS included — can reverse it. That difference is said out loud, in the output,
|
|
7
7
|
// before the agreement is asked for.
|
|
8
8
|
//
|
|
9
|
-
// ⛔ IT PRICES BEFORE IT SPENDS, ALWAYS. The reads and the quote are free and happen first
|
|
10
|
-
// `--dry-run` answers with a real number and never reaches the key.
|
|
11
|
-
// run without `requireWalletGrant("extend", …)` having passed.
|
|
9
|
+
// ⛔ IT PRICES BEFORE IT SPENDS, ALWAYS. The reads and the quote are free and happen first
|
|
10
|
+
// (`planExtension`), so `--dry-run` answers with a real number and never reaches the key.
|
|
11
|
+
// Nothing below the quote can run without `requireWalletGrant("extend", …)` having passed.
|
|
12
12
|
//
|
|
13
13
|
// ⛔ THE SERVER DOES NOT EXTEND ANYTHING, and this command is shaped by that. `POST
|
|
14
14
|
// /v1/items/{id}/extended` means "record an extension the device already signed": the storage is
|
|
@@ -19,21 +19,19 @@
|
|
|
19
19
|
// ⛔ AND A FILE THAT IS NOT RUNNING OUT IS NOT EXTENDED BY ACCIDENT. Extending early loses nothing
|
|
20
20
|
// — epochs are added to what is left — so this is not a refusal on principle; it is a refusal
|
|
21
21
|
// to spend money on a deadline nobody is near, unless somebody says so with `--yes`.
|
|
22
|
+
//
|
|
23
|
+
// ⚠ WHAT IS DECIDED HERE AND WHAT IS NOT. The reads, the arithmetic, the price, the balances, the
|
|
24
|
+
// signature and the recording are `storage-control.ts`, which the SDK calls as well; this file is
|
|
25
|
+
// the terminal over them — the sentences, the agreement, the standing gift and the exit code.
|
|
22
26
|
import { recordWalletSpend, requireWalletGrant } from "../wallet-grant.js";
|
|
23
|
-
import { request } from "../api.js";
|
|
24
|
-
import { buildIndex, entryAt, fullPathOf, KIND_FILE, normalisePath } from "../drive-paths.js";
|
|
25
27
|
import { NmtsError } from "../errors.js";
|
|
26
|
-
import { daysLeftInWords
|
|
27
|
-
import {
|
|
28
|
-
import { isRecord } from "../guards.js";
|
|
29
|
-
import { readFileList } from "../manifest.js";
|
|
28
|
+
import { daysLeftInWords } from "../expiry.js";
|
|
29
|
+
import { describeBudget, shortfallNextStep } from "../extend-budget.js";
|
|
30
30
|
import { resolveNetwork } from "../network.js";
|
|
31
31
|
import { BINARY_NAME } from "../product.js";
|
|
32
32
|
import { openSession } from "../session.js";
|
|
33
|
-
import {
|
|
34
|
-
import {
|
|
35
|
-
import { activeWalletOf } from "../shared/lib/drive/manifest-settings.js";
|
|
36
|
-
import { budgetFacts, describeBudget, readBudget, shortfallNextStep } from "../extend-budget.js";
|
|
33
|
+
import { applyExtension, planExtension } from "../storage-control.js";
|
|
34
|
+
import { walletIndexOf } from "../wallet-pay-index.js";
|
|
37
35
|
export async function extend(target, options = {}) {
|
|
38
36
|
const say = options.write ?? ((line) => process.stdout.write(`${line}\n`));
|
|
39
37
|
const now = options.now ?? Date.now();
|
|
@@ -44,129 +42,50 @@ export async function extend(target, options = {}) {
|
|
|
44
42
|
});
|
|
45
43
|
}
|
|
46
44
|
const session = await openSession(options);
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
exitCode: 4,
|
|
60
|
-
nextStep: "That is a folder. Nothing was signed and nothing was charged — storage is bought per " +
|
|
61
|
-
"file, so this takes one file at a time.",
|
|
62
|
-
});
|
|
63
|
-
}
|
|
64
|
-
const path = fullPathOf(buildIndex(entries), entry);
|
|
65
|
-
// ⛔ THE SERVER SAYS WHICH BLOBS, AND NOTHING ELSE. Its `expiry_epoch` is client-reported and
|
|
66
|
-
// advisory; a command that spends money reads the chain's own answer below.
|
|
67
|
-
const preview = asExtendPreview(await request(session.server, `/v1/items/${encodeURIComponent(entry.id)}/extend-preview`, {
|
|
68
|
-
token: session.apiKey,
|
|
69
|
-
}));
|
|
70
|
-
if (preview.targets.length === 0) {
|
|
71
|
-
throw new NmtsError(`Nothing on "${path}" can be extended from here.`, {
|
|
72
|
-
exitCode: 4,
|
|
73
|
-
nextStep: nothingToExtend(preview),
|
|
74
|
-
});
|
|
75
|
-
}
|
|
76
|
-
const reads = await (options.readChain ?? defaultReads)(session.network);
|
|
77
|
-
const window = await reads.readWindow();
|
|
78
|
-
if (window === null) {
|
|
79
|
-
// ⛔ Not "nothing needs extending". The two look identical from outside and mean opposite things.
|
|
80
|
-
throw new NmtsError(`The ${session.network} storage network could not be read.`, {
|
|
81
|
-
exitCode: 1,
|
|
82
|
-
nextStep: `Nothing was signed and nothing was charged. Which epoch the network is in, and how far ` +
|
|
83
|
-
`ahead it will sell, are facts only the chain has — this tool will not spend against a ` +
|
|
84
|
-
`guess. Try again, or name a different Sui node in NMTS_SUI_RPC.`,
|
|
85
|
-
});
|
|
86
|
-
}
|
|
87
|
-
const clock = window.clock;
|
|
88
|
-
const leases = await reads.readLeases(preview.targets.map((t) => t.objectId));
|
|
89
|
-
const endEpoch = soonestEnd(leases);
|
|
90
|
-
if (endEpoch === null) {
|
|
91
|
-
throw new NmtsError(`The chain holds no storage term for "${path}".`, {
|
|
92
|
-
exitCode: 4,
|
|
93
|
-
nextStep: nothingToExtend(preview),
|
|
94
|
-
});
|
|
95
|
-
}
|
|
96
|
-
const stage = stageOf(clock, endEpoch, now);
|
|
97
|
-
if (stage === "lapsed") {
|
|
98
|
-
throw new NmtsError(`The storage term for "${path}" has already ended.`, {
|
|
99
|
-
exitCode: 4,
|
|
100
|
-
nextStep: `Nothing was signed and nothing was charged. A lease is extended before it ends — once it ` +
|
|
45
|
+
// ⛔ A NUMBER THAT IS NOT ONE IS REFUSED BEFORE ANYTHING IS READ. Absent, the account's own number
|
|
46
|
+
// comes out of the sealed list the plan reads — one read, so the address that is priced and
|
|
47
|
+
// the address that signs cannot differ.
|
|
48
|
+
const named = options.wallet === undefined || options.wallet === "" ? undefined : walletIndexOf(options.wallet);
|
|
49
|
+
const plan = await planExtension(session, target, {
|
|
50
|
+
now,
|
|
51
|
+
epochs: options.epochs,
|
|
52
|
+
wallet: named,
|
|
53
|
+
readChain: options.readChain,
|
|
54
|
+
// ⚠ THE LAST SENTENCE IS THIS FILE'S: `nmts get` is a command at a prompt.
|
|
55
|
+
hints: {
|
|
56
|
+
lapsed: `Nothing was signed and nothing was charged. A lease is extended before it ends — once it ` +
|
|
101
57
|
`is over there is no storage object left to extend, and the bytes may already be gone. ` +
|
|
102
58
|
`\`${BINARY_NAME} get\` says whether they can still be read.`,
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
const epochs = chooseEpochs(options.epochs, headroom(leases, clock.current, window.maxAhead));
|
|
106
|
-
const newEndEpoch = endEpoch + epochs;
|
|
107
|
-
const before = daysLeftUntilEpoch(clock, endEpoch, now);
|
|
108
|
-
const after = daysLeftUntilEpoch(clock, newEndEpoch, now);
|
|
109
|
-
// ⛔ A COST THAT COULD NOT BE COMPUTED MUST NOT BECOME A COST OF ZERO. `quote` rejects rather
|
|
110
|
-
// than defaulting, and that rejection stops this run before the agreement is asked for.
|
|
111
|
-
const frost = await reads.quote(leases, epochs);
|
|
112
|
-
const cohort = Math.max(0, ...preview.targets.map((t) => t.sharedItems));
|
|
113
|
-
const unreachable = preview.treasuryParts + preview.untrackedParts;
|
|
114
|
-
// What the wallet holds and what the chain would charge — read, never assumed (`extend-budget.ts`).
|
|
115
|
-
// ⛔ WHICH WALLET PAYS comes out of the list this run already read, before the price is measured
|
|
116
|
-
// against a balance: the address below is the one that will sign (`wallet-pay-index.ts`).
|
|
117
|
-
const wallet = await payingWalletIndex({ ...options, readActiveWallet: async () => activeWalletOf(settings) });
|
|
118
|
-
const address = await walletAddress(session.code, wallet);
|
|
119
|
-
const budget = await readBudget(reads, {
|
|
120
|
-
address,
|
|
121
|
-
objectIds: preview.targets.map((t) => t.objectId),
|
|
122
|
-
epochs,
|
|
123
|
-
priceFrost: frost,
|
|
59
|
+
},
|
|
124
60
|
});
|
|
125
|
-
const facts =
|
|
126
|
-
file: path,
|
|
127
|
-
itemId: entry.id,
|
|
128
|
-
network: session.network,
|
|
129
|
-
epoch: clock.current,
|
|
130
|
-
endEpoch,
|
|
131
|
-
epochs,
|
|
132
|
-
newEndEpoch,
|
|
133
|
-
daysLeft: before,
|
|
134
|
-
daysLeftAfter: after,
|
|
135
|
-
blobs: leases.length,
|
|
136
|
-
priceFrost: frost.toString(),
|
|
137
|
-
priceWal: coinAmount(frost),
|
|
138
|
-
paidFrom: "wallet",
|
|
139
|
-
filesOnTheSameBlobs: cohort,
|
|
140
|
-
partsThatCannotBeExtended: unreachable,
|
|
141
|
-
...budgetFacts(budget),
|
|
142
|
-
};
|
|
61
|
+
const { facts, budget } = plan;
|
|
143
62
|
if (options.dryRun === true) {
|
|
144
|
-
// ⛔ NOTHING BELOW THIS BRANCH RUNS. No key is derived, no agreement is asked for,
|
|
145
|
-
// signing module is not even loaded — `--dry-run` is a price and nothing else.
|
|
63
|
+
// ⛔ NOTHING BELOW THIS BRANCH RUNS. No key is derived for signing, no agreement is asked for,
|
|
64
|
+
// and the signing module is not even loaded — `--dry-run` is a price and nothing else.
|
|
146
65
|
if (options.json) {
|
|
147
66
|
say(JSON.stringify({ ...facts, dryRun: true, signed: false }));
|
|
148
67
|
return 0;
|
|
149
68
|
}
|
|
150
|
-
describe(say, facts
|
|
69
|
+
describe(say, facts);
|
|
151
70
|
describeBudget(say, budget);
|
|
152
71
|
say(``);
|
|
153
72
|
if (budget.shortfall !== null)
|
|
154
73
|
say(` ⚠ ${budget.shortfall} As it stands, it would be refused.`);
|
|
155
74
|
say(` Nothing was signed and nothing was charged. Run the same command without --dry-run to`);
|
|
156
75
|
say(` buy it.`);
|
|
157
|
-
if (stage === "later")
|
|
76
|
+
if (plan.stage === "later")
|
|
158
77
|
say(` It is not near its deadline, so buying it also needs --yes.`);
|
|
159
78
|
return 0;
|
|
160
79
|
}
|
|
161
80
|
if (!options.json) {
|
|
162
|
-
describe(say, facts
|
|
81
|
+
describe(say, facts);
|
|
163
82
|
describeBudget(say, budget);
|
|
164
83
|
}
|
|
165
84
|
// ⛔ ASKED AFTER THE PRICE IS KNOWN AND BEFORE ANYTHING IS SIGNED. Extending early loses nothing,
|
|
166
85
|
// so this is not a refusal on principle — it is a refusal to spend on a deadline that is not
|
|
167
86
|
// close, unless somebody says otherwise out loud.
|
|
168
|
-
if (stage === "later" && options.yes !== true) {
|
|
169
|
-
throw new NmtsError(`"${path}" is not near the end of its storage term.`, {
|
|
87
|
+
if (plan.stage === "later" && options.yes !== true) {
|
|
88
|
+
throw new NmtsError(`"${plan.path}" is not near the end of its storage term.`, {
|
|
170
89
|
exitCode: 4,
|
|
171
90
|
nextStep: `Nothing was signed and nothing was charged. Extending early loses nothing — the epochs ` +
|
|
172
91
|
`are added to what is left — but it spends now for time this file does not need yet. ` +
|
|
@@ -181,49 +100,37 @@ export async function extend(target, options = {}) {
|
|
|
181
100
|
// ⛔ THE ONE GATE THAT STANDS BETWEEN A PROGRAM AND SOMEBODY'S WALLET. Everything above this line
|
|
182
101
|
// is a read; nothing below it can be undone. The grant names a scope, runs out, and may carry
|
|
183
102
|
// a ceiling — this signature is held against all three (`wallet-grant.ts`).
|
|
184
|
-
const spend = { walFrost:
|
|
103
|
+
const spend = { walFrost: budget.priceFrost, suiMist: budget.feeMist ?? 0n };
|
|
185
104
|
requireWalletGrant("extend", spend, new Date(now));
|
|
186
|
-
const
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
objectIds: preview.targets.map((t) => t.objectId),
|
|
192
|
-
epochs,
|
|
105
|
+
const outcome = await applyExtension(session, plan, {
|
|
106
|
+
sign: options.sign,
|
|
107
|
+
// What left the wallet is added to the grant's ledger first — the fee as estimated, since the
|
|
108
|
+
// amount actually charged is not read back here.
|
|
109
|
+
onSigned: () => recordWalletSpend(spend),
|
|
193
110
|
});
|
|
194
|
-
|
|
195
|
-
// amount actually charged is not read back here.
|
|
196
|
-
recordWalletSpend(spend);
|
|
197
|
-
// From here the storage IS extended. Recording it is bookkeeping, and a failure to record must
|
|
198
|
-
// never be reported as a failure to extend — that reading invites a second run, which pays again.
|
|
199
|
-
let replay = false;
|
|
200
|
-
try {
|
|
201
|
-
const recorded = await request(session.server, `/v1/items/${encodeURIComponent(entry.id)}/extended`, { method: "POST", token: session.apiKey, body: { epochs, tx_digest: digest } });
|
|
202
|
-
replay = isRecord(recorded) && recorded["replay"] === true;
|
|
203
|
-
}
|
|
204
|
-
catch (error) {
|
|
111
|
+
if (!outcome.recorded) {
|
|
205
112
|
if (options.json) {
|
|
206
|
-
say(JSON.stringify({ ...facts, dryRun: false, signed: true, digest, recorded: false }));
|
|
113
|
+
say(JSON.stringify({ ...facts, dryRun: false, signed: true, digest: outcome.digest, recorded: false }));
|
|
207
114
|
}
|
|
208
115
|
else {
|
|
209
116
|
say(``);
|
|
210
|
-
say(` The storage IS extended and the payment has been made — transaction ${digest}.`);
|
|
117
|
+
say(` The storage IS extended and the payment has been made — transaction ${outcome.digest}.`);
|
|
211
118
|
say(` What failed is telling the NMTS server about it, so the drive will go on showing the`);
|
|
212
119
|
say(` old date until something tells it. ⛔ Do not run this command again for this file:`);
|
|
213
120
|
say(` that would buy the same epochs a second time. Opening the account in a browser reads`);
|
|
214
121
|
say(` the chain directly.`);
|
|
215
|
-
say(` Cause: ${
|
|
122
|
+
say(` Cause: ${outcome.notRecorded}`);
|
|
216
123
|
}
|
|
217
124
|
return 1;
|
|
218
125
|
}
|
|
219
126
|
if (options.json) {
|
|
220
|
-
say(JSON.stringify({ ...facts, dryRun: false, signed: true, digest, recorded: true, replay }));
|
|
127
|
+
say(JSON.stringify({ ...facts, dryRun: false, signed: true, digest: outcome.digest, recorded: true, replay: outcome.replay }));
|
|
221
128
|
}
|
|
222
129
|
else {
|
|
223
130
|
say(``);
|
|
224
|
-
say(` Extended. The storage now ends at epoch ${newEndEpoch} — ${daysLeftInWords(
|
|
225
|
-
say(` Transaction ${digest}`);
|
|
226
|
-
if (replay)
|
|
131
|
+
say(` Extended. The storage now ends at epoch ${facts.newEndEpoch} — ${daysLeftInWords(facts.daysLeftAfter)}.`);
|
|
132
|
+
say(` Transaction ${outcome.digest}`);
|
|
133
|
+
if (outcome.replay)
|
|
227
134
|
say(` The server had already recorded this transaction, so nothing was written twice.`);
|
|
228
135
|
}
|
|
229
136
|
// ⛔ AFTER THE PAYMENT, NEVER INSIDE IT, and it cannot change the answer above. In --json the
|
|
@@ -232,17 +139,19 @@ export async function extend(target, options = {}) {
|
|
|
232
139
|
server: session.server,
|
|
233
140
|
network: resolveNetwork(session.server, session.network),
|
|
234
141
|
code: session.code,
|
|
235
|
-
settings,
|
|
236
|
-
wallet,
|
|
237
|
-
paidWalFrost:
|
|
142
|
+
settings: plan.settings,
|
|
143
|
+
wallet: plan.wallet,
|
|
144
|
+
paidWalFrost: budget.priceFrost,
|
|
238
145
|
say: options.json === true ? (line) => void process.stderr.write(`${line}\n`) : say,
|
|
239
146
|
...(options.tip ?? {}),
|
|
240
147
|
});
|
|
241
148
|
return 0;
|
|
242
149
|
}
|
|
243
150
|
/** What the numbers say, for a person, in the order somebody deciding needs them. */
|
|
244
|
-
function describe(say, facts
|
|
151
|
+
function describe(say, facts) {
|
|
245
152
|
const when = (epoch, left) => `epoch ${epoch} — ${daysLeftInWords(left)}`;
|
|
153
|
+
const cohort = facts.filesOnTheSameBlobs;
|
|
154
|
+
const unreachable = facts.partsThatCannotBeExtended;
|
|
246
155
|
say(`${facts.file}`);
|
|
247
156
|
say(` Storage ends at ${when(facts.endEpoch, facts.daysLeft)}.`);
|
|
248
157
|
say(` Extending by ${facts.epochs} epoch${facts.epochs === 1 ? "" : "s"} moves that to ` +
|
|
@@ -262,21 +171,3 @@ function describe(say, facts, cohort, unreachable) {
|
|
|
262
171
|
say(` This is paid in WAL from the wallet this NMTS key derives — not from credits, which`);
|
|
263
172
|
say(` is what every other command in this tool spends. \`${BINARY_NAME} wallet\` shows what is in it.`);
|
|
264
173
|
}
|
|
265
|
-
/** Why a file has nothing to extend, said as the two different things it can be. */
|
|
266
|
-
function nothingToExtend(preview) {
|
|
267
|
-
const parts = [];
|
|
268
|
-
if (preview.treasuryParts > 0) {
|
|
269
|
-
parts.push(`${preview.treasuryParts} part${preview.treasuryParts === 1 ? " is" : "s are"} on storage NMTS ` +
|
|
270
|
-
`paid for, which this account cannot extend`);
|
|
271
|
-
}
|
|
272
|
-
if (preview.untrackedParts > 0) {
|
|
273
|
-
parts.push(`${preview.untrackedParts} part${preview.untrackedParts === 1 ? " has" : "s have"} no ` +
|
|
274
|
-
`recorded storage object, so there is nothing to name on the chain`);
|
|
275
|
-
}
|
|
276
|
-
const why = parts.length === 0 ? "The server lists no storage object for it." : `${parts.join(", and ")}.`;
|
|
277
|
-
return `Nothing was signed and nothing was charged. ${why} Opening the account in a browser shows what it is stored on.`;
|
|
278
|
-
}
|
|
279
|
-
/** The real chain reads. Imported only when no seam was supplied — it loads the storage SDK. */
|
|
280
|
-
async function defaultReads(network) {
|
|
281
|
-
return (await import("../extend-chain.js")).extendReads(network);
|
|
282
|
-
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { type Network } from "../network.ts";
|
|
2
|
-
import { type StorageOpsReads } from "../storage-control
|
|
2
|
+
import { type StorageOp, type StorageOpsReads } from "../storage-control.ts";
|
|
3
3
|
import type { SignStorageOp } from "../wallet-sign.ts";
|
|
4
|
-
export type StorageOp
|
|
4
|
+
export type { StorageOp } from "../storage-control.ts";
|
|
5
5
|
export interface StorageOpsOptions {
|
|
6
6
|
server?: string | undefined;
|
|
7
7
|
network?: string | undefined;
|