@needmoretruth/nmts-cli 0.38.0 → 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
@@ -40,6 +40,15 @@ for the NMTS key: it asks nothing and works in a container. The NMTS key from `N
40
40
  readable through `docker inspect`, `/proc/<pid>/environ` and most CI logs, so that shape stops
41
41
  once, for an unlock (`nmts help login`).
42
42
 
43
+ ## What is built on it today
44
+
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.
51
+
43
52
  ## What only a person can do, once
44
53
 
45
54
  Four things need a person, all at the beginning. If you are being set up, hand this list back in full at once.
@@ -167,12 +176,10 @@ work around it. Setup and the sealed-code rule: `nmts help mcp`.
167
176
 
168
177
  ## Remembering this across sessions
169
178
 
170
- You will lose this context. The person has to put it somewhere your next session reads: the file
171
- your client loads from a repository root, holding the first section above and the two
172
- environment variable names; a saved instruction saying *the `nmts` command manages files in an
173
- NMTS account; run `nmts env` first, then `nmts --help`, and `nmts help <command>` before a command
174
- you have not used*; or, for an MCP client, the server entry. The smallest thing that works is one
175
- 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.
176
183
 
177
184
  ## Reporting a problem or an idea
178
185
 
@@ -182,17 +189,11 @@ run at all, write to `nmts@nmts.me` with what you ran and what it said.
182
189
 
183
190
  ## Exit codes
184
191
 
185
- `0` done · `1` something went wrong · `2` the command line was wrong · `3` not signed in ·
186
- `4` the command exists but could not do it · `5` waiting on the person's agreement ·
187
- `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.
188
194
 
189
- ## Licence
195
+ ## Licence and source
190
196
 
191
197
  Apache-2.0 (it was AGPL-3.0-only until 2026-08-30). **Calling this program from your own code puts
192
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.
193
-
194
- ## Source
195
-
196
- <https://github.com/needmoretruth/nmts-cli> · The same operations as a library, for a program that calls
197
- NMTS from its own code: `npm install @needmoretruth/nmts-sdk` · <https://github.com/needmoretruth/nmts-sdk>
198
- (its own document for agents is `AGENTS.md` there).
199
+ Source: <https://github.com/needmoretruth/nmts-cli>.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,23 @@
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
+
15
+ ## 0.38.1 — 2026-09-20
16
+
17
+ - The folder and trash functions behind `@needmoretruth/nmts-cli/drive-edit` no longer import
18
+ `node:crypto`, so a browser bundle that reaches them builds again; the browser entry of `nmts-sdk`
19
+ does. Nothing a command prints has changed.
20
+ - `AGENTS.md` has a new section, "What is built on it today": storage for an agent, encrypted
21
+ storage inside a product through the SDK, the S3 endpoint, and recovery without NMTS.
22
+
6
23
  ## 0.38.0 — 2026-09-19
7
24
 
