@needmoretruth/nmts-cli 0.38.1 → 0.39.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,15 @@
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.39.0 — 2026-09-20
7
+
8
+ - The library surface has a new entry, `@needmoretruth/nmts-cli/drive-erase`: what `nmts erase` does,
9
+ without the prompt or the printing, so a program can erase files for good with the same order of
10
+ operations. `nmts erase` is unchanged: the same sentence to type, the same output, the same exit
11
+ codes. `nmts-sdk` 0.5.0 is built on it.
12
+ - A delegation token can carry a fifth scope, `files_erase`. The server still asks for the NMTS
13
+ key's own proof on the two erase requests.
14
+
6
15
  ## 0.38.1 — 2026-09-20
7
16
 
8
17
  - 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.39.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.39.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.39.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
- }
@@ -0,0 +1,83 @@
1
+ import { type ListEditInput } from "./manifest-write.ts";
2
+ /** What the erasing underneath takes: where to talk, what opens the list, and whose list it is. */
3
+ export interface EraseInput extends ListEditInput {
4
+ /**
5
+ * The account code's own proof for this one run, base64url — what the two permanent doors ask
6
+ * for beside the credential (`x-nmts-account-proof`).
7
+ *
8
+ * ⛔ BUILT BY THE CALLER AND KEPT BY NOBODY. It is not the NMTS key and opens no file; what it
9
+ * proves is possession of the code, which is exactly the question these two doors ask.
10
+ */
11
+ accountProof: string;
12
+ }
13
+ export interface EraseOptions {
14
+ /** Also destroy the treasury's storage under credit-paid files, before erasing them. */
15
+ releaseStorage?: boolean;
16
+ }
17
+ /** One thing this run acts on: its id, and where it sits in the list as it was read. */
18
+ export interface ErasePath {
19
+ id: string;
20
+ path: string;
21
+ }
22
+ /** What one run will destroy, worked out before anything is sent. */
23
+ export interface ErasePlan {
24
+ /** Every FILE going: the ones named, and every file under a folder that was named. */
25
+ readonly files: readonly ErasePath[];
26
+ /**
27
+ * The ids leaving the sealed list — the files above and the folders that were named.
28
+ *
29
+ * ⚠ WIDER THAN `files` ON PURPOSE. A named folder has no server row of its own, so nothing is
30
+ * erased for it; its entry still has to go, or the list keeps a folder whose contents are gone.
31
+ */
32
+ readonly going: readonly string[];
33
+ }
34
+ /** What one file's storage release came back with. */
35
+ export interface StorageRelease {
36
+ path: string;
37
+ released: number;
38
+ alreadyReleased: number;
39
+ failed: number;
40
+ /**
41
+ * Credits the release actually cost, and where they came from.
42
+ *
43
+ * ⛔ TWO FIELDS, NOT ONE, because "it cost nothing" and "it cost nothing OUT OF THE BALANCE" are
44
+ * different answers and only one of them is true. A release paid out of the file's own
45
+ * deposit charges the balance nothing; a file with no deposit pays twice the fee from the
46
+ * balance, and somebody watching their credits needs to be able to tell which happened.
47
+ */
48
+ feeCredits: number;
49
+ fromDeposit: boolean;
50
+ /** The server's typed refusal when the storage was not the treasury's to destroy. */
51
+ refused: string | null;
52
+ }
53
+ /** What one run did. */
54
+ export interface EraseOutcome {
55
+ /** How many server rows went. Lower than what was asked for when one was already gone. */
56
+ erased: number;
57
+ /** The files it acted on, with the paths they had. */
58
+ files: ErasePath[];
59
+ /** One per file whose storage was asked about — empty unless `releaseStorage` was asked for. */
60
+ releases: StorageRelease[];
61
+ /** The version of the sealed list after the entries left it. */
62
+ seq: number;
63
+ }
64
+ /**
65
+ * Work out what erasing these paths would destroy, without destroying anything.
66
+ *
67
+ * ⛔ ITS OWN STEP BECAUSE ONE CALLER HAS TO SHOW THE LIST BEFORE IT ASKS. `nmts erase` prints every
68
+ * file it is about to destroy and then waits for a typed sentence; folding this into the act
69
+ * would leave the terminal with nothing to print, and reading the list twice would leave the
70
+ * two reads free to disagree about what is in it.
71
+ */
72
+ export declare function planErase(input: ListEditInput, paths: readonly string[]): Promise<ErasePlan>;
73
+ /**
74
+ * Destroy what the plan names. ⛔ Irreversible, and nothing below asks whether the caller meant it.
75
+ */
76
+ export declare function eraseFiles(input: EraseInput, plan: ErasePlan, options?: EraseOptions): Promise<EraseOutcome>;
77
+ /**
78
+ * Resolve these paths and destroy what they name, in one call.
79
+ *
80
+ * ⚠ FOR A CALLER THAT HAS ALREADY DECIDED. Anything that shows a person what is about to go should
81
+ * use `planErase` first, so what it shows is what it then destroys.
82
+ */
83
+ export declare function erasePaths(input: EraseInput, paths: readonly string[], options?: EraseOptions): Promise<EraseOutcome>;
@@ -0,0 +1,161 @@
1
+ // Erasing files for good, without a terminal: the targets resolved, the treasury's storage let go,
2
+ // the server's records destroyed and the entries taken out of the sealed list — decided, done and
3
+ // handed back rather than printed.
4
+ //
5
+ // ⛔ ONE IMPLEMENTATION, TWO CALLERS, WHICH IS THE WHOLE REASON THIS FILE EXISTS — the argument
6
+ // `drive-edit.ts` makes one verb family over. `commands/erase.ts` is a terminal's shape: it
7
+ // prints the list, asks for the typed sentence and answers an exit code. The SDK is somebody
8
+ // else's program and needs the same act with none of those. A second implementation of "what
9
+ // order do these three destructions happen in" would be a second place for the rules below to
10
+ // be got right, and the copy nobody re-reads is the one that quietly disagrees — about the one
11
+ // verb in this package that nothing can undo.
12
+ //
13
+ // ⛔ NOTHING HERE WRITES TO A STREAM, PICKS AN EXIT CODE OR ASKS ANYBODY ANYTHING. The confirmation
14
+ // is the caller's: a terminal types a sentence, a program passes one. This file is reached only
15
+ // after somebody decided, and it destroys what it is given.
16
+ //
17
+ // ⛔ THE SERVER GOES FIRST AND THE LIST LAST, which is the order the trash keeps and for the same
18
+ // reason: a row erased before its list entry leaves a file the person can see and never open.
19
+ // A release that FAILS stops the run before a single row is touched — nothing is erased behind
20
+ // one. A release the server REFUSES ("that storage was bought by the wallet, not by credits")
21
+ // is an answer rather than a failure: it is reported on that file and the erase goes on.
22
+ //
23
+ // ⛔ THE PROOF IS THE CALLER'S TO BUILD AND IS NOT DERIVED HERE. Both doors ask for the account
24
+ // code's own proof, and WHETHER this run may make one is a policy question with two different
25
+ // answers — the command line asks for an agreement first (`account-proof.ts`), a library caller
26
+ // has already made that decision by calling. So it arrives as a value. Nothing here reaches for
27
+ // `node:`: the SDK's browser entry bundles what this exports.
28
+ //
29
+ // ⚠ THE PIECES IT BORROWS COME FROM `drive-edit/` DIRECTLY, the way that folder's own files borrow
30
+ // from each other. This is a sibling of that family rather than an outside caller.
31
+ import { request, ServerError } from "./api.js";
32
+ import { DriveEditError, resolving } from "./drive-edit/errors.js";
33
+ import { filesUnder, uniqueById } from "./drive-edit/tree.js";
34
+ import { buildIndex, fullPathOf, KIND_FILE } from "./drive-paths.js";
35
+ import { NmtsError } from "./errors.js";
36
+ import { readFileList } from "./manifest.js";
37
+ import { applyToList, batchTargets } from "./manifest-write.js";
38
+ import { BINARY_NAME } from "./product.js";
39
+ /** The server takes at most this many ids in one erase (`ERASE_BATCH_MAX`). */
40
+ const BATCH = 200;
41
+ /** The refusal a no-deposit release gets when the balance cannot cover the doubled fee. */
42
+ const FEE_INSUFFICIENT = "DEPOSIT_FEE_INSUFFICIENT";
43
+ /**
44
+ * Work out what erasing these paths would destroy, without destroying anything.
45
+ *
46
+ * ⛔ ITS OWN STEP BECAUSE ONE CALLER HAS TO SHOW THE LIST BEFORE IT ASKS. `nmts erase` prints every
47
+ * file it is about to destroy and then waits for a typed sentence; folding this into the act
48
+ * would leave the terminal with nothing to print, and reading the list twice would leave the
49
+ * two reads free to disagree about what is in it.
50
+ */
51
+ export async function planErase(input, paths) {
52
+ const list = await readFileList(input.server, input.apiKey, input.code, input.accountId);
53
+ const entries = list.manifest?.entries ?? [];
54
+ const index = buildIndex(entries);
55
+ const targets = resolving(() => batchTargets(entries, paths, { includeTrashed: true, nothingHappened: "Nothing was erased." }));
56
+ const files = uniqueById(targets.flatMap((t) => (t.kind === KIND_FILE ? [t] : filesUnder(entries, t.id))));
57
+ const going = uniqueById([...targets, ...files]);
58
+ if (files.length === 0) {
59
+ throw new DriveEditError("NOT_FOUND", `Nothing named holds a file; empty folders are removed with \`${BINARY_NAME} rm\`.`, {
60
+ exitCode: 4,
61
+ });
62
+ }
63
+ return {
64
+ files: files.map((f) => ({ id: f.id, path: fullPathOf(index, f) })),
65
+ going: going.map((e) => e.id),
66
+ };
67
+ }
68
+ /**
69
+ * Destroy what the plan names. ⛔ Irreversible, and nothing below asks whether the caller meant it.
70
+ */
71
+ export async function eraseFiles(input, plan, options = {}) {
72
+ const releases = [];
73
+ if (options.releaseStorage === true) {
74
+ for (const f of plan.files) {
75
+ const path = f.path;
76
+ try {
77
+ const reply = await request(input.server, `/v1/items/${encodeURIComponent(f.id)}/release-storage`, {
78
+ method: "POST",
79
+ body: {},
80
+ token: input.apiKey,
81
+ accountProof: input.accountProof,
82
+ });
83
+ releases.push({ path, refused: null, ...counts(reply), ...fee(reply) });
84
+ }
85
+ catch (error) {
86
+ // ⛔ THE ONE REFUSAL THAT STOPS THE RUN. A file with no deposit pays twice the chain fee
87
+ // out of the balance, and a balance that cannot cover it means the release did not
88
+ // happen — erasing behind it would destroy the account's key to bytes that are still
89
+ // being served and still being paid for. Nothing is erased, and the two numbers say
90
+ // exactly how far short the balance is.
91
+ if (error instanceof ServerError && error.code === FEE_INSUFFICIENT) {
92
+ throw new NmtsError(`${path}: releasing its storage costs ${amount(error, "needed_credits")} credits and this ` +
93
+ `account has ${amount(error, "balance_credits")}. It has no deposit, so the fee comes out of the balance.`, {
94
+ exitCode: 4,
95
+ nextStep: `Nothing was erased. Buy credits and run this again, or leave --release-storage off ` +
96
+ `to erase the file and let its storage run out on its own.`,
97
+ });
98
+ }
99
+ // ⚠ "Not ours to destroy" is an answer, not a failure: the storage was bought by the
100
+ // wallet, and the erase goes on. A refusal of the KEY or the proof, and anything the
101
+ // server could not do, stops the run before a row is touched.
102
+ if (error instanceof ServerError && error.status !== 401 && error.status !== 403 && error.status < 500) {
103
+ releases.push({ path, released: 0, alreadyReleased: 0, failed: 0, feeCredits: 0, fromDeposit: false, refused: error.message });
104
+ continue;
105
+ }
106
+ throw error;
107
+ }
108
+ }
109
+ }
110
+ let erased = 0;
111
+ for (let i = 0; i < plan.files.length; i += BATCH) {
112
+ const reply = await request(input.server, "/v1/items/erase", {
113
+ method: "POST",
114
+ body: { item_ids: plan.files.slice(i, i + BATCH).map((f) => f.id) },
115
+ token: input.apiKey,
116
+ accountProof: input.accountProof,
117
+ });
118
+ erased += typeof reply === "object" && reply !== null && typeof Reflect.get(reply, "erased") === "number"
119
+ ? Number(Reflect.get(reply, "erased"))
120
+ : 0;
121
+ }
122
+ // ⛔ THE LIST GOES LAST, and re-decided against the list as it is on this attempt: only the
123
+ // ids this run erased leave it, whatever another device wrote in between.
124
+ const ids = new Set(plan.going);
125
+ const result = await applyToList(input, (current) => {
126
+ const still = current.filter((e) => ids.has(e.id)).map((e) => e.id);
127
+ return still.length === 0 ? null : { op: "purge", ids: still };
128
+ });
129
+ return { erased, files: [...plan.files], releases, seq: result.seq };
130
+ }
131
+ /**
132
+ * Resolve these paths and destroy what they name, in one call.
133
+ *
134
+ * ⚠ FOR A CALLER THAT HAS ALREADY DECIDED. Anything that shows a person what is about to go should
135
+ * use `planErase` first, so what it shows is what it then destroys.
136
+ */
137
+ export async function erasePaths(input, paths, options = {}) {
138
+ return eraseFiles(input, await planErase(input, paths), options);
139
+ }
140
+ /** The three counts a release answers with, read defensively. */
141
+ function counts(reply) {
142
+ const n = (name) => {
143
+ const v = typeof reply === "object" && reply !== null ? Reflect.get(reply, name) : undefined;
144
+ return typeof v === "number" ? v : 0;
145
+ };
146
+ return { released: n("released"), alreadyReleased: n("already_released"), failed: n("failed") };
147
+ }
148
+ /** What it cost, read the same defensive way. An older server says neither, which reads as 0. */
149
+ function fee(reply) {
150
+ const at = (name) => typeof reply === "object" && reply !== null ? Reflect.get(reply, name) : undefined;
151
+ const charged = at("fee_credits");
152
+ return {
153
+ feeCredits: typeof charged === "number" ? charged : 0,
154
+ fromDeposit: at("from_deposit") === true,
155
+ };
156
+ }
157
+ /** One credit amount out of a refusal's details, or `?` when the server did not name it. */
158
+ function amount(error, field) {
159
+ const value = error.details[field];
160
+ return typeof value === "number" ? String(value) : "?";
161
+ }
@@ -25,14 +25,22 @@ export declare const DELEGATION_MAX_TTL_SECS = 2592000;
25
25
  * ⛔ THE FIRST THREE ARE AN API KEY'S OWN SCOPES, value for value. One vocabulary, so that a
