@needmoretruth/nmts-cli 0.39.0 → 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/CHANGELOG.md CHANGED
@@ -3,6 +3,13 @@
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
+
6
13
  ## 0.39.0 — 2026-09-20
7
14
 
8
15
  - The library surface has a new entry, `@needmoretruth/nmts-cli/drive-erase`: what `nmts erase` does,
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.39.0 # 버전을 고정할 때
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.39.0 # 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.39.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
  /**
@@ -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;