@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 +22 -0
- package/CHANGELOG.md +8 -0
- package/README.ko.md +1 -1
- package/README.md +1 -1
- package/dist/artifact-about.d.ts +1 -1
- package/dist/drive-edit/errors.d.ts +44 -0
- package/dist/drive-edit/errors.js +65 -0
- package/dist/drive-edit/folders.d.ts +29 -0
- package/dist/drive-edit/folders.js +98 -0
- package/dist/drive-edit/move.d.ts +66 -0
- package/dist/drive-edit/move.js +119 -0
- package/dist/drive-edit/trash.d.ts +53 -0
- package/dist/drive-edit/trash.js +190 -0
- package/dist/drive-edit/tree.d.ts +15 -0
- package/dist/drive-edit/tree.js +77 -0
- package/dist/drive-edit.d.ts +9 -189
- package/dist/drive-edit.js +9 -527
- package/dist/product.d.ts +1 -1
- package/dist/product.js +1 -1
- package/dist/s3/contract.d.ts +80 -0
- package/dist/s3/contract.js +3 -0
- package/dist/s3/routes.d.ts +4 -0
- package/dist/s3/routes.js +249 -0
- package/dist/s3/server.d.ts +3 -80
- package/dist/s3/server.js +4 -247
- package/package.json +1 -1
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.
|
|
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.
|
|
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
|
|
package/dist/artifact-about.d.ts
CHANGED
|
@@ -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.
|
|
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>;
|