26
26
  * business asking for "read and write" asks for the number a person's key already means by it.
27
27
  * `register` is the fourth and opens exactly one door: making the account the token names.
28
+ *
29
+ * ⛔ `files_erase` IS THE FIFTH, AND AN API KEY HAS NO BIT OF THAT NAME. It opens the two acts
30
+ * nothing undoes: erasing a file's record and this account's key to it, and destroying the
31
+ * treasury's storage under a file. Its own name rather than `files_write`, so a token minted so
32
+ * an app can upload cannot destroy — and it does not stand alone: both doors also ask for the
33
+ * account code's own proof on the same request, which is an act only whoever holds the code can
34
+ * perform. The token is the business's permission; the proof is the key holder's.
28
35
  */
29
36
  export declare const SCOPE_BITS: {
30
37
  readonly files_read: 1;
31
38
  readonly files_write: 2;
32
39
  readonly storage_spend: 4;
33
40
  readonly register: 8;
41
+ readonly files_erase: 16;
34
42
  };
35
- /** One of the four names above. */
43
+ /** One of the five names above. */
36
44
  export type ScopeName = keyof typeof SCOPE_BITS;
37
45
  /** Every bit a token may carry. */
38
46
  export declare const SCOPE_ALL: number;
@@ -53,15 +53,23 @@ export const DELEGATION_MAX_TTL_SECS = 2_592_000;
53
53
  * ⛔ THE FIRST THREE ARE AN API KEY'S OWN SCOPES, value for value. One vocabulary, so that a