8
25
  - The library surface has a new entry, `@needmoretruth/nmts-cli/s3-gateway`: the S3 server `nmts s3`
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.0 # 버전을 고정할 때
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.0 # 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.0";
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,44 @@
1
+ import { NmtsError } from "../errors.ts";
2
+ /**
3
+ * What went wrong, in a word a program can branch on.
4
+ *
5
+ * ⚠ FIVE, AND THEY ARE ABOUT THE LIST. A server refusal arrives as `ServerError` with the server's
6
+ * own code, and a lost compare-and-swap that never settles arrives as a plain `NmtsError`;
7
+ * neither is a decision this file made.
8
+ */
9
+ export type DriveEditCode =
10
+ /** No entry at that path — or a path that names two, which is the same "which one?" */
11
+ "NOT_FOUND"
12
+ /** Something else in that folder already answers to that name. */
13
+ | "NAME_TAKEN"
14
+ /** The name itself cannot be used: empty, `.`, `..`, or a path pretending to be a name. */
15
+ | "BAD_NAME"
16
+ /** A restore was asked for something that is not in the trash, or not in it on its own account. */
17
+ | "NOT_IN_TRASH"
18
+ /** A folder was asked to move inside itself. */
19
+ | "INTO_ITSELF";
20
+ /** A refusal about the list, with the sentence a person reads and the word a program reads. */
21
+ export declare class DriveEditError extends NmtsError {
22
+ readonly code: DriveEditCode;
23
+ constructor(code: DriveEditCode, message: string, options?: {
24
+ exitCode?: number;
25
+ nextStep?: string | null;
26
+ });
27
+ }
28
+ /**
29
+ * Run a path lookup, and label whatever it refused as `NOT_FOUND`.
30
+ *
31
+ * ⛔ THE SENTENCE, THE EXIT CODE AND THE NEXT STEP ARE CARRIED THROUGH UNTOUCHED. `drive-paths.ts`
32
+ * words three different failures — nothing there, it is in the trash, it names two things — and
33
+ * each of them is better than anything this file could say about them. What is added is the one
34
+ * thing it cannot carry: a code, so a program does not have to read English to know a path did
35
+ * not resolve.
36
+ */
37
+ export declare function resolving<T>(body: () => T): T;
38
+ /**
39
+ * A new name is a name and not a path.
40
+ *
41
+ * ⛔ REFUSED RATHER THAN SPLIT. A name with a `/` in it is somebody asking for a move while typing
42
+ * a rename, and quietly doing the move would put the file somewhere they did not look.
43
+ */
44
+ export declare function requireNewName(name: string): string;
@@ -0,0 +1,65 @@
1
+ // What the drive's own edits refuse with: a word a program branches on, and the one rule that
2
+ // decides whether a new name is a name at all.
3
+ //
4
+ // ⛔ AND EVERY REFUSAL THIS FILE MAKES CARRIES A CODE. A terminal reads the sentence; a program
5
+ // reads `error.code` and branches on it. That is why `DriveEditError` EXTENDS the tool's own
6
+ // error rather than replacing it: the exit code and the next step are still there, `renderError`
7
+ // prints exactly what it printed before, and `error instanceof NmtsError` is still true.
8
+ //
9
+ // ⚠ THE ARGUMENT-SHAPE REFUSALS ARE NOT HERE. "`nmts rename` needs what to rename" is about a
10
+ // command line, and a library caller has no command line to be told about. What IS here is every
11
+ // refusal about the LIST, because that one is the same question whoever asked it.
12
+ import { NmtsError } from "../errors.js";
13
+ /** A refusal about the list, with the sentence a person reads and the word a program reads. */
14
+ export class DriveEditError extends NmtsError {
15
+ code;
16
+ constructor(code, message, options = {}) {
17
+ super(message, options);
18
+ this.name = "DriveEditError";
19
+ this.code = code;
20
+ }
21
+ }
22
+ /**
23
+ * Run a path lookup, and label whatever it refused as `NOT_FOUND`.
24
+ *
25
+ * ⛔ THE SENTENCE, THE EXIT CODE AND THE NEXT STEP ARE CARRIED THROUGH UNTOUCHED. `drive-paths.ts`
26
+ * words three different failures — nothing there, it is in the trash, it names two things — and
27
+ * each of them is better than anything this file could say about them. What is added is the one
28
+ * thing it cannot carry: a code, so a program does not have to read English to know a path did
29
+ * not resolve.
30
+ */
31
+ export function resolving(body) {
32
+ try {
33
+ return body();
34
+ }
35
+ catch (error) {
36
+ if (error instanceof NmtsError && !(error instanceof DriveEditError)) {
37
+ throw new DriveEditError("NOT_FOUND", error.message, {
38
+ exitCode: error.exitCode,
39
+ nextStep: error.nextStep,
40
+ });
41
+ }
42
+ throw error;
43
+ }
44
+ }
45
+ /**
46
+ * A new name is a name and not a path.
47
+ *
48
+ * ⛔ REFUSED RATHER THAN SPLIT. A name with a `/` in it is somebody asking for a move while typing
49
+ * a rename, and quietly doing the move would put the file somewhere they did not look.
50
+ */
51
+ export function requireNewName(name) {
52
+ if (name.trim() === "") {
53
+ throw new DriveEditError("BAD_NAME", "A name cannot be empty.", {
54
+ exitCode: 2,
55
+ nextStep: "Nothing was renamed.",
56
+ });
57
+ }
58
+ if (name.includes("/")) {
59
+ throw new DriveEditError("BAD_NAME", `A name cannot contain "/" — that is what makes it a path.`, {
60
+ exitCode: 2,
61
+ nextStep: `To move it, use \`nmts mv\`. Nothing was renamed.`,
62
+ });
63
+ }
64
+ return name;
65
+ }
@@ -0,0 +1,29 @@
1
+ import { type ListEditInput } from "../manifest-write.ts";
2
+ /**
3
+ * Make a folder path, and every folder above it that is missing, for an account already opened.
4
+ *
5
+ * ⛔ THE RULES BELOW ARE THE ONES A SECOND COPY WOULD GET SUBTLY WRONG: a folder that is already
6
+ * there IS the folder asked for (never a numbered one), the decision is taken inside each
7
+ * attempt so a lost race cannot make two, and what was made before a failure is named rather
8
+ * than silently kept.
9
+ *
10
+ * ⚠ MISSING PARENTS ARE CREATED, and that is a decision rather than a convenience. A folder costs
11
+ * nothing, holds nothing and can be moved to the trash, so the failure mode of creating one too
12
+ * many is a tidy-up; the failure mode of refusing is a caller that has to discover the tree one
13
+ * call at a time. Every folder made is named in the result, so it is never a surprise.
14
+ */
15
+ export declare function ensureFolderPath(input: ListEditInput, wanted: string): Promise<{
16
+ parentId: string | null;
17
+ made: string[];
18
+ }>;
19
+ /** What making a folder did. `made` is empty when every folder in the path was already there. */
20
+ export interface MadeFolder {
21
+ /** The path as the drive spells it — no leading slash, no trailing one. */
22
+ path: string;
23
+ /** The folder at the end of the path. Null only for the top of the drive, which is never made. */
24
+ parentId: string | null;
25
+ /** The folders this call actually made, outermost first. */
26
+ made: string[];
27
+ }
28
+ /** Make one folder path. A path that is already there is a success with nothing made. */
29
+ export declare function makeFolder(input: ListEditInput, path: string): Promise<MadeFolder>;
@@ -0,0 +1,98 @@
1
+ // Making a folder, and every folder above it that is missing.
2
+ import { KIND_FOLDER, normaliseName, normalisePath } from "../drive-paths.js";
3
+ import { NmtsError } from "../errors.js";
4
+ import { applyToList } from "../manifest-write.js";
5
+ import { DriveEditError } from "./errors.js";
6
+ /**
7
+ * Make a folder path, and every folder above it that is missing, for an account already opened.
8
+ *
9
+ * ⛔ THE RULES BELOW ARE THE ONES A SECOND COPY WOULD GET SUBTLY WRONG: a folder that is already
10
+ * there IS the folder asked for (never a numbered one), the decision is taken inside each
11
+ * attempt so a lost race cannot make two, and what was made before a failure is named rather
12
+ * than silently kept.
13
+ *
14
+ * ⚠ MISSING PARENTS ARE CREATED, and that is a decision rather than a convenience. A folder costs
15
+ * nothing, holds nothing and can be moved to the trash, so the failure mode of creating one too
16
+ * many is a tidy-up; the failure mode of refusing is a caller that has to discover the tree one
17
+ * call at a time. Every folder made is named in the result, so it is never a surprise.
18
+ */
19
+ export async function ensureFolderPath(input, wanted) {
20
+ const made = [];
21
+ let parentId = null;
22
+ let walked = "";
23
+ for (const name of wanted.split("/")) {
24
+ // ⚠ A name that is only spaces is refused too. `mkdir` used to accept it, and then `rm` and
25
+ // `restore` rejected the very path `ls` printed for it as "no path given" — a code-2 message
26
+ // blaming the caller for an argument they had supplied (2026-08-23).
27
+ if (name.trim() === "" || name === "." || name === "..") {
28
+ throw new DriveEditError("BAD_NAME", `"${wanted}" is not a folder path this tool will make.`, {
29
+ exitCode: 2,
30
+ nextStep: `Empty names, "." and ".." are not folder names in a drive. Nothing was made.`,
31
+ });
32
+ }
33
+ walked = walked === "" ? name : `${walked}/${name}`;
34
+ const under = parentId;
35
+ const here = walked;
36
+ // The global one, not `node:crypto`: the SDK's browser entry bundles this file.
37
+ const fresh = globalThis.crypto.randomUUID();
38
+ let landedOn = fresh;
39
+ // ⛔ ONE WRITE PER FOLDER, and the check that decides whether to write happens INSIDE the
40
+ // attempt. Two things went wrong when it sat outside (2026-08-23):
41
+ // · running `mkdir` twice at the same moment made `shared` AND `shared (2)`, because the
42
+ // loser of the compare-and-swap re-applied a decision taken against the older list;
43
+ // · a trashed folder of the same name made the second `mkdir` produce `photos (2)` while
44
+ // printing `Made "photos"`, because it went through the upload helper — and picking a
45
+ // free name is the right rule for BYTES and the wrong rule for a folder. A folder with
46
+ // that name in that parent IS the folder that was asked for.
47
+ // Building the whole chain in memory and writing once would be fewer round trips and would
48
+ // also mean a lost compare-and-swap threw away folders the ones below already point at.
49
+ const result = await applyToList(input, (entries) => {
50
+ const there = entries.find((e) => e.parentId === under &&
51
+ normaliseName(e.name) === normaliseName(name) &&
52
+ e.deletedAt === undefined);
53
+ if (there !== undefined) {
54
+ if (there.kind !== KIND_FOLDER) {
55
+ throw new DriveEditError("NAME_TAKEN", `"${here}" is a file, so nothing can be made inside it.`, {
56
+ exitCode: 4,
57
+ nextStep: made.length > 0 ? `The folders made so far are kept: ${made.join(", ")}.` : "Nothing was made.",
58
+ });
59
+ }
60
+ landedOn = there.id;
61
+ return null;
62
+ }
63
+ landedOn = fresh;
64
+ const at = Date.now();
65
+ return {
66
+ op: "add",
67
+ entry: { id: fresh, parentId: under, kind: KIND_FOLDER, name, size: 0, createdAt: at, updatedAt: at },
68
+ };
69
+ }).catch((error) => {
70
+ // ⛔ WHAT SURVIVED IS NAMED. A run that stops half way leaves real folders behind, and the
71
+ // message that says so was attached only to the "that is a file" refusal.
72
+ if (error instanceof NmtsError || made.length === 0)
73
+ throw error;
74
+ const because = error instanceof Error ? error.message : "the server refused";
75
+ throw new NmtsError(because, {
76
+ exitCode: 1,
77
+ nextStep: `The folders made so far are kept: ${made.join(", ")}. Running the same command again ` +
78
+ `makes the rest — nothing is lost.`,
79
+ });
80
+ });
81
+ if (result.changed)
82
+ made.push(here);
83
+ parentId = landedOn;
84
+ }
85
+ return { parentId, made };
86
+ }
87
+ /** Make one folder path. A path that is already there is a success with nothing made. */
88
+ export async function makeFolder(input, path) {
89
+ const wanted = normalisePath(path);
90
+ if (wanted === "") {
91
+ throw new DriveEditError("BAD_NAME", `"${path}" names the whole drive, not a folder in it.`, {
92
+ exitCode: 2,
93
+ nextStep: "Nothing was made.",
94
+ });
95
+ }
96
+ const { parentId, made } = await ensureFolderPath(input, wanted);
97
+ return { path: wanted, parentId, made };
98
+ }
@@ -0,0 +1,66 @@
1
+ import { type ListEditInput } from "../manifest-write.ts";
2
+ /** One thing a move carried, with where it came from and where it landed. */
3
+ export interface MovedThing {
4
+ id: string;
5
+ /** Its name, which a move never changes. */
6
+ name: string;
7
+ /** Its full path before the move. */
8
+ from: string;
9
+ /** Its full path after, or null if another device took it out of the list meanwhile. */
10
+ path: string | null;
11
+ }
12
+ /** What one run of moving did. */
13
+ export interface MoveOutcome {
14
+ /** The things this run moved, in the order they were named. */
15
+ moved: MovedThing[];
16
+ /** The names that were already in the destination, so nothing was written for them. */
17
+ already: string[];
18
+ /** The destination folder id, or null for the top of the drive. */
19
+ parentId: string | null;
20
+ /** False when everything named was already there, so no list was written. */
21
+ changed: boolean;
22
+ /** True when the list was rebuilt because another device wrote first. */
23
+ reappliedAfterConflict: boolean;
24
+ /** The list version now current. */
25
+ seq: number;
26
+ }
27
+ /**
28
+ * Move things into a folder. An empty destination is the top of the drive.
29
+ *
30
+ * ⛔ ONE WRITE FOR THE WHOLE RUN, however many things are named. The list is rewritten whole on
31
+ * every save, so a second thing costs nothing extra — while a second WRITE is a second chance
32
+ * to lose the compare-and-swap, and losing it half way through a run leaves some things moved
33
+ * and some not, which is a state the caller cannot tell apart from the one it asked for.
34
+ *
35
+ * ⛔ AND THE NAME CHECK RUNS AGAINST WHAT THIS RUN HAS ALREADY MOVED, not against the list as it
36
+ * was read. Two files called `notes.txt` in two folders, moved into one folder by one call,
37
+ * would otherwise both be written — two entries at one path, which nothing can address
38
+ * afterwards: every lookup answers "names 2 things in this account". So the loop folds each
39
+ * move onto a working copy and asks the working copy the next question.
40
+ */
41
+ export declare function moveEntries(input: ListEditInput, paths: readonly string[], destination: string): Promise<MoveOutcome>;
42
+ /** What renaming one thing did. */
43
+ export interface RenameOutcome {
44
+ id: string;
45
+ /** The name it had. */
46
+ from: string;
47
+ /** The full path it had, which is what a person recognises it by. */
48
+ fromPath: string;
49
+ /** The name it has now. */
50
+ to: string;
51
+ /** False when it was already called that, so no list was written. */
52
+ changed: boolean;
53
+ reappliedAfterConflict: boolean;
54
+ seq: number;
55
+ }
56
+ /**
57
+ * Give one thing a new name. The path stays the same otherwise.
58
+ *
59
+ * ⛔ REFUSED RATHER THAN NUMBERED, AND THE REFUSAL IS RE-DECIDED ON EVERY ATTEMPT. An upload picks
60
+ * `report (2).pdf` because nobody was watching; a rename is somebody choosing a name on purpose,
61
+ * and silently giving them a different one is how two files end up looking like a mistake nobody
62
+ * made. Checking once, before the write, was not enough: when another device took the name in
63
+ * between, the retry re-applied the old decision and produced two entries at one path, which
64
+ * nothing can address afterwards (2026-08-23).
65
+ */
66
+ export declare function renameEntry(input: ListEditInput, path: string, name: string): Promise<RenameOutcome>;