@needmoretruth/nmts-cli 0.17.2

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 (151) hide show
  1. package/AGENTS.md +591 -0
  2. package/LICENSE +202 -0
  3. package/LICENSING.md +49 -0
  4. package/README.ko.md +641 -0
  5. package/README.md +657 -0
  6. package/dist/account-proof.js +78 -0
  7. package/dist/account.js +75 -0
  8. package/dist/api-key.js +310 -0
  9. package/dist/api.js +271 -0
  10. package/dist/args.js +137 -0
  11. package/dist/artifact-about.js +70 -0
  12. package/dist/autonomy.js +98 -0
  13. package/dist/code-access.js +76 -0
  14. package/dist/code-vault.js +233 -0
  15. package/dist/collision.js +133 -0
  16. package/dist/commands/balance.js +93 -0
  17. package/dist/commands/consent.js +77 -0
  18. package/dist/commands/create.js +356 -0
  19. package/dist/commands/env.js +136 -0
  20. package/dist/commands/expiring.js +167 -0
  21. package/dist/commands/extend.js +236 -0
  22. package/dist/commands/get.js +130 -0
  23. package/dist/commands/kit.js +136 -0
  24. package/dist/commands/listfile.js +105 -0
  25. package/dist/commands/login.js +335 -0
  26. package/dist/commands/logout.js +33 -0
  27. package/dist/commands/ls.js +202 -0
  28. package/dist/commands/marks.js +176 -0
  29. package/dist/commands/mcp.js +139 -0
  30. package/dist/commands/mode.js +54 -0
  31. package/dist/commands/on-collision.js +45 -0
  32. package/dist/commands/organise.js +296 -0
  33. package/dist/commands/public-code.js +89 -0
  34. package/dist/commands/pull.js +185 -0
  35. package/dist/commands/push.js +278 -0
  36. package/dist/commands/put.js +282 -0
  37. package/dist/commands/rebuild.js +160 -0
  38. package/dist/commands/receive.js +125 -0
  39. package/dist/commands/recovery-list.js +104 -0
  40. package/dist/commands/recovery.js +291 -0
  41. package/dist/commands/s3.js +241 -0
  42. package/dist/commands/settings.js +28 -0
  43. package/dist/commands/share.js +276 -0
  44. package/dist/commands/sweep.js +240 -0
  45. package/dist/commands/trash.js +288 -0
  46. package/dist/commands/trial.js +264 -0
  47. package/dist/commands/update.js +169 -0
  48. package/dist/commands/usage.js +84 -0
  49. package/dist/commands/verify.js +331 -0
  50. package/dist/commands/wallet.js +122 -0
  51. package/dist/commands/whoami.js +33 -0
  52. package/dist/consent.js +200 -0
  53. package/dist/credentials.js +311 -0
  54. package/dist/crypto-surface.js +54 -0
  55. package/dist/crypto.js +137 -0
  56. package/dist/download-part.js +144 -0
  57. package/dist/download-sink.js +213 -0
  58. package/dist/download.js +163 -0
  59. package/dist/drive-paths.js +170 -0
  60. package/dist/environment.js +190 -0
  61. package/dist/errors.js +71 -0
  62. package/dist/exit.js +107 -0
  63. package/dist/expiry.js +115 -0
  64. package/dist/extend-chain.js +137 -0
  65. package/dist/extend-plan.js +149 -0
  66. package/dist/extend-sign.js +130 -0
  67. package/dist/guards.js +15 -0
  68. package/dist/help.js +192 -0
  69. package/dist/human-check.js +54 -0
  70. package/dist/item-trash.js +25 -0
  71. package/dist/kit-file.js +133 -0
  72. package/dist/list-file.js +71 -0
  73. package/dist/list-view-find.js +71 -0
  74. package/dist/list-view-order.js +96 -0
  75. package/dist/main.js +394 -0
  76. package/dist/manifest-create.js +81 -0
  77. package/dist/manifest-write.js +244 -0
  78. package/dist/manifest.js +213 -0
  79. package/dist/mark-render.js +45 -0
  80. package/dist/mcp-args.js +154 -0
  81. package/dist/mcp-tools/context.js +50 -0
  82. package/dist/mcp-tools/files.js +133 -0
  83. package/dist/mcp-tools/organise.js +128 -0
  84. package/dist/mcp-tools/reads.js +89 -0
  85. package/dist/mcp-tools/share.js +61 -0
  86. package/dist/mcp.js +142 -0
  87. package/dist/net-retry.js +84 -0
  88. package/dist/network.js +43 -0
  89. package/dist/notice.js +45 -0
  90. package/dist/product.js +38 -0
  91. package/dist/progress.js +110 -0
  92. package/dist/prompt.js +145 -0
  93. package/dist/rebuild.js +255 -0
  94. package/dist/recovery-assemble.js +148 -0
  95. package/dist/recovery-build.js +260 -0
  96. package/dist/recovery-map-file.js +114 -0
  97. package/dist/recovery-map.js +191 -0
  98. package/dist/recovery-release.js +229 -0
  99. package/dist/recovery-seq.js +63 -0
  100. package/dist/recovery-source.js +143 -0
  101. package/dist/registration.js +83 -0
  102. package/dist/s3/listing.js +134 -0
  103. package/dist/s3/multipart.js +104 -0
  104. package/dist/s3/response-sink.js +41 -0
  105. package/dist/s3/same-file.js +117 -0
  106. package/dist/s3/server.js +252 -0
  107. package/dist/s3/sigv4.js +168 -0
  108. package/dist/s3/staging.js +87 -0
  109. package/dist/s3/xml.js +80 -0
  110. package/dist/safe-path.js +89 -0
  111. package/dist/seal.js +206 -0
  112. package/dist/secret-reader.js +112 -0
  113. package/dist/server.js +33 -0
  114. package/dist/session.js +49 -0
  115. package/dist/setup-questions.js +31 -0
  116. package/dist/share.js +195 -0
  117. package/dist/shared/lib/crypto/size-padding.js +126 -0
  118. package/dist/shared/lib/drive/manifest-codec.js +287 -0
  119. package/dist/shared/lib/drive/manifest-index.js +235 -0
  120. package/dist/shared/lib/drive/manifest-ops.js +267 -0
  121. package/dist/shared/lib/drive/manifest-settings.js +65 -0
  122. package/dist/shared/lib/drive/name-conflict.js +121 -0
  123. package/dist/shared/lib/drive/unique-name.js +48 -0
  124. package/dist/shared/lib/extend/epochs.js +256 -0
  125. package/dist/shared/lib/net/retry-budget.js +95 -0
  126. package/dist/shared/lib/share/shared-file-info.js +65 -0
  127. package/dist/shared/lib/storage-network.js +64 -0
  128. package/dist/shared/lib/upload/part-plan.js +28 -0
  129. package/dist/stdout.js +109 -0
  130. package/dist/trash-sweep.js +123 -0
  131. package/dist/units.js +19 -0
  132. package/dist/update-check.js +164 -0
  133. package/dist/update-source.js +143 -0
  134. package/dist/upload-api.js +98 -0
  135. package/dist/upload-file.js +242 -0
  136. package/dist/upload-price.js +117 -0
  137. package/dist/upload-steps.js +111 -0
  138. package/dist/upload-store.js +252 -0
  139. package/dist/upload-wire.js +25 -0
  140. package/dist/upload.js +294 -0
  141. package/dist/usage-report.js +54 -0
  142. package/dist/wallet-chain.js +76 -0
  143. package/dist/wallet.js +209 -0
  144. package/dist/walrus-write.js +188 -0
  145. package/dist/walrus.js +156 -0
  146. package/package.json +56 -0
  147. package/vendor/nmts-crypto/nmts_crypto_wasm.d.ts +469 -0
  148. package/vendor/nmts-crypto/nmts_crypto_wasm.js +1300 -0
  149. package/vendor/nmts-crypto/nmts_crypto_wasm_bg.wasm +0 -0
  150. package/vendor/nmts-crypto/nmts_crypto_wasm_bg.wasm.d.ts +55 -0
  151. package/vendor/nmts-crypto/package.json +17 -0
