@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 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.36.3 # 버전을 고정할 때
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.36.3 # a pinned version
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
 
@@ -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.36.3";
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
  /**
@@ -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 "./trash.js";
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. A folder costs
12
- * nothing, holds nothing and can be moved to the trash, so the failure mode of creating one too
13
- * many is a tidy-up; the failure mode of refusing is an agent that has to discover the tree one
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. The list is rewritten whole on
34
- * every save, so a second thing costs nothing extra while a second WRITE is a second chance
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 { randomUUID } from "node:crypto";
14
- import { buildIndex, entryAt, folderIdFor, fullPathOf, KIND_FOLDER, namesIn, normaliseName, normalisePath, } from "../drive-paths.js";
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. A folder costs
25
- * nothing, holds nothing and can be moved to the trash, so the failure mode of creating one too
26
- * many is a tidy-up; the failure mode of refusing is an agent that has to discover the tree one
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 ensureFolderPath(session, wanted);
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. The list is rewritten whole on
129
- * every save, so a second thing costs nothing extra while a second WRITE is a second chance
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 at = Date.now();
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: result.changed,
203
- reappliedAfterConflict: result.reappliedAfterConflict,
204
- seq: result.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 (result.changed && result.reappliedAfterConflict) {
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
- if (name.includes("/")) {
232
- throw new NmtsError(`A name cannot contain "/" that is what makes it a path.`, {
233
- exitCode: 2,
234
- nextStep: `To move it, use \`nmts mv\`. Nothing was renamed.`,
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 list = await readFileList(session.server, session.apiKey, session.code, session.accountId);
239
- const entries = list.manifest?.entries ?? [];
240
- const target = entryAt(entries, path, { nothingHappened: "Nothing was renamed." });
241
- const at = Date.now();
242
- const before = fullPathOf(buildIndex(entries), target);
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: target.id,
265
- from: target.name,
117
+ id: outcome.id,
118
+ from: outcome.from,
266
119
  to: name,
267
- changed: result.changed,
268
- reappliedAfterConflict: result.reappliedAfterConflict,
269
- seq: result.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));
@@ -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 "./organise.js";
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`));
@@ -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;
@@ -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 { createWriteStream } from "node:fs";
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 { createStaging } from "../s3/staging.js";
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 "./organise.js";
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
- const server = createGateway({
126
- credential,
127
- source: {
128
- entries,
129
- ...(writable
130
- ? {
131
- write: {
132
- // ⛔ THE BODY IS SPOOLED TO A FILE FIRST, 0600, and deleted whatever happens. The
133
- // upload path reserves storage, cuts parts and seals them from a file, and giving
134
- // it a socket instead would mean either holding whole uploads in memory or
135
- // writing a second upload path — and a second upload path is a second place for
136
- // "what if the reservation succeeds and the part fails" to be got right.
137
- put: async (key, body, size) => {
138
- await makeDir(stagingRoot, { recursive: true, mode: 0o700 });
139
- const spool = join(stagingRoot, randomUUID());
140
- try {
141
- await pipeline(body, createWriteStream(spool, { mode: 0o600 }));
142
- const written = (await stat(spool)).size;
143
- if (written !== size) {
144
- throw new NmtsError(`The upload said ${size} bytes and ${written} arrived. Nothing was stored.`);
145
- }
146
- await storeFile(key, spool);
147
- }
148
- finally {
149
- await removeFile(spool, { force: true });
150
- }
151
- },
152
- multipart: createStaging(stagingRoot, storeFile),
153
- trash: async (object) => {
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"