54
54
  * business asking for "read and write" asks for the number a person's key already means by it.
55
55
  * `register` is the fourth and opens exactly one door: making the account the token names.
56
+ *
57
+ * ⛔ `files_erase` IS THE FIFTH, AND AN API KEY HAS NO BIT OF THAT NAME. It opens the two acts
58
+ * nothing undoes: erasing a file's record and this account's key to it, and destroying the
59
+ * treasury's storage under a file. Its own name rather than `files_write`, so a token minted so
60
+ * an app can upload cannot destroy — and it does not stand alone: both doors also ask for the
61
+ * account code's own proof on the same request, which is an act only whoever holds the code can
62
+ * perform. The token is the business's permission; the proof is the key holder's.
56
63
  */
57
64
  export const SCOPE_BITS = {
58
65
  files_read: 1,
59
66
  files_write: 2,
60
67
  storage_spend: 4,
61
68
  register: 8,
69
+ files_erase: 16,
62
70
  };
63
71
  /** Every bit a token may carry. */
64
- export const SCOPE_ALL = SCOPE_BITS.files_read | SCOPE_BITS.files_write | SCOPE_BITS.storage_spend | SCOPE_BITS.register;
72
+ export const SCOPE_ALL = SCOPE_BITS.files_read | SCOPE_BITS.files_write | SCOPE_BITS.storage_spend | SCOPE_BITS.register | SCOPE_BITS.files_erase;
65
73
  /**
66
74
  * A new key pair, from the runtime's own random source.
67
75
  *
package/dist/product.d.ts CHANGED
@@ -9,7 +9,7 @@ export declare const BINARY_NAME = "nmts";
9
9
  * beside it. Here rather than in `main.ts` because the MCP server has to say it too, and a
10
10
  * command importing the entry point is a cycle waiting to bite.
11
11
  */