@@ -0,0 +1,235 @@
1
+ /** Folder items use kind 0, files kind 1 — the same numbers the items API uses. */
2
+ export const KIND_FOLDER = 0;
3
+ export const KIND_FILE = 1;
4
+ /** Parent key for the drive root. `parentId: null` is stored; this is its map key. */
5
+ const ROOT = "\u0000root";
6
+ /** Build the lookup structures for one version of the list. Cost is linear; do it once. */
7
+ export function buildIndex(entries) {
8
+ const byId = new Map();
9
+ const childrenByParent = new Map();
10
+ for (const e of entries) {
11
+ byId.set(e.id, e);
12
+ const key = e.parentId ?? ROOT;
13
+ const bucket = childrenByParent.get(key);
14
+ if (bucket)
15
+ bucket.push(e);
16
+ else
17
+ childrenByParent.set(key, [e]);
18
+ }
19
+ return { all: entries, byId, childrenByParent };
20
+ }
21
+ /**
22
+ * The instant this item became trash — its own, or the nearest trashed ancestor's.
23
+ * `null` means live. A parent chain that is broken or looping counts as live at the point it
24
+ * breaks: showing an item whose parent vanished is recoverable, hiding it silently is not.
25
+ */
26
+ export function trashedAt(index, entry) {
27
+ let cursor = entry;
28
+ const seen = new Set();
29
+ while (cursor) {
30
+ if (cursor.deletedAt !== undefined)
31
+ return cursor.deletedAt;
32
+ if (cursor.parentId === null)
33
+ return null;
34
+ if (seen.has(cursor.id))
35
+ return null;
36
+ seen.add(cursor.id);
37
+ cursor = index.byId.get(cursor.parentId);
38
+ }
39
+ return null;
40
+ }
41
+ /** Live = not trashed itself and under no trashed ancestor. */
42
+ export function isLive(index, entry) {
43
+ return trashedAt(index, entry) === null;
44
+ }
45
+ /**
46
+ * Does the ACCOUNT hold a file that whole-account export could actually write out?
47
+ *
48
+ * Asked by the export card, which offers itself on this and nothing else. The two wrong answers it
49
+ * replaces are both easy to reach: counting the CURRENT LEVEL hides the card from an account whose
50
+ * files all sit inside folders, and counting entries of any kind offers a download to a drive of
51
+ * empty folders — one that can only answer "there is nothing to download".
52
+ *
53
+ * ⚠ Trashed files are excluded on purpose. They are still stored and still paid for, but export
54
+ * writes the live drive, so a drive whose every file is in the trash has nothing to export.
55
+ */
56
+ export function hasLiveFile(index) {
57
+ return index.all.some((e) => e.kind === KIND_FILE && isLive(index, e));
58
+ }
59
+ /**
60
+ * Live children of a folder (`null` = drive root), in list order.
61
+ * Sorting belongs to the view: the same folder is shown by name, size and date in different places.
62
+ */
63
+ export function childrenOf(index, parentId) {
64
+ const bucket = index.childrenByParent.get(parentId ?? ROOT);
65
+ if (!bucket)
66
+ return [];
67
+ return bucket.filter((e) => isLive(index, e));
68
+ }
69
+ /**
70
+ * What the trash view shows: items the person deleted directly, newest first.
71
+ * A child whose parent is also trashed is deliberately absent — it is restored with its parent.
72
+ */
73
+ export function trashRoots(index) {
74
+ const roots = index.all.filter((e) => {
75
+ if (e.deletedAt === undefined)
76
+ return false;
77
+ if (e.parentId === null)
78
+ return true;
79
+ const parent = index.byId.get(e.parentId);
80
+ // Parent gone entirely: this is the top of what remains, so it is its own root.
81
+ return parent === undefined || trashedAt(index, parent) === null;
82
+ });
83
+ return roots.slice().sort((a, b) => (b.deletedAt ?? 0) - (a.deletedAt ?? 0));
84
+ }
85
+ /**
86
+ * Folder names from the root down to (and excluding) this item.
87
+ * An entry whose chain is broken returns what could be resolved — callers render that as a
88
+ * partial path rather than claiming the item sits at the root.
89
+ */
90
+ export function pathOf(index, entry) {
91
+ const names = [];
92
+ const seen = new Set([entry.id]);
93
+ let parentId = entry.parentId;
94
+ while (parentId !== null) {
95
+ if (seen.has(parentId))
96
+ break;
97
+ seen.add(parentId);
98
+ const parent = index.byId.get(parentId);
99
+ if (!parent)
100
+ break;
101
+ names.push(parent.name);
102
+ parentId = parent.parentId;
103
+ }
104
+ names.reverse();
105
+ return names;
106
+ }
107
+ /** Every descendant of a folder, live and trashed, depth-first. The folder itself is excluded. */
108
+ export function descendantsOf(index, folderId) {
109
+ const out = [];
110
+ const seen = new Set([folderId]);
111
+ const stack = [folderId];
112
+ while (stack.length > 0) {
113
+ const id = stack.pop();
114
+ for (const child of index.childrenByParent.get(id) ?? []) {
115
+ if (seen.has(child.id))
116
+ continue;
117
+ seen.add(child.id);
118
+ out.push(child);
119
+ if (child.kind === KIND_FOLDER)
120
+ stack.push(child.id);
121
+ }
122
+ }
123
+ return out;
124
+ }
125
+ /**
126
+ * True when `folderId` is `candidateId` or sits underneath it.
127
+ * Move targets are checked with this: dropping a folder into its own subtree would detach that
128
+ * whole branch from the root, and nothing in the UI could reach it afterwards.
129
+ */
130
+ export function isSelfOrDescendant(index, candidateId, folderId) {
131
+ if (candidateId === folderId)
132
+ return true;
133
+ let cursor = index.byId.get(candidateId);
134
+ const seen = new Set();
135
+ while (cursor && cursor.parentId !== null) {
136
+ if (cursor.parentId === folderId)
137
+ return true;
138
+ if (seen.has(cursor.id))
139
+ return false;
140
+ seen.add(cursor.id);
141
+ cursor = index.byId.get(cursor.parentId);
142
+ }
143
+ return false;
144
+ }
145
+ /**
146
+ * Live items whose name contains `query`, case-insensitively.
147
+ *
148
+ * This search is COMPLETE — it reads the whole list from memory. That is a change worth knowing
149
+ * about: the old server-backed listing could only match what had been fetched, so "no results"
150
+ * was a claim the UI had to hedge. Here it is simply true.
151
+ */
152
+ export function searchByName(index, query, limit = 500) {
153
+ const needle = query.trim().toLocaleLowerCase();
154
+ if (needle === "")
155
+ return [];
156
+ const out = [];
157
+ for (const e of index.all) {
158
+ if (out.length >= limit)
159
+ break;
160
+ if (!e.name.toLocaleLowerCase().includes(needle))
161
+ continue;
162
+ if (!isLive(index, e))
163
+ continue;
164
+ out.push(e);
165
+ }
166
+ return out;
167
+ }
168
+ /**
169
+ * Live files the person starred, newest first.
170
+ *
171
+ * Files only: a folder is already reachable in the panel's tree, so starring one would put the same
172
+ * thing in two places and make "favourites" mean two different kinds of row.
173
+ */
174
+ export function favoriteFiles(index) {
175
+ return index.all
176
+ .filter((e) => e.kind === KIND_FILE && e.favorite === true && isLive(index, e))
177
+ .slice()
178
+ .sort((a, b) => b.updatedAt - a.updatedAt);
179
+ }
180
+ /**
181
+ * Every label in use, with how many live files wear it, ordered by the person's own locale.
182
+ *
183
+ * Counting here rather than in the panel matters: the count is what tells someone a label still has
184
+ * files in it before they rename or clear it, and it must agree with what opening it shows.
185
+ */
186
+ export function labelCounts(index) {
187
+ const counts = new Map();
188
+ for (const e of index.all) {
189
+ if (e.kind !== KIND_FILE || !e.labels || e.labels.length === 0)
190
+ continue;
191
+ if (!isLive(index, e))
192
+ continue;
193
+ for (const label of e.labels)
194
+ counts.set(label, (counts.get(label) ?? 0) + 1);
195
+ }
196
+ return [...counts.entries()]
197
+ .map(([label, count]) => ({ label, count }))
198
+ .sort((a, b) => a.label.localeCompare(b.label));
199
+ }
200
+ /** Live files wearing one label, newest first. */
201
+ export function filesWithLabel(index, label) {
202
+ return index.all
203
+ .filter((e) => e.kind === KIND_FILE &&
204
+ (e.labels ?? []).includes(label) &&
205
+ isLive(index, e))
206
+ .slice()
207
+ .sort((a, b) => b.updatedAt - a.updatedAt);
208
+ }
209
+ /** Whole-drive counts. Exact, because the list is complete by construction. */
210
+ export function totalsOf(index) {
211
+ const totals = {
212
+ files: 0,
213
+ folders: 0,
214
+ bytes: 0,
215
+ trashedFiles: 0,
216
+ trashedBytes: 0,
217
+ };
218
+ for (const e of index.all) {
219
+ const live = isLive(index, e);
220
+ if (e.kind === KIND_FOLDER) {
221
+ if (live)
222
+ totals.folders += 1;
223
+ continue;
224
+ }
225
+ if (live) {
226
+ totals.files += 1;
227
+ totals.bytes += e.size;
228
+ }
229
+ else {
230
+ totals.trashedFiles += 1;
231
+ totals.trashedBytes += e.size;
232
+ }
233
+ }
234
+ return totals;
235
+ }
@@ -0,0 +1,267 @@
1
+ // Runtime import (relative + .ts — the header's node --test rule) for the bounds the patch obeys.
2
+ import { TEXT_SCALE_DEFAULT_PCT, TEXT_SCALE_MAX_PCT, TEXT_SCALE_MIN_PCT, } from "./manifest-codec.js";
3
+ /**
4
+ * Apply one intent, returning a new list. The input is never mutated: the store keeps the
5
+ * pre-save snapshot around to rebuild from after a version conflict.
6
+ *
7
+ * Returns the SAME array reference when nothing changed, so callers can skip a re-render and a
8
+ * save for an intent that turned out to be a no-op (a rename to the name it already had, a trash
9
+ * of something another device already purged).
10
+ */
11
+ export function applyIntent(entries, intent) {
12
+ switch (intent.op) {
13
+ case "add":
14
+ return upsert(entries, intent.entry);
15
+ case "rename":
16
+ return patchOne(entries, intent.id, (e) => e.name === intent.name ? e : { ...e, name: intent.name, updatedAt: intent.at });
17
+ case "move":
18
+ return patchOne(entries, intent.id, (e) => e.parentId === intent.parentId
19
+ ? e
20
+ : { ...e, parentId: intent.parentId, updatedAt: intent.at });
21
+ case "trash": {
22
+ const ids = new Set(intent.ids);
23
+ // `deletedAt` is left alone when already set: it is the start of the retention window the
24
+ // UI promises ("restorable for 30 days"), and re-stamping it would quietly extend that.
25
+ return patchMany(entries, ids, (e) => e.deletedAt !== undefined ? e : { ...e, deletedAt: intent.at, updatedAt: intent.at });
26
+ }
27
+ case "restore": {
28
+ const ids = new Set(intent.ids);
29
+ return patchMany(entries, ids, (e) => {
30
+ if (e.deletedAt === undefined)
31
+ return e;
32
+ const { deletedAt: _dropped, ...rest } = e;
33
+ return { ...rest, updatedAt: intent.at };
34
+ });
35
+ }
36
+ case "purge": {
37
+ const ids = new Set(intent.ids);
38
+ const kept = entries.filter((e) => !ids.has(e.id));
39
+ return kept.length === entries.length ? entries : kept;
40
+ }
41
+ case "favorite": {
42
+ const ids = new Set(intent.ids);
43
+ return patchMany(entries, ids, (e) => setMark(e, "favorite", intent.on, intent.at));
44
+ }
45
+ case "pin": {
46
+ const ids = new Set(intent.ids);
47
+ return patchMany(entries, ids, (e) => setMark(e, "pinned", intent.on, intent.at));
48
+ }
49
+ case "label": {
50
+ const label = intent.label.trim();
51
+ if (label === "")
52
+ return entries;
53
+ const ids = new Set(intent.ids);
54
+ return patchMany(entries, ids, (e) => {
55
+ const has = (e.labels ?? []).includes(label);
56
+ if (has === intent.on)
57
+ return e;
58
+ const next = intent.on
59
+ ? [...(e.labels ?? []), label]
60
+ : (e.labels ?? []).filter((l) => l !== label);
61
+ return withLabels(e, next, intent.at);
62
+ });
63
+ }
64
+ case "labelRename": {
65
+ const from = intent.from.trim();
66
+ const to = intent.to.trim();
67
+ if (from === "" || to === "" || from === to)
68
+ return entries;
69
+ return patchAll(entries, (e) => {
70
+ if (!(e.labels ?? []).includes(from))
71
+ return e;
72
+ // Renaming ONTO an existing label merges the two rather than leaving one item wearing the
73
+ // same label twice — which would show a doubled row and count every file twice.
74
+ const next = (e.labels ?? []).filter((l) => l !== from);
75
+ if (!next.includes(to))
76
+ next.push(to);
77
+ return withLabels(e, next, intent.at);
78
+ });
79
+ }
80
+ case "labelDelete": {
81
+ const label = intent.label.trim();
82
+ if (label === "")
83
+ return entries;
84
+ return patchAll(entries, (e) => {
85
+ if (!(e.labels ?? []).includes(label))
86
+ return e;
87
+ return withLabels(e, (e.labels ?? []).filter((l) => l !== label), intent.at);
88
+ });
89
+ }
90
+ case "shareRecord": {
91
+ const address = intent.address.trim();
92
+ if (address === "")
93
+ return entries;
94
+ return patchOne(entries, intent.id, (e) => {
95
+ const existing = (e.shares ?? []).find((r) => r.address === address);
96
+ // A re-share of the same file to the same person REVIVES the receipt: the row is live
97
+ // again, so a revoked mark left over from before would report a lingering row that is now
98
+ // exactly what the sender asked for.
99
+ if (existing && existing.revoked !== true)
100
+ return e;
101
+ const others = (e.shares ?? []).filter((r) => r.address !== address);
102
+ return withShares(e, [...others, { address, at: intent.at }], intent.at);
103
+ });
104
+ }
105
+ case "shareRevoked": {
106
+ const address = intent.address.trim();
107
+ if (address === "")
108
+ return entries;
109
+ return patchOne(entries, intent.id, (e) => {
110
+ const receipts = e.shares ?? [];
111
+ if (!receipts.some((r) => r.address === address && r.revoked !== true))
112
+ return e;
113
+ return withShares(e, receipts.map((r) => (r.address === address ? { ...r, revoked: true } : r)), intent.at);
114
+ });
115
+ }
116
+ case "sharePrune": {
117
+ const addresses = new Set(intent.addresses);
118
+ if (addresses.size === 0)
119
+ return entries;
120
+ return patchOne(entries, intent.id, (e) => {
121
+ const receipts = e.shares ?? [];
122
+ const kept = receipts.filter((r) => !(r.revoked === true && addresses.has(r.address)));
123
+ return kept.length === receipts.length ? e : withShares(e, kept, intent.at);
124
+ });
125
+ }
126
+ }
127
+ }
128
+ /**
129
+ * Set or clear one boolean mark, keeping "absent" as the only spelling of false.
130
+ *
131
+ * Writing `favorite: false` would be a second spelling of the same fact, and it would ride in
132
+ * every save for every item the person ever un-starred.
133
+ */
134
+ function setMark(e, key, on, at) {
135
+ if (on === (e[key] === true))
136
+ return e;
137
+ if (on)
138
+ return { ...e, [key]: true, updatedAt: at };
139
+ const next = { ...e, updatedAt: at };
140
+ delete next[key];
141
+ return next;
142
+ }
143
+ /** Replace an item's label list, dropping the field entirely when nothing is left. */
144
+ function withLabels(e, labels, at) {
145
+ if (labels.length === 0) {
146
+ const next = { ...e, updatedAt: at };
147
+ delete next.labels;
148
+ return next;
149
+ }
150
+ return { ...e, labels, updatedAt: at };
151
+ }
152
+ /** Replace an item's share receipts, dropping the field entirely when nothing is left. */
153
+ function withShares(e, shares, at) {
154
+ if (shares.length === 0) {
155
+ const next = { ...e, updatedAt: at };
156
+ delete next.shares;
157
+ return next;
158
+ }
159
+ return { ...e, shares, updatedAt: at };
160
+ }
161
+ /**
162
+ * Apply one settings patch, returning new settings. Returns the SAME reference when nothing
163
+ * changed, so callers can skip a save (a version bump every other device must download).
164
+ *
165
+ * A text scale is CLAMPED into the codec's bounds here — this is the one write path, so a value
166
+ * the slider or the typed field lets through never reaches the wire out of range.
167
+ */
168
+ export function applySettingsPatch(settings, patch) {
169
+ const next = { ...settings };
170
+ if (patch.developerMode !== undefined) {
171
+ if (patch.developerMode)
172
+ next.developerMode = true;
173
+ else
174
+ delete next.developerMode;
175
+ }
176
+ if (patch.paddingMode !== undefined) {
177
+ // The default is spelled as absence, like every other field here — so two devices that both
178
+ // "choose the default" write the same bytes and neither bumps the list's version.
179
+ if (patch.paddingMode === "pow2")
180
+ next.paddingMode = "pow2";
181
+ else
182
+ delete next.paddingMode;
183
+ }
184
+ if (patch.textScalePct !== undefined && Number.isFinite(patch.textScalePct)) {
185
+ const pct = Math.round(Math.min(TEXT_SCALE_MAX_PCT, Math.max(TEXT_SCALE_MIN_PCT, patch.textScalePct)));
186
+ if (pct === TEXT_SCALE_DEFAULT_PCT)
187
+ delete next.textScalePct;
188
+ else
189
+ next.textScalePct = pct;
190
+ }
191
+ const same = (next.developerMode === true) === (settings.developerMode === true) &&
192
+ next.paddingMode === settings.paddingMode &&
193
+ next.textScalePct === settings.textScalePct;
194
+ return same ? settings : next;
195
+ }
196
+ /** Replay a whole queue in order. Used to rebuild after a version conflict. */
197
+ export function applyIntents(entries, intents) {
198
+ return intents.reduce(applyIntent, entries);
199
+ }
200
+ /**
201
+ * True when the intent touches storage the server also tracks, so a save must not be deferred.
202
+ *
203
+ * A queued save that dies with the tab is recoverable for a rename (the name is only in the
204
+ * manifest, and losing it leaves the old name — annoying, not damaging). It is NOT recoverable
205
+ * when a file was just committed or just deleted: the storage record moved, and a manifest that
206
+ * disagrees leaves a file that is paid for but invisible, or one that shows but is gone.
207
+ *
208
+ * Stars, pins and labels are deliberately NOT in this list: they exist only in the manifest, so the
209
+ * worst a lost save can do is leave a file unstarred — the same recoverable loss as a rename.
210
+ *
211
+ * SHARE RECEIPTS ARE, for the same reason a commit is: they describe a row the server now
212
+ * holds, and nothing else on this side records it. A receipt lost with the tab does not degrade to
213
+ * a wrong colour — it degrades to a share this device can never again hold the server to.
214
+ * `sharePrune` stays out: losing it leaves a settled receipt that the next listing prunes again.
215
+ */
216
+ export function mustSaveNow(intent) {
217
+ return (intent.op === "add" ||
218
+ intent.op === "trash" ||
219
+ intent.op === "purge" ||
220
+ intent.op === "shareRecord" ||
221
+ intent.op === "shareRevoked");
222
+ }
223
+ function upsert(entries, entry) {
224
+ const at = entries.findIndex((e) => e.id === entry.id);
225
+ if (at < 0)
226
+ return [...entries, entry];
227
+ const next = entries.slice();
228
+ next[at] = entry;
229
+ return next;
230
+ }
231
+ function patchOne(entries, id, patch) {
232
+ // ⚠ FOUND BY VALUE, NOT BY INDEX. `entries[at]` after a `findIndex` is provably present and the
233
+ // compiler cannot know it — which is fine here and NOT fine in the copy of this file that
234
+ // ships inside the `nmts` command, where `noUncheckedIndexedAccess` is on. Reaching for the
235
+ // element itself needs no assertion in either build.
236
+ const found = entries.find((e) => e.id === id);
237
+ // Absent = another device removed it. Adding it back would undo a deletion the person made.
238
+ if (found === undefined)
239
+ return entries;
240
+ const updated = patch(found);
241
+ if (updated === found)
242
+ return entries;
243
+ return entries.map((e) => (e === found ? updated : e));
244
+ }
245
+ /** Patch every entry the callback changes. Used by the label sweeps, which are not id-addressed. */
246
+ function patchAll(entries, patch) {
247
+ let changed = false;
248
+ const next = entries.map((e) => {
249
+ const updated = patch(e);
250
+ if (updated !== e)
251
+ changed = true;
252
+ return updated;
253
+ });
254
+ return changed ? next : entries;
255
+ }
256
+ function patchMany(entries, ids, patch) {
257
+ let changed = false;
258
+ const next = entries.map((e) => {
259
+ if (!ids.has(e.id))
260
+ return e;
261
+ const updated = patch(e);
262
+ if (updated !== e)
263
+ changed = true;
264
+ return updated;
265
+ });
266
+ return changed ? next : entries;
267
+ }
@@ -0,0 +1,65 @@
1
+ // The account-level settings that ride inside the sealed file list, and how they travel.
2
+ //
3
+ // ⛔ THEY ARE IN THE SEALED LIST BECAUSE THE SERVER MUST NOT LEARN THEM. Each one would otherwise
4
+ // be a small per-account fingerprint a server could keep, and each follows the account rather
5
+ // than the device, so two machines behave the same way for one person.
6
+ //
7
+ // ⛔ A SETTING IS NOT SAVED BECAUSE IT IS DECLARED — it is saved because both functions below name
8
+ // it. One shipped declared, written by its own screen and read by the uploader, and carried by
9
+ // NEITHER direction: every save dropped it silently, on every device, and the test standing
10
+ // beside it listed the fields it checked and so could only check what somebody remembered to
11
+ // list. That test is now typed so a field added here and not added there does not compile.
12
+ /** The sanity bounds a stored text scale must sit in to be USED. One place; codec and UI agree. */
13
+ export const TEXT_SCALE_MIN_PCT = 80;
14
+ export const TEXT_SCALE_MAX_PCT = 160;
15
+ /** Follow the device. Not written to the wire — absence is the only spelling of it. */
16
+ export const TEXT_SCALE_DEFAULT_PCT = 100;
17
+ /** Settings → wire, or null when every field is at its default (then nothing is written). */
18
+ export function settingsToWire(s) {
19
+ if (!s)
20
+ return null;
21
+ const w = {};
22
+ if (s.developerMode)
23
+ w.dm = 1;
24
+ if (typeof s.textScalePct === "number" &&
25
+ Number.isFinite(s.textScalePct) &&
26
+ s.textScalePct !== TEXT_SCALE_DEFAULT_PCT &&
27
+ s.textScalePct >= TEXT_SCALE_MIN_PCT &&
28
+ s.textScalePct <= TEXT_SCALE_MAX_PCT) {
29
+ w.tx = Math.round(s.textScalePct);
30
+ }
31
+ if (s.paddingMode === "pow2")
32
+ w.pd = "pow2";
33
+ return w.dm !== undefined || w.tx !== undefined || w.pd !== undefined ? w : null;
34
+ }
35
+ /**
36
+ * Wire → settings, dropping anything unusable. A text scale outside the bounds is DROPPED, not
37
+ * clamped: rendering a whole app at a number some other build miswrote is worse than falling back
38
+ * to the device's own size, which is always readable.
39
+ */
40
+ export function settingsFromWire(w) {
41
+ if (!w || typeof w !== "object")
42
+ return undefined;
43
+ // Read field by field rather than asserting the shape: this arrives from a sealed blob some
44
+ // other build wrote, and every field below is checked before it is used anyway.
45
+ const dm = Reflect.get(w, "dm");
46
+ const tx = Reflect.get(w, "tx");
47
+ const pd = Reflect.get(w, "pd");
48
+ const s = {};
49
+ if (dm === 1)
50
+ s.developerMode = true;
51
+ if (typeof tx === "number" &&
52
+ Number.isFinite(tx) &&
53
+ tx !== TEXT_SCALE_DEFAULT_PCT &&
54
+ tx >= TEXT_SCALE_MIN_PCT &&
55
+ tx <= TEXT_SCALE_MAX_PCT) {
56
+ s.textScalePct = Math.round(tx);
57
+ }
58
+ // An unknown rule is DROPPED, not guessed at: padding a file by a rule this build does not know
59
+ // would give it a size no reader here can undo. Falling back to the default is always readable.
60
+ if (pd === "pow2")
61
+ s.paddingMode = "pow2";
62
+ return s.developerMode !== undefined || s.textScalePct !== undefined || s.paddingMode !== undefined
63
+ ? s
64
+ : undefined;
65
+ }
@@ -0,0 +1,121 @@
1
+ // What to do when an upload's name is already in use in its destination folder. ⚠ PUBLISHED —
2
+ // copied byte-for-byte into the `nmts` command-line package; keep comments self-contained English.
3
+ //
4
+ // UNTIL 2026-08-25 NOTHING WAS ASKED: a taken name silently became `report (2).pdf`. The owner
5
+ // changed that — "there is a file with this name" and two choices, the way a desktop does it,
6
+ // with "do this for all of them" when several files arrive at once.
7
+ //
8
+ // WHY THIS FILE IS PURE AND SEPARATE FROM THE DIALOG. Three callers need the same answer and only
9
+ // one of them has a screen: the browser asks a person, the S3 gateway and the command line
10
+ // follow a setting chosen once at setup. Putting the rule in the dialog would mean the headless
11
+ // paths either duplicate it or drift from it.
12
+ //
13
+ // ⛔ OVERWRITE IS NOT UNDOABLE AND THIS FILE DOES NOT SOFTEN THAT. NMTS keeps no previous versions
14
+ // (an automatic version history would spend the person's own storage on every generation), so
15
+ // the old file is gone. What this returns is the intent; the caller is what destroys anything.
16
+ //
17
+ // ⛔ EXACT-MATCH COMPARISON, matching `unique-name.ts`: the drive holds `A.txt` and `a.txt` as two
18
+ // files, so a case-insensitive collision here would offer to replace a file the person can see
19
+ // is named differently.
20
+ import { uniqueFileName } from "./unique-name.js";
21
+ /**
22
+ * Which of these collide, in batch order.
23
+ *
24
+ * ⛔ THE TAKEN SET GROWS AS THIS WALKS. Two files called `report.pdf` in one drop collide with each
25
+ * other, not only with the drive — and a caller that asked only about the drive would give both
26
+ * the same name. So a name that has been handed out here counts as taken from then on, and the
27
+ * second one is reported as a conflict too.
28
+ */
29
+ export function findConflicts(batch, takenIn) {
30
+ const extra = new Map();
31
+ const seen = (parentId) => {
32
+ let set = extra.get(parentId);
33
+ if (!set) {
34
+ set = new Set();
35
+ extra.set(parentId, set);
36
+ }
37
+ return set;
38
+ };
39
+ const out = [];
40
+ batch.forEach((item, at) => {
41
+ const already = takenIn(item.parentId).has(item.name) || seen(item.parentId).has(item.name);
42
+ if (already)
43
+ out.push({ at, name: item.name, parentId: item.parentId });
44
+ seen(item.parentId).add(item.name);
45
+ });
46
+ return out;
47
+ }
48
+ /**
49
+ * Apply the choices and hand back what each file becomes.
50
+ *
51
+ * `choiceFor` is asked only about names that actually collide, one at a time and in batch order, so
52
+ * a screen can put the question to a person and this walk waits. Anything it is not asked about
53
+ * keeps its name. A batch answer ("do this for all") is the caller returning the same value from
54
+ * then on without asking again — this file does not need to know that happened.
55
+ *
56
+ * ⛔ A RENAME CONSUMES THE NAME IT WAS GIVEN, an overwrite does not. Two files called `report.pdf`
57
+ * both overwriting would otherwise be two writes to one name — the second wins and the first is
58
+ * lost with nothing said. So the second one is renamed regardless of the choice, and the caller
59
+ * can see that because the name it gets back is not the name it asked for.
60
+ */
61
+ export async function settle(batch, takenIn, choiceFor,
62
+ /**
63
+ * Can the thing holding this name be replaced at all?
64
+ *
65
+ * ⛔ A FOLDER CAN HOLD THE NAME. Offering "overwrite" then would offer to delete a folder and
66
+ * everything under it in order to store one file, from a dialog that names a file. Nothing
67
+ * asks; those are renamed, which is what happened before anything was asked at all.
68
+ * ⚠ Left out means everything is replaceable, which is right for callers whose names are all
69
+ * files (the S3 gateway has no folders).
70
+ */
71
+ overwritable = () => true) {
72
+ const conflicts = new Map();
73
+ const found = findConflicts(batch, takenIn);
74
+ for (const c of found)
75
+ conflicts.set(c.at, c);
76
+ const handedOut = new Map();
77
+ const given = (parentId) => {
78
+ let set = handedOut.get(parentId);
79
+ if (!set) {
80
+ set = new Set();
81
+ handedOut.set(parentId, set);
82
+ }
83
+ return set;
84
+ };
85
+ const takenNow = (parentId) => new Set([...takenIn(parentId), ...given(parentId)]);
86
+ const overwrittenHere = new Map();
87
+ const overwritten = (parentId) => {
88
+ let set = overwrittenHere.get(parentId);
89
+ if (!set) {
90
+ set = new Set();
91
+ overwrittenHere.set(parentId, set);
92
+ }
93
+ return set;
94
+ };
95
+ const out = [];
96
+ let asked = 0;
97
+ for (const [at, item] of batch.entries()) {
98
+ const conflict = conflicts.get(at);
99
+ if (conflict === undefined) {
100
+ given(item.parentId).add(item.name);
101
+ out.push({ name: item.name, parentId: item.parentId });
102
+ continue;
103
+ }
104
+ const renamedTo = uniqueFileName(item.name, takenNow(item.parentId));
105
+ if (!overwritable(conflict)) {
106
+ given(item.parentId).add(renamedTo);
107
+ out.push({ name: renamedTo, parentId: item.parentId });
108
+ continue;
109
+ }
110
+ asked += 1;
111
+ const choice = await choiceFor({ ...conflict, renamedTo, remaining: found.length - asked });
112
+ if (choice === "overwrite" && !overwritten(item.parentId).has(item.name)) {
113
+ overwritten(item.parentId).add(item.name);
114
+ out.push({ name: item.name, parentId: item.parentId, replaces: item.name });
115
+ continue;
116
+ }
117
+ given(item.parentId).add(renamedTo);
118
+ out.push({ name: renamedTo, parentId: item.parentId });
119
+ }
120
+ return out;
121
+ }