@needmoretruth/nmts-cli 0.36.3 → 0.38.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/AGENTS.md +22 -0
  2. package/CHANGELOG.md +25 -0
  3. package/README.ko.md +1 -1
  4. package/README.md +1 -1
  5. package/dist/artifact-about.d.ts +1 -1
  6. package/dist/commands/erase.js +1 -1
  7. package/dist/commands/organise.d.ts +5 -27
  8. package/dist/commands/organise.js +33 -193
  9. package/dist/commands/push.js +1 -1
  10. package/dist/commands/s3.d.ts +0 -10
  11. package/dist/commands/s3.js +46 -121
  12. package/dist/commands/trash.d.ts +0 -9
  13. package/dist/commands/trash.js +23 -223
  14. package/dist/drive-edit/errors.d.ts +44 -0
  15. package/dist/drive-edit/errors.js +65 -0
  16. package/dist/drive-edit/folders.d.ts +29 -0
  17. package/dist/drive-edit/folders.js +98 -0
  18. package/dist/drive-edit/move.d.ts +66 -0
  19. package/dist/drive-edit/move.js +119 -0
  20. package/dist/drive-edit/trash.d.ts +53 -0
  21. package/dist/drive-edit/trash.js +190 -0
  22. package/dist/drive-edit/tree.d.ts +15 -0
  23. package/dist/drive-edit/tree.js +77 -0
  24. package/dist/drive-edit.d.ts +9 -0
  25. package/dist/drive-edit.js +23 -0
  26. package/dist/product.d.ts +1 -1
  27. package/dist/product.js +1 -1
  28. package/dist/s3/contract.d.ts +80 -0
  29. package/dist/s3/contract.js +3 -0
  30. package/dist/s3/drive.d.ts +108 -0
  31. package/dist/s3/drive.js +136 -0
  32. package/dist/s3/listing.d.ts +8 -1
  33. package/dist/s3/listing.js +8 -1
  34. package/dist/s3/routes.d.ts +4 -0
  35. package/dist/s3/routes.js +249 -0
  36. package/dist/s3/server.d.ts +15 -53
  37. package/dist/s3/server.js +18 -222
  38. package/dist/s3/sigv4.d.ts +26 -0
  39. package/dist/s3/sigv4.js +37 -0
  40. package/dist/s3/xml.d.ts +8 -1
  41. package/dist/s3/xml.js +13 -4
  42. package/dist/s3-gateway.d.ts +8 -0
  43. package/dist/s3-gateway.js +13 -0
  44. package/docs/commands/create.md +2 -2
  45. package/docs/commands/env.md +1 -1
  46. package/docs/commands/extend.md +0 -3
  47. package/docs/commands/login.md +1 -1
  48. package/docs/commands/logout.md +1 -1
  49. package/docs/commands/marks.md +1 -1
  50. package/docs/commands/mcp.md +1 -1
  51. package/docs/commands/put.md +2 -2
  52. package/docs/commands/wallet.md +7 -9
  53. package/docs/commands/whoami.md +2 -2
  54. package/package.json +9 -1
