@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
|
@@ -0,0 +1,541 @@
|
|
|
1
|
+
// The drive's own edits without a terminal: making a folder, moving, renaming, and the two halves
|
|
2
|
+
// of the trash — decided, written, and handed back rather than printed.
|
|
3
|
+
//
|
|
4
|
+
// ⛔ ONE IMPLEMENTATION, TWO CALLERS, WHICH IS THE WHOLE REASON THIS FILE EXISTS. The commands in
|
|
5
|
+
// `commands/organise.ts` and `commands/trash.ts` are a terminal's shape: they print sentences
|
|
6
|
+
// and answer an exit code. The SDK is somebody else's program and needs the same five verbs
|
|
7
|
+
// with neither. A second implementation of "what does moving onto a taken name do" would be a
|
|
8
|
+
// second place for the compare-and-swap rules to be got right, and the copy nobody re-reads is
|
|
9
|
+
// the one that quietly disagrees — which is the failure this package has already had once, in
|
|
10
|
+
// the two `mkdir` paths that produced `photos (2)` while printing `Made "photos"`.
|
|
11
|
+
//
|
|
12
|
+
// ⛔ NOTHING HERE WRITES TO A STREAM OR PICKS AN EXIT CODE. Every refusal is thrown and every
|
|
13
|
+
// outcome is returned; the words a person reads are the caller's.
|
|
14
|
+
//
|
|
15
|
+
// ⛔ AND EVERY REFUSAL THIS FILE MAKES CARRIES A CODE. A terminal reads the sentence; a program
|
|
16
|
+
// reads `error.code` and branches on it. That is why `DriveEditError` EXTENDS the tool's own
|
|
17
|
+
// error rather than replacing it: the exit code and the next step are still there, `renderError`
|
|
18
|
+
// prints exactly what it printed before, and `error instanceof NmtsError` is still true.
|
|
19
|
+
//
|
|
20
|
+
// ⚠ THE ARGUMENT-SHAPE REFUSALS ARE NOT HERE. "`nmts rename` needs what to rename" is about a
|
|
21
|
+
// command line, and a library caller has no command line to be told about. What IS here is every
|
|
22
|
+
// refusal about the LIST, because that one is the same question whoever asked it.
|
|
23
|
+
import { randomUUID } from "node:crypto";
|
|
24
|
+
import { buildIndex, entryAt, folderIdFor, fullPathOf, isLive, KIND_FILE, KIND_FOLDER, namesIn, normaliseName, normalisePath, } from "./drive-paths.js";
|
|
25
|
+
import { NmtsError } from "./errors.js";
|
|
26
|
+
import { setTrashed } from "./item-trash.js";
|
|
27
|
+
import { readFileList } from "./manifest.js";
|
|
28
|
+
import { applyManyToList, applyToList, batchTargets } from "./manifest-write.js";
|
|
29
|
+
import { applyIntent } from "./shared/lib/drive/manifest-ops.js";
|
|
30
|
+
/** A refusal about the list, with the sentence a person reads and the word a program reads. */
|
|
31
|
+
export class DriveEditError extends NmtsError {
|
|
32
|
+
code;
|
|
33
|
+
constructor(code, message, options = {}) {
|
|
34
|
+
super(message, options);
|
|
35
|
+
this.name = "DriveEditError";
|
|
36
|
+
this.code = code;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Run a path lookup, and label whatever it refused as `NOT_FOUND`.
|
|
41
|
+
*
|
|
42
|
+
* ⛔ THE SENTENCE, THE EXIT CODE AND THE NEXT STEP ARE CARRIED THROUGH UNTOUCHED. `drive-paths.ts`
|
|
43
|
+
* words three different failures — nothing there, it is in the trash, it names two things — and
|
|
44
|
+
* each of them is better than anything this file could say about them. What is added is the one
|
|
45
|
+
* thing it cannot carry: a code, so a program does not have to read English to know a path did
|
|
46
|
+
* not resolve.
|
|
47
|
+
*/
|
|
48
|
+
function resolving(body) {
|
|
49
|
+
try {
|
|
50
|
+
return body();
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
if (error instanceof NmtsError && !(error instanceof DriveEditError)) {
|
|
54
|
+
throw new DriveEditError("NOT_FOUND", error.message, {
|
|
55
|
+
exitCode: error.exitCode,
|
|
56
|
+
nextStep: error.nextStep,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
throw error;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* A new name is a name and not a path.
|
|
64
|
+
*
|
|
65
|
+
* ⛔ REFUSED RATHER THAN SPLIT. A name with a `/` in it is somebody asking for a move while typing
|
|
66
|
+
* a rename, and quietly doing the move would put the file somewhere they did not look.
|
|
67
|
+
*/
|
|
68
|
+
export function requireNewName(name) {
|
|
69
|
+
if (name.trim() === "") {
|
|
70
|
+
throw new DriveEditError("BAD_NAME", "A name cannot be empty.", {
|
|
71
|
+
exitCode: 2,
|
|
72
|
+
nextStep: "Nothing was renamed.",
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
if (name.includes("/")) {
|
|
76
|
+
throw new DriveEditError("BAD_NAME", `A name cannot contain "/" — that is what makes it a path.`, {
|
|
77
|
+
exitCode: 2,
|
|
78
|
+
nextStep: `To move it, use \`nmts mv\`. Nothing was renamed.`,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
return name;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Make a folder path, and every folder above it that is missing, for an account already opened.
|
|
85
|
+
*
|
|
86
|
+
* ⛔ THE RULES BELOW ARE THE ONES A SECOND COPY WOULD GET SUBTLY WRONG: a folder that is already
|
|
87
|
+
* there IS the folder asked for (never a numbered one), the decision is taken inside each
|
|
88
|
+
* attempt so a lost race cannot make two, and what was made before a failure is named rather
|
|
89
|
+
* than silently kept.
|
|
90
|
+
*
|
|
91
|
+
* ⚠ MISSING PARENTS ARE CREATED, and that is a decision rather than a convenience. A folder costs
|
|
92
|
+
* nothing, holds nothing and can be moved to the trash, so the failure mode of creating one too
|
|
93
|
+
* many is a tidy-up; the failure mode of refusing is a caller that has to discover the tree one
|
|
94
|
+
* call at a time. Every folder made is named in the result, so it is never a surprise.
|
|
95
|
+
*/
|
|
96
|
+
export async function ensureFolderPath(input, wanted) {
|
|
97
|
+
const made = [];
|
|
98
|
+
let parentId = null;
|
|
99
|
+
let walked = "";
|
|
100
|
+
for (const name of wanted.split("/")) {
|
|
101
|
+
// ⚠ A name that is only spaces is refused too. `mkdir` used to accept it, and then `rm` and
|
|
102
|
+
// `restore` rejected the very path `ls` printed for it as "no path given" — a code-2 message
|
|
103
|
+
// blaming the caller for an argument they had supplied (2026-08-23).
|
|
104
|
+
if (name.trim() === "" || name === "." || name === "..") {
|
|
105
|
+
throw new DriveEditError("BAD_NAME", `"${wanted}" is not a folder path this tool will make.`, {
|
|
106
|
+
exitCode: 2,
|
|
107
|
+
nextStep: `Empty names, "." and ".." are not folder names in a drive. Nothing was made.`,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
walked = walked === "" ? name : `${walked}/${name}`;
|
|
111
|
+
const under = parentId;
|
|
112
|
+
const here = walked;
|
|
113
|
+
const fresh = randomUUID();
|
|
114
|
+
let landedOn = fresh;
|
|
115
|
+
// ⛔ ONE WRITE PER FOLDER, and the check that decides whether to write happens INSIDE the
|
|
116
|
+
// attempt. Two things went wrong when it sat outside (2026-08-23):
|
|
117
|
+
// · running `mkdir` twice at the same moment made `shared` AND `shared (2)`, because the
|
|
118
|
+
// loser of the compare-and-swap re-applied a decision taken against the older list;
|
|
119
|
+
// · a trashed folder of the same name made the second `mkdir` produce `photos (2)` while
|
|
120
|
+
// printing `Made "photos"`, because it went through the upload helper — and picking a
|
|
121
|
+
// free name is the right rule for BYTES and the wrong rule for a folder. A folder with
|
|
122
|
+
// that name in that parent IS the folder that was asked for.
|
|
123
|
+
// Building the whole chain in memory and writing once would be fewer round trips and would
|
|
124
|
+
// also mean a lost compare-and-swap threw away folders the ones below already point at.
|
|
125
|
+
const result = await applyToList(input, (entries) => {
|
|
126
|
+
const there = entries.find((e) => e.parentId === under &&
|
|
127
|
+
normaliseName(e.name) === normaliseName(name) &&
|
|
128
|
+
e.deletedAt === undefined);
|
|
129
|
+
if (there !== undefined) {
|
|
130
|
+
if (there.kind !== KIND_FOLDER) {
|
|
131
|
+
throw new DriveEditError("NAME_TAKEN", `"${here}" is a file, so nothing can be made inside it.`, {
|
|
132
|
+
exitCode: 4,
|
|
133
|
+
nextStep: made.length > 0 ? `The folders made so far are kept: ${made.join(", ")}.` : "Nothing was made.",
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
landedOn = there.id;
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
landedOn = fresh;
|
|
140
|
+
const at = Date.now();
|
|
141
|
+
return {
|
|
142
|
+
op: "add",
|
|
143
|
+
entry: { id: fresh, parentId: under, kind: KIND_FOLDER, name, size: 0, createdAt: at, updatedAt: at },
|
|
144
|
+
};
|
|
145
|
+
}).catch((error) => {
|
|
146
|
+
// ⛔ WHAT SURVIVED IS NAMED. A run that stops half way leaves real folders behind, and the
|
|
147
|
+
// message that says so was attached only to the "that is a file" refusal.
|
|
148
|
+
if (error instanceof NmtsError || made.length === 0)
|
|
149
|
+
throw error;
|
|
150
|
+
const because = error instanceof Error ? error.message : "the server refused";
|
|
151
|
+
throw new NmtsError(because, {
|
|
152
|
+
exitCode: 1,
|
|
153
|
+
nextStep: `The folders made so far are kept: ${made.join(", ")}. Running the same command again ` +
|
|
154
|
+
`makes the rest — nothing is lost.`,
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
if (result.changed)
|
|
158
|
+
made.push(here);
|
|
159
|
+
parentId = landedOn;
|
|
160
|
+
}
|
|
161
|
+
return { parentId, made };
|
|
162
|
+
}
|
|
163
|
+
/** Make one folder path. A path that is already there is a success with nothing made. */
|
|
164
|
+
export async function makeFolder(input, path) {
|
|
165
|
+
const wanted = normalisePath(path);
|
|
166
|
+
if (wanted === "") {
|
|
167
|
+
throw new DriveEditError("BAD_NAME", `"${path}" names the whole drive, not a folder in it.`, {
|
|
168
|
+
exitCode: 2,
|
|
169
|
+
nextStep: "Nothing was made.",
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
const { parentId, made } = await ensureFolderPath(input, wanted);
|
|
173
|
+
return { path: wanted, parentId, made };
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Move things into a folder. An empty destination is the top of the drive.
|
|
177
|
+
*
|
|
178
|
+
* ⛔ ONE WRITE FOR THE WHOLE RUN, however many things are named. The list is rewritten whole on
|
|
179
|
+
* every save, so a second thing costs nothing extra — while a second WRITE is a second chance
|
|
180
|
+
* to lose the compare-and-swap, and losing it half way through a run leaves some things moved
|
|
181
|
+
* and some not, which is a state the caller cannot tell apart from the one it asked for.
|
|
182
|
+
*
|
|
183
|
+
* ⛔ AND THE NAME CHECK RUNS AGAINST WHAT THIS RUN HAS ALREADY MOVED, not against the list as it
|
|
184
|
+
* was read. Two files called `notes.txt` in two folders, moved into one folder by one call,
|
|
185
|
+
* would otherwise both be written — two entries at one path, which nothing can address
|
|
186
|
+
* afterwards: every lookup answers "names 2 things in this account". So the loop folds each
|
|
187
|
+
* move onto a working copy and asks the working copy the next question.
|
|
188
|
+
*/
|
|
189
|
+
export async function moveEntries(input, paths, destination) {
|
|
190
|
+
const at = Date.now();
|
|
191
|
+
let moved = [];
|
|
192
|
+
let already = [];
|
|
193
|
+
let parentId = null;
|
|
194
|
+
// ⛔ EVERY GUARD RUNS INSIDE THE ATTEMPT, INCLUDING WHICH ENTRY EACH PATH NAMES. A lost
|
|
195
|
+
// compare-and-swap re-applies the intent to a list that changed underneath — and when the
|
|
196
|
+
// winner had just taken this name, the loser landed on top of it and produced two entries at
|
|
197
|
+
// one path. Meanwhile the caller was told the move had been made.
|
|
198
|
+
const result = await applyManyToList(input, (now) => {
|
|
199
|
+
const targets = resolving(() => batchTargets(now, paths, { nothingHappened: "Nothing was moved." }));
|
|
200
|
+
const into = resolving(() => folderIdFor(destination, now, "Nothing was moved."));
|
|
201
|
+
const index = buildIndex(now);
|
|
202
|
+
const intents = [];
|
|
203
|
+
const carried = [];
|
|
204
|
+
const there = [];
|
|
205
|
+
let working = now;
|
|
206
|
+
for (const target of targets) {
|
|
207
|
+
if (into !== null && (into === target.id || isUnder(working, into, target.id))) {
|
|
208
|
+
throw new DriveEditError("INTO_ITSELF", `A folder cannot be moved inside itself.`, {
|
|
209
|
+
exitCode: 4,
|
|
210
|
+
nextStep: "Nothing was moved.",
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
if (into === target.parentId) {
|
|
214
|
+
there.push(target.name);
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
if (namesIn(working, into).has(normaliseName(target.name))) {
|
|
218
|
+
throw new DriveEditError("NAME_TAKEN", `Something called "${target.name}" is already in that folder.`, {
|
|
219
|
+
exitCode: 4,
|
|
220
|
+
nextStep: `Nothing was moved. Rename it first: nmts rename "${target.name}" <new name>`,
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
const intent = { op: "move", id: target.id, parentId: into, at };
|
|
224
|
+
intents.push(intent);
|
|
225
|
+
working = applyIntent(working, intent);
|
|
226
|
+
carried.push({ id: target.id, name: target.name, from: fullPathOf(index, target) });
|
|
227
|
+
}
|
|
228
|
+
moved = carried;
|
|
229
|
+
already = there;
|
|
230
|
+
parentId = into;
|
|
231
|
+
return intents;
|
|
232
|
+
});
|
|
233
|
+
// ⚠ Read off the list AS WRITTEN, not off the intents: an id another device took out of the list
|
|
234
|
+
// meanwhile has no path any more, and claiming one would name a place nothing is at.
|
|
235
|
+
const after = buildIndex(result.entries);
|
|
236
|
+
return {
|
|
237
|
+
moved: moved.map((m) => {
|
|
238
|
+
const live = result.entries.find((e) => e.id === m.id);
|
|
239
|
+
return { id: m.id, name: m.name, from: m.from, path: live === undefined ? null : fullPathOf(after, live) };
|
|
240
|
+
}),
|
|
241
|
+
already,
|
|
242
|
+
parentId,
|
|
243
|
+
changed: result.changed,
|
|
244
|
+
reappliedAfterConflict: result.reappliedAfterConflict,
|
|
245
|
+
seq: result.seq,
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* Give one thing a new name. The path stays the same otherwise.
|
|
250
|
+
*
|
|
251
|
+
* ⛔ REFUSED RATHER THAN NUMBERED, AND THE REFUSAL IS RE-DECIDED ON EVERY ATTEMPT. An upload picks
|
|
252
|
+
* `report (2).pdf` because nobody was watching; a rename is somebody choosing a name on purpose,
|
|
253
|
+
* and silently giving them a different one is how two files end up looking like a mistake nobody
|
|
254
|
+
* made. Checking once, before the write, was not enough: when another device took the name in
|
|
255
|
+
* between, the retry re-applied the old decision and produced two entries at one path, which
|
|
256
|
+
* nothing can address afterwards (2026-08-23).
|
|
257
|
+
*/
|
|
258
|
+
export async function renameEntry(input, path, name) {
|
|
259
|
+
requireNewName(name);
|
|
260
|
+
const list = await readFileList(input.server, input.apiKey, input.code, input.accountId);
|
|
261
|
+
const entries = list.manifest?.entries ?? [];
|
|
262
|
+
const target = resolving(() => entryAt(entries, path, { nothingHappened: "Nothing was renamed." }));
|
|
263
|
+
const at = Date.now();
|
|
264
|
+
const fromPath = fullPathOf(buildIndex(entries), target);
|
|
265
|
+
const result = await applyToList(input, (now) => {
|
|
266
|
+
const live = now.find((e) => e.id === target.id);
|
|
267
|
+
if (live === undefined)
|
|
268
|
+
return null;
|
|
269
|
+
if (normaliseName(name) !== normaliseName(live.name) && namesIn(now, live.parentId).has(normaliseName(name))) {
|
|
270
|
+
throw new DriveEditError("NAME_TAKEN", `Something called "${name}" is already in that folder.`, {
|
|
271
|
+
exitCode: 4,
|
|
272
|
+
nextStep: "Nothing was renamed.",
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
return { op: "rename", id: target.id, name, at };
|
|
276
|
+
});
|
|
277
|
+
return {
|
|
278
|
+
id: target.id,
|
|
279
|
+
from: target.name,
|
|
280
|
+
fromPath,
|
|
281
|
+
to: name,
|
|
282
|
+
changed: result.changed,
|
|
283
|
+
reappliedAfterConflict: result.reappliedAfterConflict,
|
|
284
|
+
seq: result.seq,
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* Move things to the trash, or bring them back.
|
|
289
|
+
*
|
|
290
|
+
* ⛔ NEITHER HALF DESTROYS ANYTHING. `rm` moves everything it is given to the trash, where it stays
|
|
291
|
+
* restorable for thirty days; the endpoint that erases a stored row for good is closed to an API
|
|
292
|
+
* key and stays closed, so nothing here can reach it.
|
|
293
|
+
*
|
|
294
|
+
* ⛔ THE SERVER ROW GOES FIRST, AND "ALREADY DONE" COUNTS AS DONE. A trashed item's bytes cannot be
|
|
295
|
+
* fetched, so the state to avoid above all others is a list that shows a file as live when the
|
|
296
|
+
* server has already trashed it: the person sees it, asks for it, and is told it does not exist.
|
|
297
|
+
* Writing the list only after the server agreed means a failed server call leaves the drive
|
|
298
|
+
* exactly as it was — the state a caller can act on.
|
|
299
|
+
*
|
|
300
|
+
* ⛔ AND ONE PATH THAT WILL NOT RESOLVE REFUSES THE WHOLE RUN, before a single server row is
|
|
301
|
+
* touched. Trashing four of the five things somebody named and answering success is worse than
|
|
302
|
+
* trashing none: the run reads as done, and finding the odd one out means diffing the drive.
|
|
303
|
+
*/
|
|
304
|
+
export async function trashPaths(input, verb, paths, options = {}) {
|
|
305
|
+
const list = await readFileList(input.server, input.apiKey, input.code, input.accountId);
|
|
306
|
+
const entries = list.manifest?.entries ?? [];
|
|
307
|
+
// ⛔ `rm` REFUSES what is already in the trash rather than quietly doing nothing, so the caller
|
|
308
|
+
// learns nothing was needed; `restore` has to be able to SEE the trash to act on it. That is
|
|
309
|
+
// why the two lookups differ.
|
|
310
|
+
const index = buildIndex(entries);
|
|
311
|
+
const found = resolving(() => batchTargets(entries, paths, {
|
|
312
|
+
...(verb === "restore" ? { includeTrashed: true } : {}),
|
|
313
|
+
nothingHappened: "Nothing changed.",
|
|
314
|
+
}));
|
|
315
|
+
const acting = [];
|
|
316
|
+
const skipped = [];
|
|
317
|
+
for (const entry of found) {
|
|
318
|
+
const at = fullPathOf(index, entry);
|
|
319
|
+
if (verb === "restore" && isLive(index, entry)) {
|
|
320
|
+
// Already in the state being asked for. Named, and left alone — unless the caller is a
|
|
321
|
+
// program, which cannot read a line about it.
|
|
322
|
+
if (options.strict === true) {
|
|
323
|
+
throw new DriveEditError("NOT_IN_TRASH", `"${at}" is not in the trash.`, {
|
|
324
|
+
exitCode: 4,
|
|
325
|
+
nextStep: "Nothing changed. Only something in the trash can be restored.",
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
skipped.push(at);
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
if (verb === "restore" && entry.deletedAt === undefined) {
|
|
332
|
+
// In the trash, but only because something above it is. Restoring this row would clear a
|
|
333
|
+
// `deletedAt` it does not have and leave the person exactly where they were.
|
|
334
|
+
throw new DriveEditError("NOT_IN_TRASH", `"${at}" is in the trash because a folder above it is.`, {
|
|
335
|
+
exitCode: 4,
|
|
336
|
+
nextStep: `Nothing changed. Restore that folder instead — \`nmts ls --all\` shows which one carries the trash.`,
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
acting.push({ entry, path: at });
|
|
340
|
+
}
|
|
341
|
+
// ⛔ NAMING A FOLDER AND SOMETHING INSIDE IT IS NAMING ONE TRASHING TWICE, and only for `rm` is
|
|
342
|
+
// that a problem worth solving here: stamping the child as well would give it a thirty-day
|
|
343
|
+
// clock of its own, and then restoring the folder would leave it behind — the person would
|
|
344
|
+
// have to remember they had also named it to ever find it again. Its bytes are covered either
|
|
345
|
+
// way, because the rows are read from the folder. `restore` is the opposite case: a child with
|
|
346
|
+
// its own instant needs its own clearing, so nothing is dropped there.
|
|
347
|
+
const named = new Set(acting.map((t) => t.entry.id));
|
|
348
|
+
const covered = verb === "rm" ? acting.filter((t) => hasNamedAncestor(entries, t.entry, named)) : [];
|
|
349
|
+
const targets = acting.filter((t) => !covered.includes(t));
|
|
350
|
+
for (const t of covered)
|
|
351
|
+
skipped.push(t.path);
|
|
352
|
+
if (targets.length === 0) {
|
|
353
|
+
// Everything named was already where it was asked to be. A no-op is a success: writing the
|
|
354
|
+
// list would cost every other device a download for nothing.
|
|
355
|
+
return { paths: [], ids: [], files: 0, skipped, changed: false, reappliedAfterConflict: false, seq: list.seq ?? 0 };
|
|
356
|
+
}
|
|
357
|
+
// Every FILE at or under the targets — a folder holds no bytes and has no server row, so the
|
|
358
|
+
// rows to move are its file descendants.
|
|
359
|
+
//
|
|
360
|
+
// ⛔ THE ROWS TO MOVE ARE THE ONES THE EDIT WILL MAKE REACHABLE, so the set is read off a PREVIEW
|
|
361
|
+
// of the list rather than guessed (2026-08-23). `rm` is easy — everything under the target
|
|
362
|
+
// loses its bytes. `restore` is not: a file the person deleted separately last week keeps its
|
|
363
|
+
// own `deletedAt`, stays in the trash after the folder comes back, and its row must stay
|
|
364
|
+
// deleted with it. Restoring that row would cancel its own thirty-day sweep, go on costing
|
|
365
|
+
// storage, and leave the list saying "trashed" while the server says "live" — after which
|
|
366
|
+
// `rm` refuses to put it back and there is no way out.
|
|
367
|
+
const at = Date.now();
|
|
368
|
+
const ids = targets.map((t) => t.entry.id);
|
|
369
|
+
const preview = buildIndex(applyIntent(entries, intentFor(verb, ids, at)));
|
|
370
|
+
const under = uniqueById(targets.flatMap((t) => filesUnder(entries, t.entry.id)));
|
|
371
|
+
// ⚠ Judged on the PREVIEW's own row, not on the one in hand: `applyIntent` returns new objects,
|
|
372
|
+
// so asking the preview about the old object reads the old `deletedAt` and answers "still
|
|
373
|
+
// trashed" for the very thing being restored.
|
|
374
|
+
const files = verb === "rm"
|
|
375
|
+
? under
|
|
376
|
+
: under.filter((f) => {
|
|
377
|
+
const after = preview.byId.get(f.id);
|
|
378
|
+
return after !== undefined && isLive(preview, after);
|
|
379
|
+
});
|
|
380
|
+
let done = 0;
|
|
381
|
+
try {
|
|
382
|
+
for (const file of files) {
|
|
383
|
+
await setTrashed(input.server, input.apiKey, file.id, verb === "rm");
|
|
384
|
+
done += 1;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
catch (error) {
|
|
388
|
+
// ⛔ A HALF-FINISHED RUN MUST NAME ITSELF. Without this an agent sees six words of stderr and
|
|
389
|
+
// the tool's own guidance ("a refusal is not a transient error, do not retry in a loop")
|
|
390
|
+
// steers it away from the one thing that fixes this — running the same command again.
|
|
391
|
+
const because = error instanceof Error ? error.message : "the server refused";
|
|
392
|
+
throw new NmtsError(because, {
|
|
393
|
+
exitCode: 1,
|
|
394
|
+
nextStep: `${done} of ${files.length} file rows were moved before this stopped, and the file list was ` +
|
|
395
|
+
`not written. Running \`nmts ${verb}\` on the same paths again finishes the job — nothing is lost.`,
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
// ⛔ THE IDS ARE DECIDED AGAIN ON EVERY ATTEMPT, and this is not ceremony. Between the read above
|
|
399
|
+
// and the write below another device can put one of these targets in the trash — by trashing
|
|
400
|
+
// it, or by moving it under a folder that already is. Re-applying the intent we built earlier
|
|
401
|
+
// would then stamp `deletedAt` on something that is ALREADY in the trash by inheritance,
|
|
402
|
+
// giving it a clock of its own and quietly detaching it from the folder it came with:
|
|
403
|
+
// restoring that folder afterwards would leave it behind. An id that has left the list
|
|
404
|
+
// entirely is dropped for the reason `manifest-ops.ts` gives — the other device removing it is
|
|
405
|
+
// newer information than our edit, and putting it back would undo a deletion somebody made on
|
|
406
|
+
// purpose.
|
|
407
|
+
const writing = applyManyToList(input, (now) => {
|
|
408
|
+
const nowIndex = buildIndex(now);
|
|
409
|
+
const still = ids.filter((id) => {
|
|
410
|
+
const live = nowIndex.byId.get(id);
|
|
411
|
+
if (live === undefined)
|
|
412
|
+
return false;
|
|
413
|
+
return verb === "rm" ? isLive(nowIndex, live) : live.deletedAt !== undefined;
|
|
414
|
+
});
|
|
415
|
+
if (verb === "restore" && options.strict === true)
|
|
416
|
+
refuseTakenNames(now, nowIndex.byId, still);
|
|
417
|
+
return still.length === 0 ? [] : [intentFor(verb, still, at)];
|
|
418
|
+
});
|
|
419
|
+
// ⛔ A STRICT RESTORE REFUSED HERE HAS ALREADY MOVED ITS ROWS. The name was free when the trash
|
|
420
|
+
// was read and taken by the time the list was written, so the rows above are live while the
|
|
421
|
+
// list still says trashed — the state the note on `files` calls no way out. Put the rows back
|
|
422
|
+
// before the refusal leaves. If putting them back fails too, the refusal still leaves as it
|
|
423
|
+
// is: restoring again after the rename moves the same rows and writes the list.
|
|
424
|
+
const result = await writing.catch(async (error) => {
|
|
425
|
+
if (verb === "restore" && error instanceof DriveEditError && error.code === "NAME_TAKEN") {
|
|
426
|
+
for (const file of files) {
|
|
427
|
+
await setTrashed(input.server, input.apiKey, file.id, true).catch(() => undefined);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
throw error;
|
|
431
|
+
});
|
|
432
|
+
return {
|
|
433
|
+
paths: targets.map((t) => t.path),
|
|
434
|
+
ids,
|
|
435
|
+
files: files.length,
|
|
436
|
+
skipped,
|
|
437
|
+
changed: result.changed,
|
|
438
|
+
reappliedAfterConflict: result.reappliedAfterConflict,
|
|
439
|
+
seq: result.seq,
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
/**
|
|
443
|
+
* Refuse a restore that would land beside a live thing of the same name.
|
|
444
|
+
*
|
|
445
|
+
* ⛔ DECIDED INSIDE THE ATTEMPT like every other guard here: the name that was free when the trash
|
|
446
|
+
* was read can be taken by the time the list is written, and two entries at one path is a state
|
|
447
|
+
* no lookup can get out of.
|
|
448
|
+
*/
|
|
449
|
+
function refuseTakenNames(entries, byId, ids) {
|
|
450
|
+
const index = buildIndex(entries);
|
|
451
|
+
for (const id of ids) {
|
|
452
|
+
const entry = byId.get(id);
|
|
453
|
+
if (entry === undefined)
|
|
454
|
+
continue;
|
|
455
|
+
const holder = entries.find((e) => e.id !== id && e.parentId === entry.parentId && normaliseName(e.name) === normaliseName(entry.name) && isLive(index, e));
|
|
456
|
+
if (holder === undefined)
|
|
457
|
+
continue;
|
|
458
|
+
throw new DriveEditError("NAME_TAKEN", `Something called "${entry.name}" is already in that folder.`, {
|
|
459
|
+
exitCode: 4,
|
|
460
|
+
nextStep: "The file list was not changed. Rename the one that is there, then restore again.",
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
/** The one intent either half of the trash writes. Built in two places, so it is spelled in one. */
|
|
465
|
+
function intentFor(verb, ids, at) {
|
|
466
|
+
return verb === "rm" ? { op: "trash", ids, at } : { op: "restore", ids, at };
|
|
467
|
+
}
|
|
468
|
+
/** Is `id` at or under `rootId`? Used to refuse moving a folder into its own subtree. */
|
|
469
|
+
function isUnder(entries, id, rootId) {
|
|
470
|
+
const byId = buildIndex(entries).byId;
|
|
471
|
+
const seen = new Set();
|
|
472
|
+
let at = id;
|
|
473
|
+
while (at !== null && !seen.has(at)) {
|
|
474
|
+
if (at === rootId)
|
|
475
|
+
return true;
|
|
476
|
+
seen.add(at);
|
|
477
|
+
at = byId.get(at)?.parentId ?? null;
|
|
478
|
+
}
|
|
479
|
+
return false;
|
|
480
|
+
}
|
|
481
|
+
/** Is any ancestor of this entry in the set? Used to drop a target a named folder already covers. */
|
|
482
|
+
function hasNamedAncestor(entries, entry, named) {
|
|
483
|
+
const byId = buildIndex(entries).byId;
|
|
484
|
+
const seen = new Set([entry.id]);
|
|
485
|
+
let at = entry.parentId;
|
|
486
|
+
while (at !== null && !seen.has(at)) {
|
|
487
|
+
if (named.has(at))
|
|
488
|
+
return true;
|
|
489
|
+
seen.add(at);
|
|
490
|
+
at = byId.get(at)?.parentId ?? null;
|
|
491
|
+
}
|
|
492
|
+
return false;
|
|
493
|
+
}
|
|
494
|
+
/** One entry per id, keeping the first. Two named folders can hold the same file only once. */
|
|
495
|
+
function uniqueById(files) {
|
|
496
|
+
const byId = new Map();
|
|
497
|
+
for (const file of files)
|
|
498
|
+
if (!byId.has(file.id))
|
|
499
|
+
byId.set(file.id, file);
|
|
500
|
+
return [...byId.values()];
|
|
501
|
+
}
|
|
502
|
+
/**
|
|
503
|
+
* Every file at or under one entry.
|
|
504
|
+
*
|
|
505
|
+
* ⚠ Trashed descendants are INCLUDED HERE, and the CALLER filters. Somebody who trashed one file
|
|
506
|
+
* last week and then trashes its folder expects the folder to be gone from the server too — so
|
|
507
|
+
* `rm` takes this set whole. `restore` cannot: see the note at the call site.
|
|
508
|
+
*/
|
|
509
|
+
export function filesUnder(entries, rootId) {
|
|
510
|
+
const root = entries.find((e) => e.id === rootId);
|
|
511
|
+
if (root === undefined)
|
|
512
|
+
return [];
|
|
513
|
+
if (root.kind === KIND_FILE)
|
|
514
|
+
return [root];
|
|
515
|
+
const childrenOf = new Map();
|
|
516
|
+
for (const e of entries) {
|
|
517
|
+
const list = childrenOf.get(e.parentId);
|
|
518
|
+
if (list === undefined)
|
|
519
|
+
childrenOf.set(e.parentId, [e]);
|
|
520
|
+
else
|
|
521
|
+
list.push(e);
|
|
522
|
+
}
|
|
523
|
+
const out = [];
|
|
524
|
+
const seen = new Set([rootId]);
|
|
525
|
+
const queue = [rootId];
|
|
526
|
+
while (queue.length > 0) {
|
|
527
|
+
const id = queue.pop();
|
|
528
|
+
if (id === undefined)
|
|
529
|
+
break;
|
|
530
|
+
for (const child of childrenOf.get(id) ?? []) {
|
|
531
|
+
if (seen.has(child.id))
|
|
532
|
+
continue;
|
|
533
|
+
seen.add(child.id);
|
|
534
|
+
if (child.kind === KIND_FILE)
|
|
535
|
+
out.push(child);
|
|
536
|
+
else
|
|
537
|
+
queue.push(child.id);
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
return out;
|
|
541
|
+
}
|
package/dist/product.d.ts
CHANGED
|
@@ -9,7 +9,7 @@ export declare const BINARY_NAME = "nmts";
|
|
|
9
9
|
* beside it. Here rather than in `main.ts` because the MCP server has to say it too, and a
|
|
10
10
|
* command importing the entry point is a cycle waiting to bite.
|
|
11
11
|
*/
|
|
12
|
-
export declare const VERSION = "0.
|
|
12
|
+
export declare const VERSION = "0.38.0";
|
|
13
13
|
/** Where the product lives, for messages that need to send somebody somewhere real. */
|
|
14
14
|
export declare const HOME_URL = "https://nmts.me";
|
|
15
15
|
/** The source, so a person holding only the built program can find what it was built from. */
|
package/dist/product.js
CHANGED
|
@@ -18,7 +18,7 @@ export const BINARY_NAME = "nmts";
|
|
|
18
18
|
* beside it. Here rather than in `main.ts` because the MCP server has to say it too, and a
|
|
19
19
|
* command importing the entry point is a cycle waiting to bite.
|
|
20
20
|
*/
|
|
21
|
-
export const VERSION = "0.
|
|
21
|
+
export const VERSION = "0.38.0";
|
|
22
22
|
/** Where the product lives, for messages that need to send somebody somewhere real. */
|
|
23
23
|
export const HOME_URL = "https://nmts.me";
|
|
24
24
|
/** The source, so a person holding only the built program can find what it was built from. */
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import type { PlaintextSink } from "../download-sink.ts";
|
|
2
|
+
import type { Network } from "../network.ts";
|
|
3
|
+
import type { ManifestEntry } from "../shared/lib/drive/manifest-codec.ts";
|
|
4
|
+
import type { ReadOptions } from "../walrus.ts";
|
|
5
|
+
import type { DriveObject } from "./listing.ts";
|
|
6
|
+
import type { DriveSource } from "./server.ts";
|
|
7
|
+
import { type Staging } from "./staging.ts";
|
|
8
|
+
/**
|
|
9
|
+
* How long a file list may be reused before it is fetched again.
|
|
10
|
+
*
|
|
11
|
+
* ⛔ THERE IS A CACHE BECAUSE A SYNC IS THOUSANDS OF REQUESTS. Reading the list per request would
|
|
12
|
+
* mean a server round trip and a decryption for each one, so a listing of a large drive would
|
|
13
|
+
* take minutes and cost the account's rate budget. ⚠ It also means a file uploaded from another
|
|
14
|
+
* device can be up to this long in appearing here, which is the trade and is written in the
|
|
15
|
+
* tool's own words when it starts.
|
|
16
|
+
*/
|
|
17
|
+
export declare const LIST_CACHE_MS = 5000;
|
|
18
|
+
/**
|
|
19
|
+
* Where a drive comes from, for whoever is running the gateway.
|
|
20
|
+
*
|
|
21
|
+
* ⛔ SIX FUNCTIONS AND NO CREDENTIAL. The command-line tool holds an open session; the SDK holds a
|
|
22
|
+
* client whose key may be in a business's sealed store and is borrowed one call at a time.
|
|
23
|
+
* Nothing in this file may care which, so nothing in this file is handed a key -- `withCode`
|
|
24
|
+
* borrows one for the length of a comparison and the caller decides what that costs.
|
|
25
|
+
*/
|
|
26
|
+
export interface DriveAccount {
|
|
27
|
+
/** The account's file list, read fresh from the server. Empty for an account that has none. */
|
|
28
|
+
readList(): Promise<readonly ManifestEntry[]>;
|
|
29
|
+
/**
|
|
30
|
+
* Borrow the account's code for the length of `use`.
|
|
31
|
+
*
|
|
32
|
+
* ⚠ ASKED FOR ONE THING ONLY: opening the hash this drive recorded for a file already at the key,
|
|
33
|
+
* which is sealed under the account's own data key and cannot be compared without it.
|
|
34
|
+
*/
|
|
35
|
+
withCode<T>(use: (code: string) => Promise<T>): Promise<T>;
|
|
36
|
+
/** Make this folder path, and any folder above it that is missing. */
|
|
37
|
+
makeFolder(path: string): Promise<void>;
|
|
38
|
+
/** Store one local file under `name`, in `folder` — the top of the account when undefined. */
|
|
39
|
+
store(local: string, name: string, folder: string | undefined): Promise<void>;
|
|
40
|
+
/** Send one path — with its leading slash — to the trash, where it stays for thirty days. */
|
|
41
|
+
trash(path: string): Promise<void>;
|
|
42
|
+
/** Fetch, decrypt and deliver one file into the sink. `fetchObject` below is how both do it. */
|
|
43
|
+
fetch(object: DriveObject, sink: PlaintextSink): Promise<void>;
|
|
44
|
+
}
|
|
45
|
+
export interface DriveSourceOptions {
|
|
46
|
+
readonly account: DriveAccount;
|
|
47
|
+
/**
|
|
48
|
+
* Where the pieces of a multipart upload wait until they are one file.
|
|
49
|
+
*
|
|
50
|
+
* ⛔ 0700, AND MADE WHEN IT IS FIRST NEEDED. Pieces are somebody's plaintext; leaving them in a
|
|
51
|
+
* shared temporary directory under a predictable name would put them where any other account
|
|
52
|
+
* on the machine could read them, for as long as the upload takes and afterwards.
|
|
53
|
+
*/
|
|
54
|
+
readonly stagingRoot: string;
|
|
55
|
+
/**
|
|
56
|
+
* False makes this drive read only, and that is a refusal rather than a gap — the gateway
|
|
57
|
+
* answers every write with the sentence naming what would allow it.
|
|
58
|
+
*/
|
|
59
|
+
readonly writable: boolean;
|
|
60
|
+
/**
|
|
61
|
+
* The staging an earlier source for the same bucket was using, when there was one.
|
|
62
|
+
*
|
|
63
|
+
* ⛔ AN UPLOAD IN PIECES OUTLIVES THE SOURCE IT BEGAN UNDER. A caller that rebuilds its sources —
|
|
64
|
+
* a gateway re-asking whose bucket this is — would otherwise hand the next piece to a staging
|
|
65
|
+
* that has never heard of the upload, and a large file could never finish.
|
|
66
|
+
*/
|
|
67
|
+
readonly multipart?: Staging | undefined;
|
|
68
|
+
/** How long a file list may be reused. `LIST_CACHE_MS` unless a caller has a reason. */
|
|
69
|
+
readonly listCacheMs?: number | undefined;
|
|
70
|
+
/**
|
|
71
|
+
* Told the key when it already held exactly these bytes, so nothing was sent.
|
|
72
|
+
*
|
|
73
|
+
* ⭐ NOT AN ERROR. An unchanged file costs nothing to re-offer, which is what stops a backup that
|
|
74
|
+
* runs nightly paying for the nights nothing changed. The words a person reads are the
|
|
75
|
+
* caller's — this file has no terminal.
|
|
76
|
+
*/
|
|
77
|
+
readonly onAlreadyStored?: ((key: string) => void) | undefined;
|
|
78
|
+
}
|
|
79
|
+
/** Everything `fetchObject` needs to open one file: where to ask, and whose key opens it. */
|
|
80
|
+
export interface ObjectReader {
|
|
81
|
+
readonly server: string;
|
|
82
|
+
/**
|
|
83
|
+
* What goes in the one header the server reads: an API key, or a delegation token.
|
|
84
|
+
*
|
|
85
|
+
* ⛔ NAMED FOR WHAT IT IS RATHER THAN FOR ONE OF THE TWO. A field called `apiKey` carrying a
|
|
86
|
+
* delegation token is how a reader comes to believe a delegated client cannot do something it
|
|
87
|
+
* can.
|
|
88
|
+
*/
|
|
89
|
+
readonly bearer: string;
|
|
90
|
+
readonly code: string;
|
|
91
|
+
readonly chain: Network;
|
|
92
|
+
/** Hosts to read stored bytes from, instead of the network's own aggregators. */
|
|
93
|
+
readonly read?: ReadOptions | undefined;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* `photos/2026/a.jpg` → the folder to make and the name to store under.
|
|
97
|
+
*
|
|
98
|
+
* A key with no slash lands at the top of the account, which is `undefined` rather than `""`: the
|
|
99
|
+
* two mean the same thing to a person and different things to the upload path.
|
|
100
|
+
*/
|
|
101
|
+
export declare function placeOf(key: string): {
|
|
102
|
+
folder: string | undefined;
|
|
103
|
+
name: string;
|
|
104
|
+
};
|
|
105
|
+
/** The real reader: the stored bytes, opened with this account's key and delivered to the sink. */
|
|
106
|
+
export declare function fetchObject(reader: ObjectReader, object: DriveObject, sink: PlaintextSink): Promise<void>;
|
|
107
|
+
/** One account as the protocol layer sees it: a cached list, a reader, and a writer when allowed. */
|
|
108
|
+
export declare function createDriveSource(options: DriveSourceOptions): DriveSource;
|