@needmoretruth/nmts-cli 0.36.3 → 0.38.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +17 -0
- package/README.ko.md +1 -1
- package/README.md +1 -1
- package/dist/artifact-about.d.ts +1 -1
- package/dist/commands/erase.js +1 -1
- package/dist/commands/organise.d.ts +5 -27
- package/dist/commands/organise.js +33 -193
- package/dist/commands/push.js +1 -1
- package/dist/commands/s3.d.ts +0 -10
- package/dist/commands/s3.js +46 -121
- package/dist/commands/trash.d.ts +0 -9
- package/dist/commands/trash.js +23 -223
- package/dist/drive-edit.d.ts +189 -0
- package/dist/drive-edit.js +541 -0
- package/dist/product.d.ts +1 -1
- package/dist/product.js +1 -1
- package/dist/s3/drive.d.ts +108 -0
- package/dist/s3/drive.js +136 -0
- package/dist/s3/listing.d.ts +8 -1
- package/dist/s3/listing.js +8 -1
- package/dist/s3/server.d.ts +42 -3
- package/dist/s3/server.js +59 -20
- package/dist/s3/sigv4.d.ts +26 -0
- package/dist/s3/sigv4.js +37 -0
- package/dist/s3/xml.d.ts +8 -1
- package/dist/s3/xml.js +13 -4
- package/dist/s3-gateway.d.ts +8 -0
- package/dist/s3-gateway.js +13 -0
- package/docs/commands/create.md +2 -2
- package/docs/commands/env.md +1 -1
- package/docs/commands/extend.md +0 -3
- package/docs/commands/login.md +1 -1
- package/docs/commands/logout.md +1 -1
- package/docs/commands/marks.md +1 -1
- package/docs/commands/mcp.md +1 -1
- package/docs/commands/put.md +2 -2
- package/docs/commands/wallet.md +7 -9
- package/docs/commands/whoami.md +2 -2
- package/package.json +9 -1
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.38.0 — 2026-09-19
|
|
7
|
+
|
|
8
|
+
- The library surface has a new entry, `@needmoretruth/nmts-cli/s3-gateway`: the S3 server `nmts s3`
|
|
9
|
+
runs, taking a function that says which drive a bucket name means and a list of key pairs that
|
|
10
|
+
may each be held to named buckets, so a program can put it in front of more than one account and
|
|
11
|
+
mount it in a server of its own. `nmts s3` is unchanged: the same output, the same bucket, the same
|
|
12
|
+
refusals. `nmts-sdk` 0.4.0 is built on it.
|
|
13
|
+
|
|
14
|
+
## 0.37.0 — 2026-09-19
|
|
15
|
+
|
|
16
|
+
- The library surface has a new entry, `@needmoretruth/nmts-cli/drive-edit`: the list edits behind
|
|
17
|
+
`mkdir`, `mv`, `rename`, `rm` and `restore` as functions that print nothing and refuse with a
|
|
18
|
+
`code` (`NOT_FOUND`, `NAME_TAKEN`, `BAD_NAME`, `NOT_IN_TRASH`, `INTO_ITSELF`). The commands call the
|
|
19
|
+
same functions and print what they printed before. `nmts-sdk` 0.3.0 is built on it.
|
|
20
|
+
- Six command documents called the NMTS key "code", the name it had before; `nmts help extend`
|
|
21
|
+
carried an empty code block.
|
|
22
|
+
|
|
6
23
|
## 0.36.3 — 2026-09-19
|
|
7
24
|
|
|
8
25
|
- `--help` had the `--on-collision` lines in the middle of the `--part-size` description, so the
|
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.
|
|
53
|
+
npm install -g github:needmoretruth/nmts-cli#v0.38.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.
|
|
54
|
+
npm install -g github:needmoretruth/nmts-cli#v0.38.0 # 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.
|
|
14
|
+
export declare const WRITTEN_BY = "nmts-cli 0.38.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
|
/**
|
package/dist/commands/erase.js
CHANGED
|
@@ -26,7 +26,7 @@ import { applyToList, batchTargets } from "../manifest-write.js";
|
|
|
26
26
|
import { BINARY_NAME } from "../product.js";
|
|
27
27
|
import { promptLine, stdinIsATerminal } from "../prompt.js";
|
|
28
28
|
import { openSession } from "../session.js";
|
|
29
|
-
import { filesUnder } from "
|
|
29
|
+
import { filesUnder } from "../drive-edit.js";
|
|
30
30
|
/** The server takes at most this many ids in one erase (`ERASE_BATCH_MAX`). */
|
|
31
31
|
const BATCH = 200;
|
|
32
32
|
/** The refusal a no-deposit release gets when the balance cannot cover the doubled fee. */
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { openSession } from "../session.ts";
|
|
2
1
|
export interface OrganiseOptions {
|
|
3
2
|
server?: string | undefined;
|
|
4
3
|
network?: string | undefined;
|
|
@@ -8,38 +7,17 @@ export interface OrganiseOptions {
|
|
|
8
7
|
/**
|
|
9
8
|
* Make a folder, and any folder above it that is missing.
|
|
10
9
|
*
|
|
11
|
-
* ⚠ MISSING PARENTS ARE CREATED, and that is a decision rather than a convenience
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* command at a time. Every folder made is named in the output, so it is never a surprise.
|
|
10
|
+
* ⚠ MISSING PARENTS ARE CREATED, and that is a decision rather than a convenience — the reason is
|
|
11
|
+
* beside the code that does it, in `drive-edit.ts`. Every folder made is named in the output, so
|
|
12
|
+
* it is never a surprise.
|
|
15
13
|
*/
|
|
16
|
-
/**
|
|
17
|
-
* Make a folder path, and every folder above it that is missing, under an OPEN session.
|
|
18
|
-
*
|
|
19
|
-
* ⛔ SPLIT OUT SO AN UPLOAD OF A WHOLE DIRECTORY CAN USE IT. The rules below are the ones a second
|
|
20
|
-
* copy would get subtly wrong: a folder that is already there IS the folder asked for (never a
|
|
21
|
-
* numbered one), the decision is taken inside each attempt so a lost race cannot make two, and
|
|
22
|
-
* what was made before a failure is named rather than silently kept.
|
|
23
|
-
*/
|
|
24
|
-
export declare function ensureFolderPath(session: Awaited<ReturnType<typeof openSession>>, wanted: string): Promise<{
|
|
25
|
-
parentId: string | null;
|
|
26
|
-
made: string[];
|
|
27
|
-
}>;
|
|
28
14
|
export declare function mkdir(path: string | undefined, options?: OrganiseOptions): Promise<number>;
|
|
29
15
|
/**
|
|
30
16
|
* Move things into a folder. Every operand but the last is something to move; the last is where
|
|
31
17
|
* they go, and an empty one means the top of the drive.
|
|
32
18
|
*
|
|
33
|
-
* ⛔ ONE WRITE FOR THE WHOLE RUN, however many things are named
|
|
34
|
-
*
|
|
35
|
-
* to lose the compare-and-swap, and losing it half way through a run leaves some things moved
|
|
36
|
-
* and some not, which is a state the caller cannot tell apart from the one it asked for.
|
|
37
|
-
*
|
|
38
|
-
* ⛔ AND THE NAME CHECK RUNS AGAINST WHAT THIS RUN HAS ALREADY MOVED, not against the list as it
|
|
39
|
-
* was read. Two files called `notes.txt` in two folders, moved into one folder by one command,
|
|
40
|
-
* would otherwise both be written — two entries at one path, which no command in this tool can
|
|
41
|
-
* address afterwards: every one of them answers "names 2 things in this account". So the loop
|
|
42
|
-
* folds each move onto a working copy and asks the working copy the next question.
|
|
19
|
+
* ⛔ ONE WRITE FOR THE WHOLE RUN, however many things are named, and every guard is re-decided
|
|
20
|
+
* inside the attempt. Both reasons are in `drive-edit.ts`, beside the loop that keeps them.
|
|
43
21
|
*/
|
|
44
22
|
export declare function mv(operands: readonly string[], options?: OrganiseOptions): Promise<number>;
|
|
45
23
|
/** Give one thing a new name. The path stays the same otherwise. */
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
// `nmts mkdir`, `nmts mv` and `nmts rename` — the three edits that touch no bytes.
|
|
2
2
|
//
|
|
3
|
+
// ⛔ WHAT THEY DO IS IN `drive-edit.ts`; WHAT IS HERE IS THE TERMINAL. The list editing moved out
|
|
4
|
+
// the day the SDK needed the same three verbs without one — one implementation, two callers, so
|
|
5
|
+
// "what does moving onto a taken name do" is answered in a single place.
|
|
6
|
+
//
|
|
3
7
|
// ⛔ NONE OF THESE ASKS THE SERVER TO CHANGE ANYTHING BUT THE SEALED LIST. A folder has no server
|
|
4
8
|
// row at all, and a name and a parent live only inside the list — the server holds an id, a
|
|
5
9
|
// size and a time, and was deliberately never given a place to put a name
|
|
@@ -10,97 +14,18 @@
|
|
|
10
14
|
//
|
|
11
15
|
// ⛔ AND NONE OF THEM STOPS TO ASK. Renaming, moving and making a folder are reversible and cost
|
|
12
16
|
// nothing, which is the whole test for whether this tool interrupts somebody. It does not.
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
17
|
+
import { makeFolder, moveEntries, renameEntry, requireNewName } from "../drive-edit.js";
|
|
18
|
+
import { normalisePath } from "../drive-paths.js";
|
|
15
19
|
import { NmtsError } from "../errors.js";
|
|
16
|
-
import { readFileList } from "../manifest.js";
|
|
17
|
-
import { applyManyToList, applyToList, batchTargets } from "../manifest-write.js";
|
|
18
20
|
import { openSession } from "../session.js";
|
|
19
|
-
import { applyIntent } from "../shared/lib/drive/manifest-ops.js";
|
|
20
21
|
const say_ = (line) => void process.stdout.write(`${line}\n`);
|
|
21
22
|
/**
|
|
22
23
|
* Make a folder, and any folder above it that is missing.
|
|
23
24
|
*
|
|
24
|
-
* ⚠ MISSING PARENTS ARE CREATED, and that is a decision rather than a convenience
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
* command at a time. Every folder made is named in the output, so it is never a surprise.
|
|
28
|
-
*/
|
|
29
|
-
/**
|
|
30
|
-
* Make a folder path, and every folder above it that is missing, under an OPEN session.
|
|
31
|
-
*
|
|
32
|
-
* ⛔ SPLIT OUT SO AN UPLOAD OF A WHOLE DIRECTORY CAN USE IT. The rules below are the ones a second
|
|
33
|
-
* copy would get subtly wrong: a folder that is already there IS the folder asked for (never a
|
|
34
|
-
* numbered one), the decision is taken inside each attempt so a lost race cannot make two, and
|
|
35
|
-
* what was made before a failure is named rather than silently kept.
|
|
25
|
+
* ⚠ MISSING PARENTS ARE CREATED, and that is a decision rather than a convenience — the reason is
|
|
26
|
+
* beside the code that does it, in `drive-edit.ts`. Every folder made is named in the output, so
|
|
27
|
+
* it is never a surprise.
|
|
36
28
|
*/
|
|
37
|
-
export async function ensureFolderPath(session, wanted) {
|
|
38
|
-
const made = [];
|
|
39
|
-
let parentId = null;
|
|
40
|
-
let walked = "";
|
|
41
|
-
for (const name of wanted.split("/")) {
|
|
42
|
-
// ⚠ A name that is only spaces is refused too. `mkdir` used to accept it, and then `rm` and
|
|
43
|
-
// `restore` rejected the very path `ls` printed for it as "no path given" — a code-2 message
|
|
44
|
-
// blaming the caller for an argument they had supplied (2026-08-23).
|
|
45
|
-
if (name.trim() === "" || name === "." || name === "..") {
|
|
46
|
-
throw new NmtsError(`"${wanted}" is not a folder path this tool will make.`, {
|
|
47
|
-
exitCode: 2,
|
|
48
|
-
nextStep: `Empty names, "." and ".." are not folder names in a drive. Nothing was made.`,
|
|
49
|
-
});
|
|
50
|
-
}
|
|
51
|
-
walked = walked === "" ? name : `${walked}/${name}`;
|
|
52
|
-
const under = parentId;
|
|
53
|
-
const here = walked;
|
|
54
|
-
const fresh = randomUUID();
|
|
55
|
-
let landedOn = fresh;
|
|
56
|
-
// ⛔ ONE WRITE PER FOLDER, and the check that decides whether to write happens INSIDE the
|
|
57
|
-
// attempt. Two things went wrong when it sat outside (2026-08-23):
|
|
58
|
-
// · running `mkdir` twice at the same moment made `shared` AND `shared (2)`, because the
|
|
59
|
-
// loser of the compare-and-swap re-applied a decision taken against the older list;
|
|
60
|
-
// · a trashed folder of the same name made the second `mkdir` produce `photos (2)` while
|
|
61
|
-
// printing `Made "photos"`, because it went through the upload helper — and picking a
|
|
62
|
-
// free name is the right rule for BYTES and the wrong rule for a folder. A folder with
|
|
63
|
-
// that name in that parent IS the folder that was asked for.
|
|
64
|
-
// Building the whole chain in memory and writing once would be fewer round trips and would
|
|
65
|
-
// also mean a lost compare-and-swap threw away folders the ones below already point at.
|
|
66
|
-
const result = await applyToList(session, (entries) => {
|
|
67
|
-
const there = entries.find((e) => e.parentId === under &&
|
|
68
|
-
normaliseName(e.name) === normaliseName(name) &&
|
|
69
|
-
e.deletedAt === undefined);
|
|
70
|
-
if (there !== undefined) {
|
|
71
|
-
if (there.kind !== KIND_FOLDER) {
|
|
72
|
-
throw new NmtsError(`"${here}" is a file, so nothing can be made inside it.`, {
|
|
73
|
-
exitCode: 4,
|
|
74
|
-
nextStep: made.length > 0 ? `The folders made so far are kept: ${made.join(", ")}.` : "Nothing was made.",
|
|
75
|
-
});
|
|
76
|
-
}
|
|
77
|
-
landedOn = there.id;
|
|
78
|
-
return null;
|
|
79
|
-
}
|
|
80
|
-
landedOn = fresh;
|
|
81
|
-
const at = Date.now();
|
|
82
|
-
return {
|
|
83
|
-
op: "add",
|
|
84
|
-
entry: { id: fresh, parentId: under, kind: KIND_FOLDER, name, size: 0, createdAt: at, updatedAt: at },
|
|
85
|
-
};
|
|
86
|
-
}).catch((error) => {
|
|
87
|
-
// ⛔ WHAT SURVIVED IS NAMED. A run that stops half way leaves real folders behind, and the
|
|
88
|
-
// message that says so was attached only to the "that is a file" refusal.
|
|
89
|
-
if (error instanceof NmtsError || made.length === 0)
|
|
90
|
-
throw error;
|
|
91
|
-
const because = error instanceof Error ? error.message : "the server refused";
|
|
92
|
-
throw new NmtsError(because, {
|
|
93
|
-
exitCode: 1,
|
|
94
|
-
nextStep: `The folders made so far are kept: ${made.join(", ")}. Running the same command again ` +
|
|
95
|
-
`makes the rest — nothing is lost.`,
|
|
96
|
-
});
|
|
97
|
-
});
|
|
98
|
-
if (result.changed)
|
|
99
|
-
made.push(here);
|
|
100
|
-
parentId = landedOn;
|
|
101
|
-
}
|
|
102
|
-
return { parentId, made };
|
|
103
|
-
}
|
|
104
29
|
export async function mkdir(path, options = {}) {
|
|
105
30
|
const say = options.write ?? say_;
|
|
106
31
|
const wanted = normalisePath(path ?? "");
|
|
@@ -108,7 +33,7 @@ export async function mkdir(path, options = {}) {
|
|
|
108
33
|
throw new NmtsError("`nmts mkdir` needs the path of the folder to make.", { exitCode: 2 });
|
|
109
34
|
}
|
|
110
35
|
const session = await openSession(options);
|
|
111
|
-
const { parentId, made } = await
|
|
36
|
+
const { parentId, made } = await makeFolder(session, wanted);
|
|
112
37
|
if (options.json) {
|
|
113
38
|
say(JSON.stringify({ path: wanted, id: parentId, made }));
|
|
114
39
|
return 0;
|
|
@@ -125,16 +50,8 @@ export async function mkdir(path, options = {}) {
|
|
|
125
50
|
* Move things into a folder. Every operand but the last is something to move; the last is where
|
|
126
51
|
* they go, and an empty one means the top of the drive.
|
|
127
52
|
*
|
|
128
|
-
* ⛔ ONE WRITE FOR THE WHOLE RUN, however many things are named
|
|
129
|
-
*
|
|
130
|
-
* to lose the compare-and-swap, and losing it half way through a run leaves some things moved
|
|
131
|
-
* and some not, which is a state the caller cannot tell apart from the one it asked for.
|
|
132
|
-
*
|
|
133
|
-
* ⛔ AND THE NAME CHECK RUNS AGAINST WHAT THIS RUN HAS ALREADY MOVED, not against the list as it
|
|
134
|
-
* was read. Two files called `notes.txt` in two folders, moved into one folder by one command,
|
|
135
|
-
* would otherwise both be written — two entries at one path, which no command in this tool can
|
|
136
|
-
* address afterwards: every one of them answers "names 2 things in this account". So the loop
|
|
137
|
-
* folds each move onto a working copy and asks the working copy the next question.
|
|
53
|
+
* ⛔ ONE WRITE FOR THE WHOLE RUN, however many things are named, and every guard is re-decided
|
|
54
|
+
* inside the attempt. Both reasons are in `drive-edit.ts`, beside the loop that keeps them.
|
|
138
55
|
*/
|
|
139
56
|
export async function mv(operands, options = {}) {
|
|
140
57
|
const say = options.write ?? say_;
|
|
@@ -148,60 +65,17 @@ export async function mv(operands, options = {}) {
|
|
|
148
65
|
});
|
|
149
66
|
}
|
|
150
67
|
const session = await openSession(options);
|
|
151
|
-
const
|
|
152
|
-
const outcome = { moved: [], already: [], parentId: null };
|
|
153
|
-
// ⛔ EVERY GUARD RUNS INSIDE THE ATTEMPT, INCLUDING WHICH ENTRY EACH PATH NAMES. A lost
|
|
154
|
-
// compare-and-swap re-applies the intent to a list that changed underneath — and when the
|
|
155
|
-
// winner had just taken this name, the loser landed on top of it and produced two entries at
|
|
156
|
-
// one path. Meanwhile the tool exited 0 and said the move had been made.
|
|
157
|
-
const result = await applyManyToList(session, (now) => {
|
|
158
|
-
const targets = batchTargets(now, paths, { nothingHappened: "Nothing was moved." });
|
|
159
|
-
const into = folderIdFor(destination, now, "Nothing was moved.");
|
|
160
|
-
const intents = [];
|
|
161
|
-
const moved = [];
|
|
162
|
-
const already = [];
|
|
163
|
-
let working = now;
|
|
164
|
-
for (const target of targets) {
|
|
165
|
-
if (into !== null && (into === target.id || isUnder(working, into, target.id))) {
|
|
166
|
-
throw new NmtsError(`A folder cannot be moved inside itself.`, {
|
|
167
|
-
exitCode: 4,
|
|
168
|
-
nextStep: "Nothing was moved.",
|
|
169
|
-
});
|
|
170
|
-
}
|
|
171
|
-
if (into === target.parentId) {
|
|
172
|
-
already.push(target.name);
|
|
173
|
-
continue;
|
|
174
|
-
}
|
|
175
|
-
if (namesIn(working, into).has(normaliseName(target.name))) {
|
|
176
|
-
throw new NmtsError(`Something called "${target.name}" is already in that folder.`, {
|
|
177
|
-
exitCode: 4,
|
|
178
|
-
nextStep: `Nothing was moved. Rename it first: nmts rename "${target.name}" <new name>`,
|
|
179
|
-
});
|
|
180
|
-
}
|
|
181
|
-
const intent = { op: "move", id: target.id, parentId: into, at };
|
|
182
|
-
intents.push(intent);
|
|
183
|
-
working = applyIntent(working, intent);
|
|
184
|
-
moved.push({ id: target.id, name: target.name });
|
|
185
|
-
}
|
|
186
|
-
outcome.moved = moved;
|
|
187
|
-
outcome.already = already;
|
|
188
|
-
outcome.parentId = into;
|
|
189
|
-
return intents;
|
|
190
|
-
});
|
|
68
|
+
const outcome = await moveEntries(session, paths, destination);
|
|
191
69
|
const shownDestination = normalisePath(destination);
|
|
192
70
|
const where = shownDestination === "" ? "the top of the drive" : `"${shownDestination}"`;
|
|
193
71
|
if (options.json) {
|
|
194
|
-
const index = buildIndex(result.entries);
|
|
195
72
|
say(JSON.stringify({
|
|
196
|
-
moved: outcome.moved.map((m) => {
|
|
197
|
-
const live = result.entries.find((e) => e.id === m.id);
|
|
198
|
-
return { id: m.id, name: m.name, path: live === undefined ? null : fullPathOf(index, live) };
|
|
199
|
-
}),
|
|
73
|
+
moved: outcome.moved.map((m) => ({ id: m.id, name: m.name, path: m.path })),
|
|
200
74
|
already: outcome.already,
|
|
201
75
|
parentId: outcome.parentId,
|
|
202
|
-
changed:
|
|
203
|
-
reappliedAfterConflict:
|
|
204
|
-
seq:
|
|
76
|
+
changed: outcome.changed,
|
|
77
|
+
reappliedAfterConflict: outcome.reappliedAfterConflict,
|
|
78
|
+
seq: outcome.seq,
|
|
205
79
|
}));
|
|
206
80
|
return 0;
|
|
207
81
|
}
|
|
@@ -214,7 +88,7 @@ export async function mv(operands, options = {}) {
|
|
|
214
88
|
if (outcome.moved.length > 0 && outcome.already.length > 0) {
|
|
215
89
|
say(` ${outcome.already.map((n) => `"${n}"`).join(", ")} was already there.`);
|
|
216
90
|
}
|
|
217
|
-
if (
|
|
91
|
+
if (outcome.changed && outcome.reappliedAfterConflict) {
|
|
218
92
|
say(` Another device wrote the file list first, so this was applied to that version.`);
|
|
219
93
|
}
|
|
220
94
|
return 0;
|
|
@@ -228,61 +102,27 @@ export async function rename(path, name, options = {}) {
|
|
|
228
102
|
nextStep: `For example: nmts rename notes.txt "meeting notes.txt"`,
|
|
229
103
|
});
|
|
230
104
|
}
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
}
|
|
105
|
+
// ⛔ ASKED HERE AS WELL AS INSIDE, AND IT IS THE SAME FUNCTION RATHER THAN A SECOND COPY. A name
|
|
106
|
+
// with a `/` in it is wrong about the ARGUMENT, so it is refused before a session is opened —
|
|
107
|
+
// otherwise a machine with no API key would be told about the key instead of about the name
|
|
108
|
+
// that was typed.
|
|
109
|
+
requireNewName(name);
|
|
237
110
|
const session = await openSession(options);
|
|
238
|
-
const
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
// ⛔ REFUSED RATHER THAN NUMBERED, AND THE REFUSAL IS RE-DECIDED ON EVERY ATTEMPT. An upload
|
|
244
|
-
// picks `report (2).pdf` because nobody was watching; a rename is somebody typing a name on
|
|
245
|
-
// purpose, and silently giving them a different one is how two files end up looking like a
|
|
246
|
-
// mistake nobody made. Checking once, before the write, was not enough: when another device
|
|
247
|
-
// took the name in between, the retry re-applied the old decision and produced two entries at
|
|
248
|
-
// one path, which no command in this tool can address afterwards (2026-08-23).
|
|
249
|
-
const result = await applyToList(session, (now) => {
|
|
250
|
-
const live = now.find((e) => e.id === target.id);
|
|
251
|
-
if (live === undefined)
|
|
252
|
-
return null;
|
|
253
|
-
if (normaliseName(name) !== normaliseName(live.name) && namesIn(now, live.parentId).has(normaliseName(name))) {
|
|
254
|
-
throw new NmtsError(`Something called "${name}" is already in that folder.`, {
|
|
255
|
-
exitCode: 4,
|
|
256
|
-
nextStep: "Nothing was renamed.",
|
|
257
|
-
});
|
|
258
|
-
}
|
|
259
|
-
return { op: "rename", id: target.id, name, at };
|
|
260
|
-
});
|
|
261
|
-
return report(say, options, result.changed, result.reappliedAfterConflict, {
|
|
262
|
-
text: result.changed ? `Renamed "${before}" to "${name}".` : `"${before}" is already called that.`,
|
|
111
|
+
const outcome = await renameEntry(session, path, name);
|
|
112
|
+
return report(say, options, outcome.changed, outcome.reappliedAfterConflict, {
|
|
113
|
+
text: outcome.changed
|
|
114
|
+
? `Renamed "${outcome.fromPath}" to "${name}".`
|
|
115
|
+
: `"${outcome.fromPath}" is already called that.`,
|
|
263
116
|
json: {
|
|
264
|
-
id:
|
|
265
|
-
from:
|
|
117
|
+
id: outcome.id,
|
|
118
|
+
from: outcome.from,
|
|
266
119
|
to: name,
|
|
267
|
-
changed:
|
|
268
|
-
reappliedAfterConflict:
|
|
269
|
-
seq:
|
|
120
|
+
changed: outcome.changed,
|
|
121
|
+
reappliedAfterConflict: outcome.reappliedAfterConflict,
|
|
122
|
+
seq: outcome.seq,
|
|
270
123
|
},
|
|
271
124
|
});
|
|
272
125
|
}
|
|
273
|
-
/** Is `id` at or under `rootId`? Used to refuse moving a folder into its own subtree. */
|
|
274
|
-
function isUnder(entries, id, rootId) {
|
|
275
|
-
const byId = buildIndex(entries).byId;
|
|
276
|
-
const seen = new Set();
|
|
277
|
-
let at = id;
|
|
278
|
-
while (at !== null && !seen.has(at)) {
|
|
279
|
-
if (at === rootId)
|
|
280
|
-
return true;
|
|
281
|
-
seen.add(at);
|
|
282
|
-
at = byId.get(at)?.parentId ?? null;
|
|
283
|
-
}
|
|
284
|
-
return false;
|
|
285
|
-
}
|
|
286
126
|
function report(say, options, changed, conflicted, out) {
|
|
287
127
|
if (options.json) {
|
|
288
128
|
say(JSON.stringify(out.json));
|
package/dist/commands/push.js
CHANGED
|
@@ -36,7 +36,7 @@ import { createUploadApi } from "../upload-api.js";
|
|
|
36
36
|
import { clearItemRecord, clearReservation } from "../upload-store.js";
|
|
37
37
|
import { CREDIT_BYTES, partSizeFor, planAndPrice, UPLOAD_EPOCHS } from "../upload-price.js";
|
|
38
38
|
import { createBlobProtocol, readCurrentEpoch } from "../walrus-write.js";
|
|
39
|
-
import { ensureFolderPath } from "
|
|
39
|
+
import { ensureFolderPath } from "../drive-edit.js";
|
|
40
40
|
import { payerOf, refuseWalletOnlyOptions } from "./put.js";
|
|
41
41
|
export async function push(target, options = {}) {
|
|
42
42
|
const say = options.write ?? ((line) => process.stdout.write(`${line}\n`));
|
package/dist/commands/s3.d.ts
CHANGED
|
@@ -1,15 +1,5 @@
|
|
|
1
1
|
/** MinIO's port, which is what most S3 tools already have in their examples. */
|
|
2
2
|
export declare const DEFAULT_PORT = 9000;
|
|
3
|
-
/**
|
|
4
|
-
* How long a file list may be reused before it is fetched again.
|
|
5
|
-
*
|
|
6
|
-
* ⛔ THERE IS A CACHE BECAUSE A SYNC IS THOUSANDS OF REQUESTS. Reading the list per request would
|
|
7
|
-
* mean a server round trip and a decryption for each one, so a listing of a large drive would
|
|
8
|
-
* take minutes and cost the account's rate budget. ⚠ It also means a file uploaded from another
|
|
9
|
-
* device can be up to this long in appearing here, which is the trade and is written in the
|
|
10
|
-
* tool's own words when it starts.
|
|
11
|
-
*/
|
|
12
|
-
export declare const LIST_CACHE_MS = 5000;
|
|
13
3
|
export interface S3Options {
|
|
14
4
|
server?: string | undefined;
|
|
15
5
|
network?: string | undefined;
|
package/dist/commands/s3.js
CHANGED
|
@@ -13,17 +13,13 @@
|
|
|
13
13
|
// machine -- and a gateway cannot ask, because its caller is a program and its stdin is not a
|
|
14
14
|
// terminal. So the agreement has to exist before it starts: without it the drive is served read
|
|
15
15
|
// only and every write is refused with the sentence naming the command that grants it.
|
|
16
|
-
import {
|
|
17
|
-
import { mkdir as makeDir, rm as removeFile, stat } from "node:fs/promises";
|
|
16
|
+
import { rm as removeFile } from "node:fs/promises";
|
|
18
17
|
import { tmpdir } from "node:os";
|
|
19
18
|
import { join } from "node:path";
|
|
20
|
-
import { pipeline } from "node:stream/promises";
|
|
21
19
|
import { randomUUID } from "node:crypto";
|
|
22
|
-
import {
|
|
23
|
-
import { refusalFor, verdictForKey } from "../s3/same-file.js";
|
|
24
|
-
import { fetchFile } from "../download.js";
|
|
20
|
+
import { createDriveSource, fetchObject, LIST_CACHE_MS } from "../s3/drive.js";
|
|
25
21
|
import { NmtsError } from "../errors.js";
|
|
26
|
-
import { ensureFolderPath } from "
|
|
22
|
+
import { ensureFolderPath } from "../drive-edit.js";
|
|
27
23
|
import { put } from "./put.js";
|
|
28
24
|
import { rm } from "./trash.js";
|
|
29
25
|
import { readFileList } from "../manifest.js";
|
|
@@ -34,16 +30,6 @@ import { BUCKET } from "../s3/listing.js";
|
|
|
34
30
|
import { BIND_ADDRESS, createGateway, newCredential } from "../s3/server.js";
|
|
35
31
|
/** MinIO's port, which is what most S3 tools already have in their examples. */
|
|
36
32
|
export const DEFAULT_PORT = 9000;
|
|
37
|
-
/**
|
|
38
|
-
* How long a file list may be reused before it is fetched again.
|
|
39
|
-
*
|
|
40
|
-
* ⛔ THERE IS A CACHE BECAUSE A SYNC IS THOUSANDS OF REQUESTS. Reading the list per request would
|
|
41
|
-
* mean a server round trip and a decryption for each one, so a listing of a large drive would
|
|
42
|
-
* take minutes and cost the account's rate budget. ⚠ It also means a file uploaded from another
|
|
43
|
-
* device can be up to this long in appearing here, which is the trade and is written in the
|
|
44
|
-
* tool's own words when it starts.
|
|
45
|
-
*/
|
|
46
|
-
export const LIST_CACHE_MS = 5_000;
|
|
47
33
|
function portOf(raw) {
|
|
48
34
|
if (raw === undefined)
|
|
49
35
|
return DEFAULT_PORT;
|
|
@@ -59,16 +45,6 @@ export async function s3(options = {}) {
|
|
|
59
45
|
const session = await openSession({ server: options.server, network: options.network });
|
|
60
46
|
const chain = resolveNetwork(session.server, session.network);
|
|
61
47
|
const credential = newCredential();
|
|
62
|
-
let cached = [];
|
|
63
|
-
let cachedAt = 0;
|
|
64
|
-
const entries = async () => {
|
|
65
|
-
if (Date.now() - cachedAt < LIST_CACHE_MS)
|
|
66
|
-
return cached;
|
|
67
|
-
const list = await readFileList(session.server, session.apiKey, session.code, session.accountId);
|
|
68
|
-
cached = list.manifest === null ? [] : list.manifest.entries;
|
|
69
|
-
cachedAt = Date.now();
|
|
70
|
-
return cached;
|
|
71
|
-
};
|
|
72
48
|
/**
|
|
73
49
|
* Where the pieces of a multipart upload wait until they are one file.
|
|
74
50
|
*
|
|
@@ -78,109 +54,58 @@ export async function s3(options = {}) {
|
|
|
78
54
|
* takes and afterwards.
|
|
79
55
|
*/
|
|
80
56
|
const stagingRoot = join(tmpdir(), `nmts-s3-${randomUUID()}`);
|
|
81
|
-
/**
|
|
82
|
-
* Store one local file at a drive key, making the folders above it if they are missing.
|
|
83
|
-
*
|
|
84
|
-
* ⛔ THE SAME-FILE QUESTION IS ANSWERED HERE AND NOWHERE ELSE.
|
|
85
|
-
* Both ways of uploading — one PUT, or pieces staged and joined — end in this
|
|
86
|
-
* function, so a rule written here cannot disagree with itself; written in the protocol layer
|
|
87
|
-
* it would have to be written twice, once for each, and the two would differ the first time
|
|
88
|
-
* one of them changed. What is compared is the plaintext's SHA-256 against the one this
|
|
89
|
-
* account sealed when the file was first stored.
|
|
90
|
-
*
|
|
91
|
-
* ⛔ IDENTICAL CONTENT IS NOT AN ERROR. Nothing is sent and nothing is charged, and the caller
|
|
92
|
-
* is told the upload finished — because the statement it was making, "that file is at that
|
|
93
|
-
* key", is true. Answering 409 there is what made every backup run fail on every file it had
|
|
94
|
-
* already stored, and a sync tool writes 409 down as a failure.
|
|
95
|
-
*/
|
|
96
|
-
const storeFile = async (key, path) => {
|
|
97
|
-
const at = key.lastIndexOf("/");
|
|
98
|
-
const folder = at < 0 ? undefined : key.slice(0, at);
|
|
99
|
-
const name = at < 0 ? key : key.slice(at + 1);
|
|
100
|
-
const verdict = await verdictForKey(await entries(), key, session.code, path);
|
|
101
|
-
// ⭐ Already there, byte for byte. This is the whole point: an unchanged file costs nothing to
|
|
102
|
-
// re-offer, so a backup that runs nightly stops paying for the nights nothing changed.
|
|
103
|
-
if (verdict === "same") {
|
|
104
|
-
say(`same ${key} — already stored, nothing sent`);
|
|
105
|
-
return;
|
|
106
|
-
}
|
|
107
|
-
if (verdict !== "free")
|
|
108
|
-
throw refusalFor(verdict, key);
|
|
109
|
-
if (folder !== undefined && folder !== "")
|
|
110
|
-
await ensureFolderPath(session, folder);
|
|
111
|
-
await put(path, {
|
|
112
|
-
server: options.server,
|
|
113
|
-
network: options.network,
|
|
114
|
-
...(folder === undefined || folder === "" ? {} : { to: folder }),
|
|
115
|
-
name,
|
|
116
|
-
write: () => undefined,
|
|
117
|
-
});
|
|
118
|
-
cachedAt = 0;
|
|
119
|
-
};
|
|
120
57
|
// ⛔ THE QUESTION WAS ANSWERED BEFORE THIS STARTED. A gateway cannot ask: its caller is a program
|
|
121
58
|
// and its stdin is not a terminal. `s3` is a medium act (`risk.ts`) — uploads through it spend
|
|
122
59
|
// credits — so the tier gate asked at the start, or the mode waved it through, and every write
|
|
123
60
|
// from here on is what was agreed to.
|
|
124
61
|
const writable = true;
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
await rm([`/${object.key}`], {
|
|
155
|
-
server: options.server,
|
|
156
|
-
network: options.network,
|
|
157
|
-
write: () => undefined,
|
|
158
|
-
});
|
|
159
|
-
cachedAt = 0;
|
|
160
|
-
},
|
|
161
|
-
},
|
|
162
|
-
}
|
|
163
|
-
: {}),
|
|
164
|
-
// The real reader. The gateway takes it as a function so its own tests can be driven by a
|
|
165
|
-
// real S3 client without an account, a network or anybody's credits.
|
|
166
|
-
fetch: async (object, sink) => {
|
|
167
|
-
const wrapped = object.entry.dekWrapped;
|
|
168
|
-
if (wrapped === undefined)
|
|
169
|
-
throw new NmtsError("That entry has no key in the file list.");
|
|
170
|
-
await fetchFile({
|
|
171
|
-
base: session.server,
|
|
172
|
-
apiKey: session.apiKey,
|
|
173
|
-
accountCode: session.code,
|
|
174
|
-
itemId: object.entry.id,
|
|
175
|
-
size: object.size,
|
|
176
|
-
dekWrapped: wrapped,
|
|
177
|
-
contentHashCt: object.entry.contentHashCt,
|
|
178
|
-
chain,
|
|
179
|
-
sink,
|
|
62
|
+
/**
|
|
63
|
+
* This account, as the shared drive module takes it.
|
|
64
|
+
*
|
|
65
|
+
* ⛔ THE SIX FUNCTIONS ARE THE ONLY THING THIS COMMAND CONTRIBUTES. What an upload DOES — the
|
|
66
|
+
* same-file verdict, the folders above the key, forgetting the cached list — lives in
|
|
67
|
+
* `s3/drive.ts`, so that the gateway a business runs from the SDK and the one a person runs
|
|
68
|
+
* here cannot come to disagree about it.
|
|
69
|
+
*/
|
|
70
|
+
const source = createDriveSource({
|
|
71
|
+
stagingRoot,
|
|
72
|
+
writable,
|
|
73
|
+
onAlreadyStored: (key) => say(`same ${key} — already stored, nothing sent`),
|
|
74
|
+
account: {
|
|
75
|
+
readList: async () => {
|
|
76
|
+
const list = await readFileList(session.server, session.apiKey, session.code, session.accountId);
|
|
77
|
+
return list.manifest === null ? [] : list.manifest.entries;
|
|
78
|
+
},
|
|
79
|
+
// The command holds the code for its whole run: a person started it and is standing there.
|
|
80
|
+
withCode: (use) => use(session.code),
|
|
81
|
+
makeFolder: async (folder) => {
|
|
82
|
+
await ensureFolderPath(session, folder);
|
|
83
|
+
},
|
|
84
|
+
store: async (path, name, folder) => {
|
|
85
|
+
await put(path, {
|
|
86
|
+
server: options.server,
|
|
87
|
+
network: options.network,
|
|
88
|
+
...(folder === undefined ? {} : { to: folder }),
|
|
89
|
+
name,
|
|
90
|
+
write: () => undefined,
|
|
180
91
|
});
|
|
181
92
|
},
|
|
93
|
+
trash: async (path) => {
|
|
94
|
+
await rm([path], {
|
|
95
|
+
server: options.server,
|
|
96
|
+
network: options.network,
|
|
97
|
+
write: () => undefined,
|
|
98
|
+
});
|
|
99
|
+
},
|
|
100
|
+
fetch: (object, sink) => fetchObject({ server: session.server, bearer: session.apiKey, code: session.code, chain }, object, sink),
|
|
182
101
|
},
|
|
183
102
|
});
|
|
103
|
+
const server = createGateway({
|
|
104
|
+
credentials: [credential],
|
|
105
|
+
// One name, one drive — which is what a bucket is for a person serving their own account.
|
|
106
|
+
bucketOf: (name) => (name === BUCKET ? source : null),
|
|
107
|
+
bucketNames: () => [BUCKET],
|
|
108
|
+
});
|
|
184
109
|
await new Promise((resolve, reject) => {
|
|
185
110
|
server.once("error", (error) => {
|
|
186
111
|
reject(error.code === "EADDRINUSE"
|