@@ -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"
@@ -1,4 +1,3 @@
1
- import type { ManifestEntry } from "../shared/lib/drive/manifest-codec.ts";
2
1
  export interface TrashOptions {
3
2
  server?: string | undefined;
4
3
  network?: string | undefined;
@@ -7,11 +6,3 @@ export interface TrashOptions {
7
6
  }
8
7
  export declare function rm(paths: readonly string[], options?: TrashOptions): Promise<number>;
9
8
  export declare function restore(paths: readonly string[], options?: TrashOptions): Promise<number>;
10
- /**
11
- * Every file at or under one entry.
12
- *
13
- * ⚠ Trashed descendants are INCLUDED HERE, and the CALLER filters. Somebody who trashed one file
14
- * last week and then trashes its folder expects the folder to be gone from the server too — so
15
- * `rm` takes this set whole. `restore` cannot: see the note at the call site.
16
- */
17
- export declare function filesUnder(entries: readonly ManifestEntry[], rootId: string): ManifestEntry[];
@@ -1,5 +1,10 @@
1
1
  // `nmts rm` and `nmts restore` — the two halves of the trash.
2
2
  //
3
+ // ⛔ WHAT THEY DO IS IN `drive-edit.ts`; WHAT IS HERE IS THE TERMINAL. The server rows, the list
4
+ // write and every refusal about the list moved out the day the SDK needed the same two verbs
5
+ // without one — and the order of the two (row first, list second) is the rule a second copy
6
+ // would be most likely to get backwards.
7
+ //
3
8
  // ⛔ NEITHER OF THESE DESTROYS ANYTHING. `rm` moves everything it is given to the trash, where it
4
9
  // stays restorable for thirty days; the endpoint that erases a stored row for good is closed to
5
10
  // an API key and stays closed, so no command here can reach it.
@@ -18,25 +23,9 @@
18
23
  // said no verb without an undo would ever live in this tool; that stopped being true the day
19
24
  // the sweep arrived, and a header that goes on describing the world before a change is the way
20
25
  // the next reader is misled.
21
- //
22
- // ⛔ THE SERVER ROW GOES FIRST, AND "ALREADY DONE" COUNTS AS DONE. The order is not arbitrary and
23
- // neither is the forgiveness:
24
- // · A trashed item's bytes cannot be fetched — `GET /v1/items/{id}/parts` requires
25
- // `deleted_at IS NULL`. So the state to avoid above all others is a drive that shows a file
26
- // as live when the server has already trashed it: the person sees it, asks for it, and is
27
- // told it does not exist.
28
- // · Writing the list only after the server agreed means a failed server call leaves the
29
- // drive exactly as it was, which is the state a person can act on.
30
- // · And a 404 from the server means the row is already in the state being asked for, which
31
- // is what an interrupted run leaves behind. Treating it as a failure would make the retry
32
- // of a half-finished command impossible — the one moment the retry is needed.
33
- import { setTrashed } from "../item-trash.js";
34
- import { buildIndex, fullPathOf, isLive, KIND_FILE } from "../drive-paths.js";
26
+ import { trashPaths } from "../drive-edit.js";
35
27
  import { NmtsError } from "../errors.js";
36
- import { readFileList } from "../manifest.js";
37
- import { applyManyToList, batchTargets } from "../manifest-write.js";
38
28
  import { openSession } from "../session.js";
39
- import { applyIntent } from "../shared/lib/drive/manifest-ops.js";
40
29
  export async function rm(paths, options = {}) {
41
30
  return run("rm", paths, options);
42
31
  }
@@ -52,150 +41,30 @@ async function run(verb, paths, options) {
52
41
  });
53
42
  }
54
43
  const session = await openSession(options);
