@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 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
- Each of these works with what is published now. None of it is a plan.
46
-
47
- - **Storage for an agent.** Notes, datasets and build outputs that outlive a session or a machine,
48
- encrypted before they leave it. This command does it, and so does [its MCP server](#if-your-client-speaks-mcp).
49
- - **Encrypted storage inside someone's product.** The [SDK](https://github.com/needmoretruth/nmts-sdk)
50
- opens an NMTS account for each person who uses a product and keeps that person's files in it. The
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
- your client loads from a repository root, holding the first section above and the two
194
- environment variable names; a saved instruction saying *the `nmts` command manages files in an
195
- NMTS account; run `nmts env` first, then `nmts --help`, and `nmts help <command>` before a command
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
- `4` the command exists but could not do it · `5` waiting on the person's agreement ·
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.38.1 # 버전을 고정할 때
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.38.1 # a pinned version
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
 
@@ -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.38.1";
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
  /**
@@ -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 SERVER GOES FIRST, THE LIST LAST. A row erased before the list entry leaves a file the
13
- // person can see and never open; that is the same order the trash keeps, for the same reason.
14
- // A release that fails leaves the file whole nothing is erased behind a failed release.
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 { buildIndex, fullPathOf, KIND_FILE } from "../drive-paths.js";
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 list = await readFileList(session.server, session.apiKey, session.code, session.accountId);
52
- const entries = list.manifest?.entries ?? [];
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(` ${fullPathOf(index, f)}`);
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 releases = [];
85
- if (options.releaseStorage === true) {
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.map((f) => ({ id: f.id, path: fullPathOf(index, f) })), releases, seq: result.seq }));
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 { type ExtendReads, type SignExtension } from "../extend-plan.ts";
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. See `ExtendReads`: a test that talked to a live
18
- * storage network could not run offline and could never be asked to be at its own ceiling.
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
  /**
@@ -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, so
10
- // `--dry-run` answers with a real number and never reaches the key. Nothing below the quote can
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, daysLeftUntilEpoch, stageOf, } from "../expiry.js";
27
- import { asExtendPreview, chooseEpochs, headroom, soonestEnd, } from "../extend-plan.js";
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 { coinAmount, walletAddress } from "../wallet.js";
34
- import { payingWalletIndex } from "../wallet-pay-index.js";
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
- const list = await readFileList(session.server, session.apiKey, session.code, session.accountId);
48
- if (list.manifest === null) {
49
- throw new NmtsError("This account has no file list, so there is nothing to extend.", { exitCode: 4 });
50
- }
51
- const entries = list.manifest.entries;
52
- // The standing share is the person's, and it is read from the same sealed list this file came from.
53
- const settings = list.manifest.settings;
54
- const entry = entryAt(entries, normalisePath(target), {
55
- nothingHappened: "Nothing was signed and nothing was charged.",
56
- });
57
- if (entry.kind !== KIND_FILE) {
58
- throw new NmtsError(`No file at "${fullPathOf(buildIndex(entries), entry)}".`, {
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, and the
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, cohort, unreachable);
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, cohort, unreachable);
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: frost, suiMist: budget.feeMist ?? 0n };
103
+ const spend = { walFrost: budget.priceFrost, suiMist: budget.feeMist ?? 0n };
185
104
  requireWalletGrant("extend", spend, new Date(now));
186
- const sign = options.sign ?? (await import("../wallet-sign.js")).signExtension;
187
- const digest = await sign({
188
- network: session.network,
189
- code: session.code,
190
- wallet,
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
- // What left the wallet is added to the grant's ledger first — the fee as estimated, since the
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: ${error instanceof Error ? error.message : String(error)}`);
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(after)}.`);
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: frost,
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, cohort, unreachable) {
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-chain.ts";
2
+ import { type StorageOp, type StorageOpsReads } from "../storage-control.ts";
3
3
  import type { SignStorageOp } from "../wallet-sign.ts";
4
- export type StorageOp = "split" | "merge" | "transfer";
4
+ export type { StorageOp } from "../storage-control.ts";
5
5
  export interface StorageOpsOptions {
6
6
  server?: string | undefined;
7
7
  network?: string | undefined;