12
- export declare const VERSION = "0.38.1";
12
+ export declare const VERSION = "0.39.0";
13
13
  /** Where the product lives, for messages that need to send somebody somewhere real. */
14
14
  export declare const HOME_URL = "https://nmts.me";
15
15
  /** The source, so a person holding only the built program can find what it was built from. */
package/dist/product.js CHANGED
@@ -18,7 +18,7 @@ export const BINARY_NAME = "nmts";
18
18
  * beside it. Here rather than in `main.ts` because the MCP server has to say it too, and a
19
19
  * command importing the entry point is a cycle waiting to bite.
20
20
  */
21
- export const VERSION = "0.38.1";
21
+ export const VERSION = "0.39.0";
22
22
  /** Where the product lives, for messages that need to send somebody somewhere real. */
23
23
  export const HOME_URL = "https://nmts.me";
24
24
  /** The source, so a person holding only the built program can find what it was built from. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@needmoretruth/nmts-cli",
3
- "version": "0.38.1",
3
+ "version": "0.39.0",
4
4
  "description": "Command-line and MCP access to NMTS (NeedMoreTruthStorage, https://nmts.me): open-source, end-to-end encrypted storage on the Walrus network. No fee to NMTS; you pay the network from your own wallet. For people and for their agents.",
5
5
  "license": "Apache-2.0",
6
6
  "publishConfig": {
@@ -81,6 +81,10 @@
81
81
  "types": "./dist/drive-edit.d.ts",
82
82
  "default": "./dist/drive-edit.js"
83
83
  },
84
+ "./drive-erase": {
85
+ "types": "./dist/drive-erase.d.ts",
86
+ "default": "./dist/drive-erase.js"
87
+ },
84
88
  "./s3-gateway": {
85
89
  "types": "./dist/s3-gateway.d.ts",
86
90
  "default": "./dist/s3-gateway.js"