55
- const list = await readFileList(session.server, session.apiKey, session.code, session.accountId);
56
- const entries = list.manifest?.entries ?? [];
57
- // `rm` REFUSES what is already in the trash rather than quietly doing nothing, so the caller
58
- // learns nothing was needed; `restore` has to be able to SEE the trash to act on it. That is
59
- // why the two lookups differ. (The comment that used to sit here claimed a shared lookup would
60
- // make `rm` say "no such path" it would not: the refusal below names the trash either way.
61
- // A reason that does not hold teaches the next reader to keep the wrong branch.)
62
- //
63
- // ⛔ AND ONE PATH THAT WILL NOT RESOLVE REFUSES THE WHOLE RUN, before a single server row is
64
- // touched. Trashing four of the five things somebody named and exiting 0 is worse than
65
- // trashing none: the run reads as done, and finding the odd one out means diffing the drive.
66
- const index = buildIndex(entries);
67
- const found = batchTargets(entries, paths, {
68
- ...(verb === "restore" ? { includeTrashed: true } : {}),
69
- nothingHappened: "Nothing changed.",
70
- });
71
- const acting = [];
72
- const skipped = [];
73
- for (const entry of found) {
74
- const at = fullPathOf(index, entry);
75
- if (verb === "restore" && isLive(index, entry)) {
76
- // Already in the state being asked for. Not a refusal — named, and left alone.
77
- skipped.push(at);
78
- continue;
79
- }
80
- if (verb === "restore" && entry.deletedAt === undefined) {
81
- // In the trash, but only because something above it is. Restoring this row would clear a
82
- // `deletedAt` it does not have and leave the person exactly where they were.
83
- throw new NmtsError(`"${at}" is in the trash because a folder above it is.`, {
84
- exitCode: 4,
85
- nextStep: `Nothing changed. Restore that folder instead — \`nmts ls --all\` shows which one carries the trash.`,
86
- });
87
- }
88
- acting.push({ entry, path: at });
89
- }
90
- // ⛔ NAMING A FOLDER AND SOMETHING INSIDE IT IS NAMING ONE TRASHING TWICE, and only for `rm` is
91
- // that a problem worth solving here: stamping the child as well would give it a thirty-day
92
- // clock of its own, and then restoring the folder would leave it behind — the person would
93
- // have to remember they had also named it to ever find it again. Its bytes are covered
94
- // either way, because the rows are read from the folder. `restore` is the opposite case: a
95
- // child with its own instant needs its own clearing, so nothing is dropped there.
96
- const named = new Set(acting.map((t) => t.entry.id));
97
- const covered = verb === "rm" ? acting.filter((t) => hasNamedAncestor(entries, t.entry, named)) : [];
98
- const targets = acting.filter((t) => !covered.includes(t));
99
- for (const t of covered)
100
- skipped.push(t.path);
101
- if (targets.length === 0) {
102
- // Everything named was already where it was asked to be. A no-op is a success: writing the
103
- // list would cost every other device a download for nothing.
104
- return nothingToDo(say, options, verb, skipped, list.seq ?? 0);
105
- }
106
- // Every FILE at or under the targets — a folder holds no bytes and has no server row, so the
107
- // rows to move are its file descendants.
108
- //
109
- // ⛔ THE ROWS TO MOVE ARE THE ONES THE EDIT WILL MAKE REACHABLE, so the set is read off a
110
- // PREVIEW of the list rather than guessed (2026-08-23). `rm` is easy — everything
111
- // under the target loses its bytes. `restore` is not: a file the person deleted separately
112
- // last week keeps its own `deletedAt`, stays in the trash after the folder comes back, and
113
- // its row must stay deleted with it. Restoring that row would cancel its own thirty-day
114
- // sweep, go on costing storage, and leave the list saying "trashed" while the server says
115
- // "live" — after which `rm` refuses to put it back and the tool has no way out.
116
- const at = Date.now();
117
- const ids = targets.map((t) => t.entry.id);
118
- const preview = buildIndex(applyIntent(entries, intentFor(verb, ids, at)));
119
- const under = uniqueById(targets.flatMap((t) => filesUnder(entries, t.entry.id)));
120
- // ⚠ Judged on the PREVIEW's own row, not on the one in hand: `applyIntent` returns new objects,
121
- // so asking the preview about the old object reads the old `deletedAt` and answers "still
122
- // trashed" for the very thing being restored.
123
- const files = verb === "rm"
124
- ? under
125
- : under.filter((f) => {
126
- const after = preview.byId.get(f.id);
127
- return after !== undefined && isLive(preview, after);
128
- });
129
- let done = 0;
130
- try {
131
- for (const file of files) {
132
- await tellServer(session.server, session.apiKey, verb, file.id);
133
- done += 1;
134
- }
135
- }
136
- catch (error) {
137
- // ⛔ A HALF-FINISHED RUN MUST NAME ITSELF. Without this the agent sees six words of stderr and
138
- // the tool's own guidance ("a refusal is not a transient error, do not retry in a loop")
139
- // steers it away from the one thing that fixes this — running the same command again.
140
- const because = error instanceof Error ? error.message : "the server refused";
141
- throw new NmtsError(because, {
142
- exitCode: 1,
143
- nextStep: `${done} of ${files.length} file rows were moved before this stopped, and the file list was ` +
144
- `not written. Running \`nmts ${verb}\` on the same paths again finishes the job — nothing is lost.`,
145
- });
146
- }
147
- // ⛔ THE IDS ARE DECIDED AGAIN ON EVERY ATTEMPT, and this is not ceremony. Between the read
148
- // above and the write below another device can put one of these targets in the trash — by
149
- // trashing it, or by moving it under a folder that already is. Re-applying the intent we
150
- // built earlier would then stamp `deletedAt` on something that is ALREADY in the trash by
151
- // inheritance, giving it a clock of its own and quietly detaching it from the folder it came
152
- // with: restoring that folder afterwards would leave it behind. An id that has left the list
153
- // entirely is dropped for the reason `manifest-ops.ts` gives — the other device removing it
154
- // is newer information than our edit, and putting it back would undo a deletion somebody
155
- // made on purpose.
156
- const result = await applyManyToList(session, (now) => {
157
- const nowIndex = buildIndex(now);
158
- const still = ids.filter((id) => {
159
- const live = nowIndex.byId.get(id);
160
- if (live === undefined)
161
- return false;
162
- return verb === "rm" ? isLive(nowIndex, live) : live.deletedAt !== undefined;
163
- });
164
- return still.length === 0 ? [] : [intentFor(verb, still, at)];
165
- });
166
- const shown = targets.map((t) => t.path);
44
+ // WITHOUT `strict`. A person gets a line saying what was already out of the trash and keeps
45
+ // the rest of their run; a program gets a refusal, because it cannot read the line. The
46
+ // difference is spelled once, where the option is declared.
47
+ const outcome = await trashPaths(session, verb, paths);
48
+ // The outcome's own field order IS this output. Nothing is assembled here, so the two can
49
+ // never drift into saying different things about the same run.
167
50
  if (options.json) {
168
- say(JSON.stringify({
169
- paths: shown,
170
- ids,
171
- files: files.length,
172
- // Named, and nothing was written for them: already out of the trash, or already covered
173
- // by a folder in the same run.
174
- skipped,
175
- changed: result.changed,
176
- // ⚠ The one signal that another writer intervened. It was printed in prose and left out of
177
- // the JSON, which is the half an agent is told to read (2026-08-23).
178
- reappliedAfterConflict: result.reappliedAfterConflict,
179
- seq: result.seq,
180
- }));
51
+ say(JSON.stringify(outcome));
181
52
  return 0;
182
53
  }
