@needmoretruth/nmts-cli 0.38.0 → 0.38.1

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,28 @@ 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
+ 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.
64
+
43
65
  ## What only a person can do, once
44
66
 
45
67
  Four things need a person, all at the beginning. If you are being set up, hand this list back in full at once.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,14 @@
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.38.1 — 2026-09-20
7
+
8
+ - The folder and trash functions behind `@needmoretruth/nmts-cli/drive-edit` no longer import
9
+ `node:crypto`, so a browser bundle that reaches them builds again; the browser entry of `nmts-sdk`
10
+ does. Nothing a command prints has changed.
11
+ - `AGENTS.md` has a new section, "What is built on it today": storage for an agent, encrypted
12
+ storage inside a product through the SDK, the S3 endpoint, and recovery without NMTS.
13
+
6
14
  ## 0.38.0 — 2026-09-19
7
15
 
8
16
  - 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.38.1 # 버전을 고정할 때
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.38.1 # 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.38.1";
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
  /**
@@ -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>;
@@ -0,0 +1,119 @@
1
+ // Moving things into a folder, and giving one thing a new name where it is.
2
+ import { buildIndex, entryAt, folderIdFor, fullPathOf, namesIn, normaliseName, } from "../drive-paths.js";
3
+ import { readFileList } from "../manifest.js";
4
+ import { applyManyToList, applyToList, batchTargets } from "../manifest-write.js";
5
+ import { applyIntent } from "../shared/lib/drive/manifest-ops.js";
6
+ import { DriveEditError, requireNewName, resolving } from "./errors.js";
7
+ import { isUnder } from "./tree.js";
8
+ /**
9
+ * Move things into a folder. An empty destination is the top of the drive.
10
+ *
11
+ * ⛔ ONE WRITE FOR THE WHOLE RUN, however many things are named. The list is rewritten whole on
12
+ * every save, so a second thing costs nothing extra — while a second WRITE is a second chance
13
+ * to lose the compare-and-swap, and losing it half way through a run leaves some things moved
14
+ * and some not, which is a state the caller cannot tell apart from the one it asked for.
15
+ *
16
+ * ⛔ AND THE NAME CHECK RUNS AGAINST WHAT THIS RUN HAS ALREADY MOVED, not against the list as it
17
+ * was read. Two files called `notes.txt` in two folders, moved into one folder by one call,
18
+ * would otherwise both be written — two entries at one path, which nothing can address
19
+ * afterwards: every lookup answers "names 2 things in this account". So the loop folds each
20
+ * move onto a working copy and asks the working copy the next question.
21
+ */
22
+ export async function moveEntries(input, paths, destination) {
23
+ const at = Date.now();
24
+ let moved = [];
25
+ let already = [];
26
+ let parentId = null;
27
+ // ⛔ EVERY GUARD RUNS INSIDE THE ATTEMPT, INCLUDING WHICH ENTRY EACH PATH NAMES. A lost
28
+ // compare-and-swap re-applies the intent to a list that changed underneath — and when the
29
+ // winner had just taken this name, the loser landed on top of it and produced two entries at
30
+ // one path. Meanwhile the caller was told the move had been made.
31
+ const result = await applyManyToList(input, (now) => {
32
+ const targets = resolving(() => batchTargets(now, paths, { nothingHappened: "Nothing was moved." }));
33
+ const into = resolving(() => folderIdFor(destination, now, "Nothing was moved."));
34
+ const index = buildIndex(now);
35
+ const intents = [];
36
+ const carried = [];
37
+ const there = [];
38
+ let working = now;
39
+ for (const target of targets) {
40
+ if (into !== null && (into === target.id || isUnder(working, into, target.id))) {
41
+ throw new DriveEditError("INTO_ITSELF", `A folder cannot be moved inside itself.`, {
42
+ exitCode: 4,
43
+ nextStep: "Nothing was moved.",
44
+ });
45
+ }
46
+ if (into === target.parentId) {
47
+ there.push(target.name);
48
+ continue;
49
+ }
50
+ if (namesIn(working, into).has(normaliseName(target.name))) {
51
+ throw new DriveEditError("NAME_TAKEN", `Something called "${target.name}" is already in that folder.`, {
52
+ exitCode: 4,
53
+ nextStep: `Nothing was moved. Rename it first: nmts rename "${target.name}" <new name>`,
54
+ });
55
+ }
56
+ const intent = { op: "move", id: target.id, parentId: into, at };
57
+ intents.push(intent);
58
+ working = applyIntent(working, intent);
59
+ carried.push({ id: target.id, name: target.name, from: fullPathOf(index, target) });
60
+ }
61
+ moved = carried;
62
+ already = there;
63
+ parentId = into;
64
+ return intents;
65
+ });
66
+ // ⚠ Read off the list AS WRITTEN, not off the intents: an id another device took out of the list
67
+ // meanwhile has no path any more, and claiming one would name a place nothing is at.
68
+ const after = buildIndex(result.entries);
69
+ return {
70
+ moved: moved.map((m) => {
71
+ const live = result.entries.find((e) => e.id === m.id);
72
+ return { id: m.id, name: m.name, from: m.from, path: live === undefined ? null : fullPathOf(after, live) };
73
+ }),
74
+ already,
75
+ parentId,
76
+ changed: result.changed,
77
+ reappliedAfterConflict: result.reappliedAfterConflict,
78
+ seq: result.seq,
79
+ };
80
+ }
81
+ /**
82
+ * Give one thing a new name. The path stays the same otherwise.
83
+ *
84
+ * ⛔ REFUSED RATHER THAN NUMBERED, AND THE REFUSAL IS RE-DECIDED ON EVERY ATTEMPT. An upload picks
85
+ * `report (2).pdf` because nobody was watching; a rename is somebody choosing a name on purpose,
86
+ * and silently giving them a different one is how two files end up looking like a mistake nobody
87
+ * made. Checking once, before the write, was not enough: when another device took the name in
88
+ * between, the retry re-applied the old decision and produced two entries at one path, which
89
+ * nothing can address afterwards (2026-08-23).
90
+ */
91
+ export async function renameEntry(input, path, name) {
92
+ requireNewName(name);
93
+ const list = await readFileList(input.server, input.apiKey, input.code, input.accountId);
94
+ const entries = list.manifest?.entries ?? [];
95
+ const target = resolving(() => entryAt(entries, path, { nothingHappened: "Nothing was renamed." }));
96
+ const at = Date.now();
97
+ const fromPath = fullPathOf(buildIndex(entries), target);
98
+ const result = await applyToList(input, (now) => {
99
+ const live = now.find((e) => e.id === target.id);
100
+ if (live === undefined)
101
+ return null;
102
+ if (normaliseName(name) !== normaliseName(live.name) && namesIn(now, live.parentId).has(normaliseName(name))) {
103
+ throw new DriveEditError("NAME_TAKEN", `Something called "${name}" is already in that folder.`, {
104
+ exitCode: 4,
105
+ nextStep: "Nothing was renamed.",
106
+ });
107
+ }
108
+ return { op: "rename", id: target.id, name, at };
109
+ });
110
+ return {
111
+ id: target.id,
112
+ from: target.name,
113
+ fromPath,
114
+ to: name,
115
+ changed: result.changed,
116
+ reappliedAfterConflict: result.reappliedAfterConflict,
117
+ seq: result.seq,
118
+ };
119
+ }
@@ -0,0 +1,53 @@
1
+ import { type ListEditInput } from "../manifest-write.ts";
2
+ /**
3
+ * What one run of the trash did — and, in this order, exactly what `nmts rm --json` prints.
4
+ *
5
+ * ⛔ THE ORDER OF THESE FIELDS IS THE COMMAND'S JSON. The command hands this object straight to
6
+ * `JSON.stringify`, so a field added in the middle changes what an agent reading that output
7
+ * sees. Add at the end, or not at all.
8
+ */
9
+ export interface TrashOutcome {
10
+ /** The paths acted on. Empty when everything named was already where it was asked to be. */
11
+ paths: string[];
12
+ /** Their ids, in the same order. */
13
+ ids: string[];
14
+ /** How many server rows were moved. A folder has none of its own; its files have one each. */
15
+ files: number;
16
+ /** Named, and nothing written for them: already out of the trash, or covered by a named folder. */
17
+ skipped: string[];
18
+ changed: boolean;
19
+ reappliedAfterConflict: boolean;
20
+ seq: number;
21
+ }
22
+ export interface TrashEditOptions {
23
+ /**
24
+ * Refuse what the command-line tool names and carries on with.
25
+ *
26
+ * ⛔ OFF FOR THE COMMANDS AND ON FOR A LIBRARY, and the difference is who is reading. A person
27
+ * who typed `nmts restore a.txt b.txt` and had already restored `a.txt` wants `b.txt` back and
28
+ * a line saying the first was not in the trash; a program calling `restore` wants to know that
29
+ * what it asked for was not what it got, and the only way it learns that is a refusal.
30
+ *
31
+ * It adds two: a path that is not in the trash (`NOT_IN_TRASH`) and a restore whose old name has
32
+ * been taken since (`NAME_TAKEN`).
33
+ */
34
+ strict?: boolean;
35
+ }
36
+ /**
37
+ * Move things to the trash, or bring them back.
38
+ *
39
+ * ⛔ NEITHER HALF DESTROYS ANYTHING. `rm` moves everything it is given to the trash, where it stays
40
+ * restorable for thirty days; the endpoint that erases a stored row for good is closed to an API
41
+ * key and stays closed, so nothing here can reach it.
42
+ *
43
+ * ⛔ THE SERVER ROW GOES FIRST, AND "ALREADY DONE" COUNTS AS DONE. A trashed item's bytes cannot be
44
+ * fetched, so the state to avoid above all others is a list that shows a file as live when the
45
+ * server has already trashed it: the person sees it, asks for it, and is told it does not exist.
46
+ * Writing the list only after the server agreed means a failed server call leaves the drive
47
+ * exactly as it was — the state a caller can act on.
48
+ *
49
+ * ⛔ AND ONE PATH THAT WILL NOT RESOLVE REFUSES THE WHOLE RUN, before a single server row is
50
+ * touched. Trashing four of the five things somebody named and answering success is worse than
51
+ * trashing none: the run reads as done, and finding the odd one out means diffing the drive.
52
+ */
53
+ export declare function trashPaths(input: ListEditInput, verb: "rm" | "restore", paths: readonly string[], options?: TrashEditOptions): Promise<TrashOutcome>;