183
- const moved = files.length === 1 ? "1 file" : `${files.length} files`;
184
- const names = shown.map((s) => `"${s}"`).join(", ");
54
+ if (outcome.paths.length === 0)
55
+ return nothingToDo(say, verb, outcome.skipped);
56
+ const moved = outcome.files === 1 ? "1 file" : `${outcome.files} files`;
57
+ const names = outcome.paths.map((s) => `"${s}"`).join(", ");
185
58
  say(verb === "rm"
186
- ? `Moved ${names} to the trash (${moved}). ${shown.length === 1 ? "It" : "They"} can be restored for 30 days.`
59
+ ? `Moved ${names} to the trash (${moved}). ${outcome.paths.length === 1 ? "It" : "They"} can be restored for 30 days.`
187
60
  : `Restored ${names} (${moved}).`);
188
- if (skipped.length > 0)
189
- say(` ${skippedLine(verb, skipped)}`);
190
- if (result.reappliedAfterConflict) {
61
+ if (outcome.skipped.length > 0)
62
+ say(` ${skippedLine(verb, outcome.skipped)}`);
63
+ if (outcome.reappliedAfterConflict) {
191
64
  say(` Another device wrote the file list first, so this was applied to that version.`);
192
65
  }
193
66
  return 0;
194
67
  }
195
- /** The one intent either verb writes. Built in two places, so it is spelled in one. */
196
- function intentFor(verb, ids, at) {
197
- return verb === "rm" ? { op: "trash", ids, at } : { op: "restore", ids, at };
198
- }
199
68
  /** What was named but not acted on, in the words that say why. */
200
69
  function skippedLine(verb, skipped) {
201
70
  const names = skipped.map((s) => `"${s}"`).join(", ");
@@ -204,85 +73,16 @@ function skippedLine(verb, skipped) {
204
73
  : `${names} was not in the trash.`;
205
74
  }
206
75
  /**
207
- * The run had nothing to do. Says so in whichever form the caller asked for.
76
+ * The run had nothing to do.
208
77
  *
209
78
  * ⚠ In practice this is the `restore` case — everything named was already out of the trash.
210
79
  * `rm` reaches it only if every path it resolved was inside another path it resolved, which
211
80
  * cannot happen while the outermost one is always kept.
212
81
  */
213
- function nothingToDo(say, options, verb, skipped, seq) {
214
- if (options.json) {
215
- say(JSON.stringify({ paths: [], ids: [], files: 0, skipped, changed: false, reappliedAfterConflict: false, seq }));
216
- return 0;
217
- }
82
+ function nothingToDo(say, verb, skipped) {
218
83
  const names = skipped.map((s) => `"${s}"`).join(", ");
219
84
  say(verb === "restore"
220
85
  ? `${names} ${skipped.length === 1 ? "is" : "are"} not in the trash. Nothing changed.`
221
86
  : `Nothing was left to move to the trash. Nothing changed.`);
222
87
  return 0;
223
88
  }
224
- /** Is any ancestor of this entry in the set? Used to drop a target a named folder already covers. */
225
- function hasNamedAncestor(entries, entry, named) {
226
- const byId = buildIndex(entries).byId;
227
- const seen = new Set([entry.id]);
228
- let at = entry.parentId;
229
- while (at !== null && !seen.has(at)) {
230
- if (named.has(at))
231
- return true;
232
- seen.add(at);
233
- at = byId.get(at)?.parentId ?? null;
234
- }
235
- return false;
236
- }
237
- /** One entry per id, keeping the first. Two named folders can hold the same file only once. */
238
- function uniqueById(files) {
239
- const byId = new Map();
240
- for (const file of files)
241
- if (!byId.has(file.id))
242
- byId.set(file.id, file);
243
- return [...byId.values()];
244
- }
245
- /**
246
- * Every file at or under one entry.
247
- *
248
- * ⚠ Trashed descendants are INCLUDED HERE, and the CALLER filters. Somebody who trashed one file
249
- * last week and then trashes its folder expects the folder to be gone from the server too — so
250
- * `rm` takes this set whole. `restore` cannot: see the note at the call site.
251
- */
252
- export function filesUnder(entries, rootId) {
253
- const root = entries.find((e) => e.id === rootId);
254
- if (root === undefined)
255
- return [];
256
- if (root.kind === KIND_FILE)
257
- return [root];
258
- const childrenOf = new Map();
259
- for (const e of entries) {
260
- const list = childrenOf.get(e.parentId);
261
- if (list === undefined)
262
- childrenOf.set(e.parentId, [e]);
263
- else
264
- list.push(e);
265
- }
266
- const out = [];
267
- const seen = new Set([rootId]);
268
- const queue = [rootId];
269
- while (queue.length > 0) {
270
- const id = queue.pop();
271
- if (id === undefined)
272
- break;
273
- for (const child of childrenOf.get(id) ?? []) {
274
- if (seen.has(child.id))
275
- continue;
276
- seen.add(child.id);
277
- if (child.kind === KIND_FILE)
278
- out.push(child);
279
- else
280
- queue.push(child.id);
281
- }
282
- }
283
- return out;
284
- }
285
- /** ⛔ 404 is "already in the state you asked for", which is what a half-finished run leaves. */
286
- async function tellServer(base, apiKey, verb, id) {
287
- await setTrashed(base, apiKey, id, verb === "rm");
288
- }
@@ -0,0 +1,44 @@
1
+ import { NmtsError } from "../errors.ts";
2
+ /**
3
+ * What went wrong, in a word a program can branch on.
4
+ *
5
+ * ⚠ FIVE, AND THEY ARE ABOUT THE LIST. A server refusal arrives as `ServerError` with the server's
6
+ * own code, and a lost compare-and-swap that never settles arrives as a plain `NmtsError`;
7
+ * neither is a decision this file made.
8
+ */
9
+ export type DriveEditCode =
10
+ /** No entry at that path — or a path that names two, which is the same "which one?" */
11
+ "NOT_FOUND"
12
+ /** Something else in that folder already answers to that name. */
13
+ | "NAME_TAKEN"
14
+ /** The name itself cannot be used: empty, `.`, `..`, or a path pretending to be a name. */
15
+ | "BAD_NAME"
16
+ /** A restore was asked for something that is not in the trash, or not in it on its own account. */
17
+ | "NOT_IN_TRASH"
18
+ /** A folder was asked to move inside itself. */
19
+ | "INTO_ITSELF";
20
+ /** A refusal about the list, with the sentence a person reads and the word a program reads. */
21
+ export declare class DriveEditError extends NmtsError {
22
+ readonly code: DriveEditCode;
23
+ constructor(code: DriveEditCode, message: string, options?: {
24
+ exitCode?: number;
25
+ nextStep?: string | null;
26
+ });
27
+ }
28
+ /**
29
+ * Run a path lookup, and label whatever it refused as `NOT_FOUND`.
30
+ *
31
+ * ⛔ THE SENTENCE, THE EXIT CODE AND THE NEXT STEP ARE CARRIED THROUGH UNTOUCHED. `drive-paths.ts`
32
+ * words three different failures — nothing there, it is in the trash, it names two things — and
33
+ * each of them is better than anything this file could say about them. What is added is the one
34
+ * thing it cannot carry: a code, so a program does not have to read English to know a path did
35
+ * not resolve.
36
+ */
37
+ export declare function resolving<T>(body: () => T): T;
38
+ /**
39
+ * A new name is a name and not a path.
40
+ *
41
+ * ⛔ REFUSED RATHER THAN SPLIT. A name with a `/` in it is somebody asking for a move while typing
42
+ * a rename, and quietly doing the move would put the file somewhere they did not look.
43
+ */
44
+ export declare function requireNewName(name: string): string;
@@ -0,0 +1,65 @@
1
+ // What the drive's own edits refuse with: a word a program branches on, and the one rule that
2
+ // decides whether a new name is a name at all.
3
+ //
4
+ // ⛔ AND EVERY REFUSAL THIS FILE MAKES CARRIES A CODE. A terminal reads the sentence; a program
5
+ // reads `error.code` and branches on it. That is why `DriveEditError` EXTENDS the tool's own
6
+ // error rather than replacing it: the exit code and the next step are still there, `renderError`
7
+ // prints exactly what it printed before, and `error instanceof NmtsError` is still true.
8
+ //
9
+ // ⚠ THE ARGUMENT-SHAPE REFUSALS ARE NOT HERE. "`nmts rename` needs what to rename" is about a
10
+ // command line, and a library caller has no command line to be told about. What IS here is every
11
+ // refusal about the LIST, because that one is the same question whoever asked it.
12
+ import { NmtsError } from "../errors.js";
13
+ /** A refusal about the list, with the sentence a person reads and the word a program reads. */
14
+ export class DriveEditError extends NmtsError {
15
+ code;
16
+ constructor(code, message, options = {}) {
17
+ super(message, options);
18
+ this.name = "DriveEditError";
19
+ this.code = code;
20
+ }
21
+ }
22
+ /**
23
+ * Run a path lookup, and label whatever it refused as `NOT_FOUND`.
24
+ *
25
+ * ⛔ THE SENTENCE, THE EXIT CODE AND THE NEXT STEP ARE CARRIED THROUGH UNTOUCHED. `drive-paths.ts`
26
+ * words three different failures — nothing there, it is in the trash, it names two things — and
27
+ * each of them is better than anything this file could say about them. What is added is the one
28
+ * thing it cannot carry: a code, so a program does not have to read English to know a path did
29
+ * not resolve.
30
+ */
31
+ export function resolving(body) {
32
+ try {
33
+ return body();
34
+ }
35
+ catch (error) {
36
+ if (error instanceof NmtsError && !(error instanceof DriveEditError)) {
37
+ throw new DriveEditError("NOT_FOUND", error.message, {
38
+ exitCode: error.exitCode,
39
+ nextStep: error.nextStep,
40
+ });
41
+ }
42
+ throw error;
43
+ }
44
+ }
45
+ /**
46
+ * A new name is a name and not a path.
47
+ *
48
+ * ⛔ REFUSED RATHER THAN SPLIT. A name with a `/` in it is somebody asking for a move while typing
49
+ * a rename, and quietly doing the move would put the file somewhere they did not look.
50
+ */
51
+ export function requireNewName(name) {
52
+ if (name.trim() === "") {
53
+ throw new DriveEditError("BAD_NAME", "A name cannot be empty.", {
54
+ exitCode: 2,
55
+ nextStep: "Nothing was renamed.",
56
+ });
57
+ }
58
+ if (name.includes("/")) {
59
+ throw new DriveEditError("BAD_NAME", `A name cannot contain "/" — that is what makes it a path.`, {
60
+ exitCode: 2,
61
+ nextStep: `To move it, use \`nmts mv\`. Nothing was renamed.`,
62
+ });
63
+ }
64
+ return name;
65
+ }
@@ -0,0 +1,29 @@
1
+ import { type ListEditInput } from "../manifest-write.ts";
2
+ /**
3
+ * Make a folder path, and every folder above it that is missing, for an account already opened.
4
+ *
5
+ * ⛔ THE RULES BELOW ARE THE ONES A SECOND COPY WOULD GET SUBTLY WRONG: a folder that is already
6
+ * there IS the folder asked for (never a numbered one), the decision is taken inside each
7
+ * attempt so a lost race cannot make two, and what was made before a failure is named rather
8
+ * than silently kept.
9
+ *
10
+ * ⚠ MISSING PARENTS ARE CREATED, and that is a decision rather than a convenience. A folder costs
11
+ * nothing, holds nothing and can be moved to the trash, so the failure mode of creating one too
12
+ * many is a tidy-up; the failure mode of refusing is a caller that has to discover the tree one
13
+ * call at a time. Every folder made is named in the result, so it is never a surprise.
14
+ */
15
+ export declare function ensureFolderPath(input: ListEditInput, wanted: string): Promise<{
16
+ parentId: string | null;
17
+ made: string[];
18
+ }>;
19
+ /** What making a folder did. `made` is empty when every folder in the path was already there. */
20
+ export interface MadeFolder {
21
+ /** The path as the drive spells it — no leading slash, no trailing one. */
22
+ path: string;
23
+ /** The folder at the end of the path. Null only for the top of the drive, which is never made. */
24
+ parentId: string | null;
25
+ /** The folders this call actually made, outermost first. */
26
+ made: string[];
27
+ }
28
+ /** Make one folder path. A path that is already there is a success with nothing made. */
29
+ export declare function makeFolder(input: ListEditInput, path: string): Promise<MadeFolder>;