@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.
- package/AGENTS.md +591 -0
- package/LICENSE +202 -0
- package/LICENSING.md +49 -0
- package/README.ko.md +641 -0
- package/README.md +657 -0
- package/dist/account-proof.js +78 -0
- package/dist/account.js +75 -0
- package/dist/api-key.js +310 -0
- package/dist/api.js +271 -0
- package/dist/args.js +137 -0
- package/dist/artifact-about.js +70 -0
- package/dist/autonomy.js +98 -0
- package/dist/code-access.js +76 -0
- package/dist/code-vault.js +233 -0
- package/dist/collision.js +133 -0
- package/dist/commands/balance.js +93 -0
- package/dist/commands/consent.js +77 -0
- package/dist/commands/create.js +356 -0
- package/dist/commands/env.js +136 -0
- package/dist/commands/expiring.js +167 -0
- package/dist/commands/extend.js +236 -0
- package/dist/commands/get.js +130 -0
- package/dist/commands/kit.js +136 -0
- package/dist/commands/listfile.js +105 -0
- package/dist/commands/login.js +335 -0
- package/dist/commands/logout.js +33 -0
- package/dist/commands/ls.js +202 -0
- package/dist/commands/marks.js +176 -0
- package/dist/commands/mcp.js +139 -0
- package/dist/commands/mode.js +54 -0
- package/dist/commands/on-collision.js +45 -0
- package/dist/commands/organise.js +296 -0
- package/dist/commands/public-code.js +89 -0
- package/dist/commands/pull.js +185 -0
- package/dist/commands/push.js +278 -0
- package/dist/commands/put.js +282 -0
- package/dist/commands/rebuild.js +160 -0
- package/dist/commands/receive.js +125 -0
- package/dist/commands/recovery-list.js +104 -0
- package/dist/commands/recovery.js +291 -0
- package/dist/commands/s3.js +241 -0
- package/dist/commands/settings.js +28 -0
- package/dist/commands/share.js +276 -0
- package/dist/commands/sweep.js +240 -0
- package/dist/commands/trash.js +288 -0
- package/dist/commands/trial.js +264 -0
- package/dist/commands/update.js +169 -0
- package/dist/commands/usage.js +84 -0
- package/dist/commands/verify.js +331 -0
- package/dist/commands/wallet.js +122 -0
- package/dist/commands/whoami.js +33 -0
- package/dist/consent.js +200 -0
- package/dist/credentials.js +311 -0
- package/dist/crypto-surface.js +54 -0
- package/dist/crypto.js +137 -0
- package/dist/download-part.js +144 -0
- package/dist/download-sink.js +213 -0
- package/dist/download.js +163 -0
- package/dist/drive-paths.js +170 -0
- package/dist/environment.js +190 -0
- package/dist/errors.js +71 -0
- package/dist/exit.js +107 -0
- package/dist/expiry.js +115 -0
- package/dist/extend-chain.js +137 -0
- package/dist/extend-plan.js +149 -0
- package/dist/extend-sign.js +130 -0
- package/dist/guards.js +15 -0
- package/dist/help.js +192 -0
- package/dist/human-check.js +54 -0
- package/dist/item-trash.js +25 -0
- package/dist/kit-file.js +133 -0
- package/dist/list-file.js +71 -0
- package/dist/list-view-find.js +71 -0
- package/dist/list-view-order.js +96 -0
- package/dist/main.js +394 -0
- package/dist/manifest-create.js +81 -0
- package/dist/manifest-write.js +244 -0
- package/dist/manifest.js +213 -0
- package/dist/mark-render.js +45 -0
- package/dist/mcp-args.js +154 -0
- package/dist/mcp-tools/context.js +50 -0
- package/dist/mcp-tools/files.js +133 -0
- package/dist/mcp-tools/organise.js +128 -0
- package/dist/mcp-tools/reads.js +89 -0
- package/dist/mcp-tools/share.js +61 -0
- package/dist/mcp.js +142 -0
- package/dist/net-retry.js +84 -0
- package/dist/network.js +43 -0
- package/dist/notice.js +45 -0
- package/dist/product.js +38 -0
- package/dist/progress.js +110 -0
- package/dist/prompt.js +145 -0
- package/dist/rebuild.js +255 -0
- package/dist/recovery-assemble.js +148 -0
- package/dist/recovery-build.js +260 -0
- package/dist/recovery-map-file.js +114 -0
- package/dist/recovery-map.js +191 -0
- package/dist/recovery-release.js +229 -0
- package/dist/recovery-seq.js +63 -0
- package/dist/recovery-source.js +143 -0
- package/dist/registration.js +83 -0
- package/dist/s3/listing.js +134 -0
- package/dist/s3/multipart.js +104 -0
- package/dist/s3/response-sink.js +41 -0
- package/dist/s3/same-file.js +117 -0
- package/dist/s3/server.js +252 -0
- package/dist/s3/sigv4.js +168 -0
- package/dist/s3/staging.js +87 -0
- package/dist/s3/xml.js +80 -0
- package/dist/safe-path.js +89 -0
- package/dist/seal.js +206 -0
- package/dist/secret-reader.js +112 -0
- package/dist/server.js +33 -0
- package/dist/session.js +49 -0
- package/dist/setup-questions.js +31 -0
- package/dist/share.js +195 -0
- package/dist/shared/lib/crypto/size-padding.js +126 -0
- package/dist/shared/lib/drive/manifest-codec.js +287 -0
- package/dist/shared/lib/drive/manifest-index.js +235 -0
- package/dist/shared/lib/drive/manifest-ops.js +267 -0
- package/dist/shared/lib/drive/manifest-settings.js +65 -0
- package/dist/shared/lib/drive/name-conflict.js +121 -0
- package/dist/shared/lib/drive/unique-name.js +48 -0
- package/dist/shared/lib/extend/epochs.js +256 -0
- package/dist/shared/lib/net/retry-budget.js +95 -0
- package/dist/shared/lib/share/shared-file-info.js +65 -0
- package/dist/shared/lib/storage-network.js +64 -0
- package/dist/shared/lib/upload/part-plan.js +28 -0
- package/dist/stdout.js +109 -0
- package/dist/trash-sweep.js +123 -0
- package/dist/units.js +19 -0
- package/dist/update-check.js +164 -0
- package/dist/update-source.js +143 -0
- package/dist/upload-api.js +98 -0
- package/dist/upload-file.js +242 -0
- package/dist/upload-price.js +117 -0
- package/dist/upload-steps.js +111 -0
- package/dist/upload-store.js +252 -0
- package/dist/upload-wire.js +25 -0
- package/dist/upload.js +294 -0
- package/dist/usage-report.js +54 -0
- package/dist/wallet-chain.js +76 -0
- package/dist/wallet.js +209 -0
- package/dist/walrus-write.js +188 -0
- package/dist/walrus.js +156 -0
- package/package.json +56 -0
- package/vendor/nmts-crypto/nmts_crypto_wasm.d.ts +469 -0
- package/vendor/nmts-crypto/nmts_crypto_wasm.js +1300 -0
- package/vendor/nmts-crypto/nmts_crypto_wasm_bg.wasm +0 -0
- package/vendor/nmts-crypto/nmts_crypto_wasm_bg.wasm.d.ts +55 -0
- package/vendor/nmts-crypto/package.json +17 -0
package/dist/download.js
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// Getting one file's bytes back: ask the server where the parts are, read them, open them, check them.
|
|
2
|
+
//
|
|
3
|
+
// ⛔ THE SERVER NEVER SEES THE FILE, AND NOTHING HERE TRUSTS IT WITH ONE. It says which stored
|
|
4
|
+
// objects a file is made of and how big the file is; every byte that becomes the file is
|
|
5
|
+
// decrypted here under a key derived from the account code, and the whole thing is checked
|
|
6
|
+
// against a hash the account sealed for itself. A server that lies about the parts, or an
|
|
7
|
+
// aggregator that returns the wrong bytes, produces a refusal — never a quietly wrong file.
|
|
8
|
+
//
|
|
9
|
+
// ⛔ ONLY THE LAST PART MAY BE PADDED. That is the write side's promise (a part carries the bytes
|
|
10
|
+
// it declares, and only the last is rounded up to hide the true size), and it is what makes the
|
|
11
|
+
// answer unique: with padding allowed anywhere, several splits would fit the same numbers and a
|
|
12
|
+
// reader would be guessing which one produced the file. It is checked here rather than assumed.
|
|
13
|
+
//
|
|
14
|
+
// ⛔⭐ THE FILE IS NEVER HELD, AND THE INTEGRITY PROMISE MOVED RATHER THAN WEAKENED. This used to
|
|
15
|
+
// keep every decrypted byte, check the whole-file digest, and hand back a finished array — so
|
|
16
|
+
// nothing wrong could ever reach a destination. It also meant a file that could be UPLOADED
|
|
17
|
+
// (part by part, bounded by one part) could not be brought back on a machine smaller than the
|
|
18
|
+
// file: the ceiling had simply moved to the other direction. Plaintext now flows to a
|
|
19
|
+
// `PlaintextSink` as it is produced, and the promise is kept by the destination instead — a
|
|
20
|
+
// file lands under a temporary name in the destination's own directory and is renamed into
|
|
21
|
+
// place only after the digest matches, and `--out -` still proves the whole file before it
|
|
22
|
+
// hands over a byte because a pipe has no rename (`download-sink.ts` carries both arguments).
|
|
23
|
+
//
|
|
24
|
+
// ⛔ WHAT ONE DOWNLOAD COSTS IN MEMORY IS NOW ONE PART PLUS ONE CHUNK, not the file: the sealed
|
|
25
|
+
// bytes of the part being read (64 MiB by default, whatever `--part-size` the uploader chose),
|
|
26
|
+
// the engine's own buffer for the chunk it is assembling, and the one chunk of plaintext it
|
|
27
|
+
// hands back — 4 MiB each in NCF-3. A hundred-gigabyte file costs the same as a hundred-megabyte
|
|
28
|
+
// one. The exception is `--out -`, which is bounded by `STDOUT_HOLD_LIMIT` and refuses above it.
|
|
29
|
+
import { createHash } from "node:crypto";
|
|
30
|
+
import { request } from "./api.js";
|
|
31
|
+
import { AAD, DERIVED, loadCrypto } from "./crypto.js";
|
|
32
|
+
import { asParts, fetchPart, openPart } from "./download-part.js";
|
|
33
|
+
import { NmtsError } from "./errors.js";
|
|
34
|
+
/**
|
|
35
|
+
* Fetch, decrypt and verify one file.
|
|
36
|
+
*
|
|
37
|
+
* The account code is used and not kept: the data key is derived, unwrapped keys are zeroed, and
|
|
38
|
+
* the derivation output — which holds every other key in the account — never outlives this call.
|
|
39
|
+
*/
|
|
40
|
+
/**
|
|
41
|
+
* Fetch, decrypt and verify one file whose key is ALREADY OPEN.
|
|
42
|
+
*
|
|
43
|
+
* ⛔ SPLIT OUT BECAUSE THERE ARE TWO WAYS TO GET THAT KEY, and only one of them belongs to the
|
|
44
|
+
* account holding it. A file this account owns has its key wrapped in its own sealed list; a
|
|
45
|
+
* file somebody SHARED has its key inside an envelope only this account can open, sealed under
|
|
46
|
+
* a different separator, and its real length comes from what the sender sealed rather than from
|
|
47
|
+
* the account's own list. Everything after the key is identical — and writing it twice is how
|
|
48
|
+
* one copy comes to check the hash and the other does not.
|
|
49
|
+
*/
|
|
50
|
+
export async function fetchWithKey(input) {
|
|
51
|
+
const described = asParts(await request(input.base, input.descriptorPath, { token: input.apiKey }));
|
|
52
|
+
if (described.parts.length === 0) {
|
|
53
|
+
throw new NmtsError("The server says this file has no stored parts.", {
|
|
54
|
+
nextStep: "Nothing was written. The file list names it, so open the account in a browser and compare.",
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
// ⛔ The server's `size` is a hint; the length the caller was given is the authority.
|
|
58
|
+
const ordered = [...described.parts].sort((a, b) => a.part_index - b.part_index);
|
|
59
|
+
const crypt = await loadCrypto();
|
|
60
|
+
const dek = input.dek;
|
|
61
|
+
const expected = input.expected;
|
|
62
|
+
return collect(crypt, ordered, dek, expected, input.size, input.chain, input.read, input.sink);
|
|
63
|
+
}
|
|
64
|
+
export async function fetchFile(input) {
|
|
65
|
+
const crypt = await loadCrypto();
|
|
66
|
+
const [from, to] = DERIVED.dataKey;
|
|
67
|
+
const derived = crypt.kdf_derive(crypt.account_code_parse(input.accountCode));
|
|
68
|
+
const dataKey = derived.slice(from, to);
|
|
69
|
+
derived.fill(0);
|
|
70
|
+
let dek;
|
|
71
|
+
try {
|
|
72
|
+
dek = crypt.envelope_open(dataKey, new TextEncoder().encode(AAD.dekWrap), Buffer.from(input.dekWrapped, "base64url"));
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
dataKey.fill(0);
|
|
76
|
+
throw new NmtsError("This file's key did not open with this account's key.", {
|
|
77
|
+
nextStep: "Either the account code belongs to somebody else, or the file list has been altered.",
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
let expected = null;
|
|
81
|
+
if (input.contentHashCt !== undefined && input.contentHashCt !== "") {
|
|
82
|
+
try {
|
|
83
|
+
expected = crypt.envelope_open(dataKey, new TextEncoder().encode(AAD.contentHash), Buffer.from(input.contentHashCt, "base64url"));
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
dataKey.fill(0);
|
|
87
|
+
dek.fill(0);
|
|
88
|
+
throw new NmtsError("This file's recorded hash did not open with this account's key.");
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
dataKey.fill(0);
|
|
92
|
+
return fetchWithKey({
|
|
93
|
+
base: input.base,
|
|
94
|
+
apiKey: input.apiKey,
|
|
95
|
+
descriptorPath: `/v1/items/${encodeURIComponent(input.itemId)}/parts?for=download`,
|
|
96
|
+
size: input.size,
|
|
97
|
+
dek,
|
|
98
|
+
expected,
|
|
99
|
+
chain: input.chain,
|
|
100
|
+
sink: input.sink,
|
|
101
|
+
...(input.read === undefined ? {} : { read: input.read }),
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Read every part, open it as it arrives, pass on exactly what it contributes, and check the
|
|
106
|
+
* whole against a hash before any of it is made visible.
|
|
107
|
+
*
|
|
108
|
+
* ⛔ ONLY THE LAST PART MAY BE PADDED. Every part but the last contributes all of itself; the last
|
|
109
|
+
* is trimmed to what is left of the file. That is the write side's promise, and it is what
|
|
110
|
+
* makes the arithmetic have one answer instead of several.
|
|
111
|
+
*
|
|
112
|
+
* ⛔ THE SINK IS COMMITTED IN EXACTLY ONE PLACE — after the parts add up AND the digest matches —
|
|
113
|
+
* and abandoned on every other way out of this function. That pairing is the whole of the
|
|
114
|
+
* integrity guarantee now that bytes move before the last chunk is checked: a reader either
|
|
115
|
+
* sees the finished file or sees nothing under that name.
|
|
116
|
+
*/
|
|
117
|
+
async function collect(crypt, ordered, dek, expected, size, chain, read, sink) {
|
|
118
|
+
const hasher = createHash("sha256");
|
|
119
|
+
let remaining = size;
|
|
120
|
+
try {
|
|
121
|
+
// ⛔ Asked before a byte is fetched. A destination that cannot take a file this size says so
|
|
122
|
+
// now, while "nothing was written" is still free.
|
|
123
|
+
sink.expect(size);
|
|
124
|
+
for (let i = 0; i < ordered.length; i += 1) {
|
|
125
|
+
const part = ordered[i];
|
|
126
|
+
if (part === undefined)
|
|
127
|
+
continue;
|
|
128
|
+
const sealed = await fetchPart(part, chain, read);
|
|
129
|
+
remaining -= await openPart(crypt, dek, part, sealed, i === ordered.length - 1, remaining, async (body) => {
|
|
130
|
+
hasher.update(body);
|
|
131
|
+
await sink.write(body);
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
if (remaining !== 0) {
|
|
135
|
+
throw new NmtsError(`The stored parts are ${remaining} bytes short of the file this list describes.`, {
|
|
136
|
+
nextStep: "Nothing was written. Open the account in a browser and compare before uploading anything again.",
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
if (expected !== null) {
|
|
140
|
+
const got = hasher.digest();
|
|
141
|
+
const same = expected.length === got.length && expected.every((b, i) => b === got[i]);
|
|
142
|
+
expected.fill(0);
|
|
143
|
+
if (!same) {
|
|
144
|
+
throw new NmtsError("The file came back whole but does not match the hash this account recorded for it.", {
|
|
145
|
+
nextStep: "Nothing was written. Every part decrypted, so this is not a wrong key — the bytes " +
|
|
146
|
+
"themselves are not the ones that were uploaded.",
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
const delivered = await sink.commit();
|
|
151
|
+
return { byteCount: size, partCount: ordered.length, contentHashChecked: expected !== null, delivered };
|
|
152
|
+
}
|
|
153
|
+
catch (failure) {
|
|
154
|
+
// ⛔ EVERY way out that is not the commit above leaves nothing behind — a wrong network, a
|
|
155
|
+
// part that would not open, arithmetic that does not add up, a digest that does not match,
|
|
156
|
+
// a disk that filled. This is the one place that knows the download did not finish.
|
|
157
|
+
await sink.abandon();
|
|
158
|
+
throw failure;
|
|
159
|
+
}
|
|
160
|
+
finally {
|
|
161
|
+
dek.fill(0);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// Turning a path a person typed into an entry in the sealed file list, and back.
|
|
2
|
+
//
|
|
3
|
+
// ⛔ PATHS ARE NOT STORED — THEY ARE COMPUTED. The list holds a name and a parent id per entry, so
|
|
4
|
+
// "photos/2026/a.jpg" exists only as the walk from a.jpg up to the root. Two folders may share
|
|
5
|
+
// a last name at different depths, which is why every lookup here matches the WHOLE path and
|
|
6
|
+
// why an ambiguous one is refused rather than resolved to the first hit.
|
|
7
|
+
//
|
|
8
|
+
// ⛔ AND NOTHING HERE GUESSES. A path that names nothing, and a path that names two things, are
|
|
9
|
+
// both refusals with their own words. A command that picked one would eventually pick wrong on
|
|
10
|
+
// somebody's account and delete the other file.
|
|
11
|
+
//
|
|
12
|
+
// ⛔ "IN THE TRASH" IS INHERITED, NOT STAMPED (2026-08-23, found by adversarial review).
|
|
13
|
+
// Trashing a folder marks the FOLDER and nothing under it, on purpose: stamping the children
|
|
14
|
+
// would restart each one's own thirty-day clock and make "restore" ambiguous. So an entry is in
|
|
15
|
+
// the trash when it OR ANY ANCESTOR carries `deletedAt`, and reading only `entry.deletedAt` is
|
|
16
|
+
// wrong. This file used to do exactly that, and the result was a drive that listed files whose
|
|
17
|
+
// bytes the server had already stopped serving — the one state this tool is built to avoid.
|
|
18
|
+
// ▶ The walk lives in the browser's index module, which is copied here byte for byte.
|
|
19
|
+
//
|
|
20
|
+
// ⛔ ONE PATH FUNCTION, NOT THREE. `ls`, `get` and the lookups below each had their own walk, and
|
|
21
|
+
// two of them disagreed about a broken parent chain — so `ls` printed a path (`…/a.txt`) that no
|
|
22
|
+
// command would accept, while an unrelated healthy file at `a.txt` became unaddressable because
|
|
23
|
+
// the third walk silently resolved the orphan to the same string. **The path that is printed
|
|
24
|
+
// must be the path that matches**, so there is one function and everybody calls it.
|
|
25
|
+
import { NmtsError } from "./errors.js";
|
|
26
|
+
import { buildIndex, isLive, KIND_FILE as SHARED_KIND_FILE, KIND_FOLDER as SHARED_KIND_FOLDER, trashedAt, } from "./shared/lib/drive/manifest-index.js";
|
|
27
|
+
/** Folder. The same numeric codes the items API uses. */
|
|
28
|
+
export const KIND_FOLDER = SHARED_KIND_FOLDER;
|
|
29
|
+
/** File. */
|
|
30
|
+
export const KIND_FILE = SHARED_KIND_FILE;
|
|
31
|
+
export { buildIndex, isLive, trashedAt };
|
|
32
|
+
/**
|
|
33
|
+
* What a broken parent chain is drawn as.
|
|
34
|
+
*
|
|
35
|
+
* ⚠ It is deliberately a character nobody types. An entry whose parent is missing HAS no path a
|
|
36
|
+
* person can name, and pretending it sits at the root would let one orphan take a healthy file
|
|
37
|
+
* hostage: two different entries would answer to the same string and every command would refuse
|
|
38
|
+
* both as ambiguous.
|
|
39
|
+
*/
|
|
40
|
+
const DETACHED = "…";
|
|
41
|
+
/**
|
|
42
|
+
* The full path of one entry, marked when the walk could not reach the root.
|
|
43
|
+
*
|
|
44
|
+
* ⚠ The `seen` set is not defensive tidiness: a list where two folders are each other's parent
|
|
45
|
+
* would loop forever, and a list is a file that can arrive from anywhere.
|
|
46
|
+
*/
|
|
47
|
+
export function fullPathOf(index, entry) {
|
|
48
|
+
const parts = [entry.name];
|
|
49
|
+
const seen = new Set([entry.id]);
|
|
50
|
+
let parentId = entry.parentId;
|
|
51
|
+
let detached = false;
|
|
52
|
+
while (parentId !== null) {
|
|
53
|
+
const parent = index.byId.get(parentId);
|
|
54
|
+
if (parent === undefined || seen.has(parent.id)) {
|
|
55
|
+
detached = true;
|
|
56
|
+
break;
|
|
57
|
+
}
|
|
58
|
+
seen.add(parent.id);
|
|
59
|
+
parts.unshift(parent.name);
|
|
60
|
+
parentId = parent.parentId;
|
|
61
|
+
}
|
|
62
|
+
if (detached)
|
|
63
|
+
parts.unshift(DETACHED);
|
|
64
|
+
return parts.join("/");
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* `/photos/2026/` and `./photos/2026` and `photos/2026` are one path.
|
|
68
|
+
*
|
|
69
|
+
* ⛔ AND SO ARE THE TWO SPELLINGS OF `café`. Unicode gives the same visible name more than one
|
|
70
|
+
* byte sequence — macOS hands back the decomposed form from the shell and the filesystem while
|
|
71
|
+
* a browser typically wrote the composed one. Comparing raw bytes meant `nmts rm café` could
|
|
72
|
+
* address a DIFFERENT entry from the one on screen. Both sides of every comparison here are
|
|
73
|
+
* folded to one form; what gets STORED is untouched, because the name belongs to whoever wrote
|
|
74
|
+
* it.
|
|
75
|
+
*/
|
|
76
|
+
/**
|
|
77
|
+
* Strip a folder's own path off one of its descendants, in DRIVE terms.
|
|
78
|
+
*
|
|
79
|
+
* ⛔ IT IS STRING ARITHMETIC AND NOT `node:path`. A drive path always uses `/`, whatever separator
|
|
80
|
+
* the machine reading it happens to use, and `path.relative` answers in the MACHINE's
|
|
81
|
+
* separator. On Windows that turned `deep/under.txt` into `deep\under.txt`, which the
|
|
82
|
+
* containment check downstream then refused as a name trying to leave its directory — so
|
|
83
|
+
* fetching a folder failed outright on one of the three platforms this tool ships for, and
|
|
84
|
+
* every test passed, because on the other two the two separators are the same character.
|
|
85
|
+
* ⚠ That is the whole class: a drive path and a path on this disk are different kinds of thing,
|
|
86
|
+
* and `node:path` is only ever right about the second.
|
|
87
|
+
*
|
|
88
|
+
* A path that is not under the prefix comes back unchanged — the caller decides what that means.
|
|
89
|
+
*/
|
|
90
|
+
export function underPrefix(prefix, drivePath) {
|
|
91
|
+
if (prefix === "")
|
|
92
|
+
return drivePath;
|
|
93
|
+
const head = prefix.endsWith("/") ? prefix : `${prefix}/`;
|
|
94
|
+
return drivePath.startsWith(head) ? drivePath.slice(head.length) : drivePath;
|
|
95
|
+
}
|
|
96
|
+
export function normalisePath(input) {
|
|
97
|
+
const stripped = input.replace(/^\.?\/+/u, "").replace(/\/+$/u, "").normalize("NFC");
|
|
98
|
+
// A bare "." is where you already are — the top of the drive, not a folder called ".".
|
|
99
|
+
return stripped === "." ? "" : stripped;
|
|
100
|
+
}
|
|
101
|
+
/** The same folding, for one name rather than a path. */
|
|
102
|
+
export function normaliseName(name) {
|
|
103
|
+
return name.normalize("NFC");
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* The one entry at this path, or a refusal saying which of the two ways it failed.
|
|
107
|
+
*
|
|
108
|
+
* ⛔ EXIT CODE 4, NOT 1: the command exists and could not do it, which is a different thing from
|
|
109
|
+
* the command being wrong. An agent is told to stop rather than to retry.
|
|
110
|
+
*/
|
|
111
|
+
export function entryAt(entries, path, options = {}) {
|
|
112
|
+
const target = normalisePath(path);
|
|
113
|
+
if (target === "") {
|
|
114
|
+
throw new NmtsError(`"${path}" names the whole drive, not one thing in it.`, { exitCode: 2 });
|
|
115
|
+
}
|
|
116
|
+
const index = buildIndex(entries);
|
|
117
|
+
const at = (e) => normalisePath(fullPathOf(index, e)) === target;
|
|
118
|
+
const matches = entries.filter((e) => (options.includeTrashed === true || isLive(index, e)) &&
|
|
119
|
+
(options.kind === undefined || e.kind === options.kind) &&
|
|
120
|
+
at(e));
|
|
121
|
+
const only = matches[0];
|
|
122
|
+
if (only === undefined) {
|
|
123
|
+
// ⛔ A path that exists BUT IS IN THE TRASH gets its own sentence. "No such thing" would send
|
|
124
|
+
// somebody looking for a typo when what they need is `--all` or `restore`.
|
|
125
|
+
const trashed = entries.some((e) => !isLive(index, e) && at(e));
|
|
126
|
+
throw new NmtsError(trashed ? `"${target}" is in the trash.` : `Nothing in this account is at "${target}".`, {
|
|
127
|
+
exitCode: 4,
|
|
128
|
+
nextStep: trashed
|
|
129
|
+
? `${options.nothingHappened ?? "Nothing changed."} \`nmts ls --all\` lists the trash.`
|
|
130
|
+
: `${options.nothingHappened ?? "Nothing changed."} \`nmts ls\` lists what is there.`,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
if (matches.length > 1) {
|
|
134
|
+
throw new NmtsError(`"${target}" names ${matches.length} things in this account.`, {
|
|
135
|
+
exitCode: 4,
|
|
136
|
+
nextStep: `${options.nothingHappened ?? "Nothing changed."} Rename one of them first.`,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
return only;
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* The folder id a destination names, or null for the top of the drive.
|
|
143
|
+
*
|
|
144
|
+
* ⚠ An empty destination is the ROOT, and that is not the same as "no destination given" being an
|
|
145
|
+
* error: `--to ""` and `--to /` both mean the top, which is what somebody types to move
|
|
146
|
+
* something back out of a folder.
|
|
147
|
+
*/
|
|
148
|
+
export function folderIdFor(wanted, entries, nothingHappened) {
|
|
149
|
+
if (wanted === undefined)
|
|
150
|
+
return null;
|
|
151
|
+
const target = normalisePath(wanted);
|
|
152
|
+
if (target === "" || target === ".")
|
|
153
|
+
return null;
|
|
154
|
+
return entryAt(entries, target, { kind: KIND_FOLDER, ...(nothingHappened ? { nothingHappened } : {}) }).id;
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* The names already used in one folder — what a new or renamed entry must not collide with.
|
|
158
|
+
*
|
|
159
|
+
* ⚠ Folded the same way paths are, so the two spellings of one visible name count as one taken
|
|
160
|
+
* name. Two entries a person cannot tell apart are worse than a refusal they can act on.
|
|
161
|
+
*/
|
|
162
|
+
export function namesIn(entries, parentId) {
|
|
163
|
+
const taken = new Set();
|
|
164
|
+
for (const e of entries) {
|
|
165
|
+
// A trashed entry still holds its name: restoring it must not land on top of a live one.
|
|
166
|
+
if (e.parentId === parentId)
|
|
167
|
+
taken.add(normaliseName(e.name));
|
|
168
|
+
}
|
|
169
|
+
return taken;
|
|
170
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// Working out where this tool is running, and what that means for the account code.
|
|
2
|
+
//
|
|
3
|
+
// ⛔ IT IS FOR THE AGENT, AND THE AGENT IS FOR THE PERSON. A program driving this tool cannot see
|
|
4
|
+
// what a person can — whether it is inside a container, whether the home directory survives the
|
|
5
|
+
// next run, whether the code it was handed can be kept private here. Those are the facts that
|
|
6
|
+
// decide whether an upload is safe to start, and they are exactly the facts nobody thinks to
|
|
7
|
+
// mention. So they are measured and printed.
|
|
8
|
+
//
|
|
9
|
+
// ⛔ EVERY ANSWER IS MEASURED OR "UNKNOWN". There is no guessing here: not knowing is a fact worth
|
|
10
|
+
// reporting, and an invented one is worse than a missing one. The failure this avoids is the
|
|
11
|
+
// tool cheerfully saying "not a container" on a runtime it has never heard of.
|
|
12
|
+
//
|
|
13
|
+
// ⚠ NOTHING HERE IS A SECURITY BOUNDARY. Everything below is discoverable by anything running in
|
|
14
|
+
// the same place; none of it protects anything. It exists so that what happens next is a
|
|
15
|
+
// decision rather than a surprise.
|
|
16
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
17
|
+
import { platform, release, tmpdir, userInfo } from "node:os";
|
|
18
|
+
import { configDir, codeStorageIsPrivate, modesAreEnforced } from "./credentials.js";
|
|
19
|
+
/**
|
|
20
|
+
* Is this a container, and which kind?
|
|
21
|
+
*
|
|
22
|
+
* The three signals, in order of how much they actually prove:
|
|
23
|
+
* · `/run/.containerenv` — Podman writes it, and it names its own settings inside.
|
|
24
|
+
* · `/.dockerenv` — Docker writes it and has for a decade.
|
|
25
|
+
* · the process's own cgroup line naming a runtime.
|
|
26
|
+
* On a platform where none of those files can exist the answer is `unknown`, not `none`.
|
|
27
|
+
*/
|
|
28
|
+
export function detectContainment() {
|
|
29
|
+
if (platform() !== "linux")
|
|
30
|
+
return "unknown";
|
|
31
|
+
if (existsSync("/run/.containerenv"))
|
|
32
|
+
return "podman";
|
|
33
|
+
if (existsSync("/.dockerenv"))
|
|
34
|
+
return "docker";
|
|
35
|
+
try {
|
|
36
|
+
const cgroup = readFileSync("/proc/1/cgroup", "utf8");
|
|
37
|
+
if (/\b(docker|podman|containerd|kubepods|lxc)\b/.test(cgroup))
|
|
38
|
+
return "container";
|
|
39
|
+
// PID 1 in a plain cgroup-v2 root line is what an ordinary host looks like.
|
|
40
|
+
return "none";
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return "unknown";
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Is this process inside a USER NAMESPACE that maps root to somebody else?
|
|
48
|
+
*
|
|
49
|
+
* ⛔ THIS IS WHAT "ROOTLESS" ACTUALLY MEANS, and measuring the uid instead gets it backwards.
|
|
50
|
+
* A rootless Podman container runs as uid 0 INSIDE — so "am I root?" answers yes — while that
|
|
51
|
+
* 0 maps to an ordinary user on the host, which is the whole point. `/run/.containerenv` was
|
|
52
|
+
* the obvious place to ask and it is EMPTY on the Podman measured here (2026-08-23), so the
|
|
53
|
+
* answer has to come from the mapping itself.
|
|
54
|
+
*
|
|
55
|
+
* `/proc/self/uid_map` is `<inside> <outside> <count>` per line. Root mapped to root over the
|
|
56
|
+
* whole range is the ordinary, un-namespaced case:
|
|
57
|
+
* host / rootful Docker: `0 0 4294967295`
|
|
58
|
+
* rootless Podman: `0 1000 1` then `1 100000 65536`
|
|
59
|
+
*
|
|
60
|
+
* `null` on any platform or kernel that does not publish it — not knowing is a fact, not a no.
|
|
61
|
+
*/
|
|
62
|
+
function inUserNamespace() {
|
|
63
|
+
try {
|
|
64
|
+
const first = readFileSync("/proc/self/uid_map", "utf8").trim().split("\n")[0];
|
|
65
|
+
const parts = first?.trim().split(/\s+/) ?? [];
|
|
66
|
+
if (parts.length < 3)
|
|
67
|
+
return null;
|
|
68
|
+
const inside = Number(parts[0]);
|
|
69
|
+
const outside = Number(parts[1]);
|
|
70
|
+
if (!Number.isFinite(inside) || !Number.isFinite(outside))
|
|
71
|
+
return null;
|
|
72
|
+
return !(inside === 0 && outside === 0);
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/** Could this process open a browser for a person to look at? */
|
|
79
|
+
function canOpenBrowser() {
|
|
80
|
+
if (platform() === "darwin" || platform() === "win32")
|
|
81
|
+
return true;
|
|
82
|
+
// On Linux a graphical session is what makes a browser possible. Neither variable being set is
|
|
83
|
+
// the ordinary case for a container, a build step, or a machine reached over ssh.
|
|
84
|
+
return Boolean(process.env["DISPLAY"] || process.env["WAYLAND_DISPLAY"]);
|
|
85
|
+
}
|
|
86
|
+
export function readEnvironment() {
|
|
87
|
+
const containment = detectContainment();
|
|
88
|
+
const inContainer = containment === "docker" || containment === "podman" || containment === "container";
|
|
89
|
+
let uid = null;
|
|
90
|
+
try {
|
|
91
|
+
uid = modesAreEnforced() ? userInfo().uid : null;
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
uid = null;
|
|
95
|
+
}
|
|
96
|
+
return {
|
|
97
|
+
os: platform(),
|
|
98
|
+
osRelease: release(),
|
|
99
|
+
node: process.versions.node,
|
|
100
|
+
containment,
|
|
101
|
+
rootMapped: inContainer ? inUserNamespace() : null,
|
|
102
|
+
uid,
|
|
103
|
+
privateStorage: codeStorageIsPrivate(),
|
|
104
|
+
configDir: configDir(),
|
|
105
|
+
interactive: process.stdin.isTTY === true,
|
|
106
|
+
browserReachable: canOpenBrowser(),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* What this environment means, in the terms somebody has to decide in.
|
|
111
|
+
*
|
|
112
|
+
* ⛔ WRITTEN FOR AN AGENT TO REPEAT TO A PERSON. Each line is a complete sentence about a fact
|
|
113
|
+
* that was measured here, so passing it along loses nothing. None of it is an instruction
|
|
114
|
+
* aimed past the reader.
|
|
115
|
+
*/
|
|
116
|
+
export function adviseFor(env, hasStoredCode) {
|
|
117
|
+
const out = [];
|
|
118
|
+
if (!env.privateStorage) {
|
|
119
|
+
out.push({
|
|
120
|
+
level: "warn",
|
|
121
|
+
text: `This filesystem does not keep the mode a file is written with, so a stored account code ` +
|
|
122
|
+
`could be read by anything else that can reach ${env.configDir}. Supplying the code for ` +
|
|
123
|
+
`each run instead of storing it avoids that.`,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
if (env.containment !== "none" && env.containment !== "unknown") {
|
|
127
|
+
out.push({
|
|
128
|
+
level: "note",
|
|
129
|
+
text: `This is a ${env.containment} container. Anything written to ${env.configDir} is lost when ` +
|
|
130
|
+
`it is removed unless that path is a volume, so a stored account code will not be there ` +
|
|
131
|
+
`next time.`,
|
|
132
|
+
});
|
|
133
|
+
out.push({
|
|
134
|
+
level: "warn",
|
|
135
|
+
text: `Do not pass the account code as an environment variable in a container: the whole ` +
|
|
136
|
+
`environment is visible to anybody who can inspect it. Put the code in a file and name ` +
|
|
137
|
+
`that file in NMTS_ACCOUNT_CODE_FILE, or pipe it in.`,
|
|
138
|
+
});
|
|
139
|
+
// ⛔ TWO DIFFERENT FACTS, and conflating them is how "rootless" gets reported backwards. What
|
|
140
|
+
// matters for the host is whether root HERE is root THERE; the uid inside is separate.
|
|
141
|
+
if (env.rootMapped === true) {
|
|
142
|
+
out.push({
|
|
143
|
+
level: "note",
|
|
144
|
+
text: `This container is rootless: root inside it is an ordinary user on the host, so a ` +
|
|
145
|
+
`mistake in any program here — this one included — is bounded by that user's reach.`,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
else if (env.rootMapped === false) {
|
|
149
|
+
out.push({
|
|
150
|
+
level: "warn",
|
|
151
|
+
text: `Root inside this container is root on the host. Running it rootless — \`podman run\` as ` +
|
|
152
|
+
`an ordinary user, or Docker's rootless mode — bounds what a mistake here can reach.`,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
if (env.uid === 0 && env.rootMapped !== true) {
|
|
156
|
+
out.push({
|
|
157
|
+
level: "warn",
|
|
158
|
+
text: `This process is running as root. Nothing this tool does needs that.`,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
if (!env.interactive && !hasStoredCode) {
|
|
163
|
+
out.push({
|
|
164
|
+
level: "warn",
|
|
165
|
+
text: `There is no terminal here, so this tool cannot ask for the account code. It has to ` +
|
|
166
|
+
`arrive in the environment or in a file named by NMTS_ACCOUNT_CODE_FILE.`,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
if (!env.browserReachable) {
|
|
170
|
+
out.push({
|
|
171
|
+
level: "note",
|
|
172
|
+
text: `No browser can be opened here. Anything that needs a human check — making an account, ` +
|
|
173
|
+
`applying for the free trial — has to be done on a machine that has one.`,
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
if (env.os === "win32") {
|
|
177
|
+
out.push({
|
|
178
|
+
level: "note",
|
|
179
|
+
text: `Windows applies no POSIX file mode, so a stored account code inherits the folder's ` +
|
|
180
|
+
`permissions rather than being restricted to one user.`,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
if (env.configDir.startsWith(tmpdir())) {
|
|
184
|
+
out.push({
|
|
185
|
+
level: "warn",
|
|
186
|
+
text: `The configuration directory is inside the temporary directory and may be cleared at any time.`,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
return out;
|
|
190
|
+
}
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// How this tool fails.
|
|
2
|
+
//
|
|
3
|
+
// ⛔ AN AGENT READS THESE. Every message here is written for a reader that will act on it without
|
|
4
|
+
// asking a person: it says what happened, and it says the one thing to do next. "Unauthorized"
|
|
5
|
+
// tells an agent nothing it can use; "your API key was revoked — issue a new one at
|
|
6
|
+
// nmts.me/account" tells it whether to retry, ask, or stop.
|
|
7
|
+
//
|
|
8
|
+
// ⛔ NOTHING SECRET IS EVER INTERPOLATED. Not the account code, not the API key, not a session
|
|
9
|
+
// token. An error string is the one place a secret escapes without anybody choosing to print
|
|
10
|
+
// it, and agents copy error strings into logs and prompts by default.
|
|
11
|
+
//
|
|
12
|
+
// ⛔⭐ THAT RULE COVERS WHAT THIS TOOL WRITES — AND `renderError` PRINTS WHAT IT DID NOT. An
|
|
13
|
+
// unknown error's `message` goes out verbatim, which is right (a swallowed cause is a debug
|
|
14
|
+
// session nobody can start) and is also a hole: an adversarial review made `JSON.parse` fail on
|
|
15
|
+
// the credentials file, and V8's own message quotes about thirty characters of the input —
|
|
16
|
+
// nine symbols of a real account code reached stderr. The fix is not here. It is that anything
|
|
17
|
+
// reading a file the code is in must catch its own parser and throw a message of its own
|
|
18
|
+
// (`credentials.ts`, `unusable`). ▶ Any NEW code that parses a secret-bearing file owes the
|
|
19
|
+
// same, and this paragraph is the reason why.
|
|
20
|
+
/** A failure this tool understood, with an exit code and something the caller can do. */
|
|
21
|
+
export class NmtsError extends Error {
|
|
22
|
+
exitCode;
|
|
23
|
+
/** One line naming the next action, or null when there is nothing useful to suggest. */
|
|
24
|
+
nextStep;
|
|
25
|
+
constructor(message, options = {}) {
|
|
26
|
+
super(message);
|
|
27
|
+
this.name = "NmtsError";
|
|
28
|
+
this.exitCode = options.exitCode ?? 1;
|
|
29
|
+
this.nextStep = options.nextStep ?? null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/** Nothing is signed in on this machine and no code was supplied. */
|
|
33
|
+
export class NotLoggedInError extends NmtsError {
|
|
34
|
+
constructor(binary, envVar) {
|
|
35
|
+
super(`No NMTS account code on this machine.`, {
|
|
36
|
+
exitCode: 3,
|
|
37
|
+
nextStep: `Run \`${binary} login\`, or set ${envVar} in the environment.`,
|
|
38
|
+
});
|
|
39
|
+
this.name = "NotLoggedInError";
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/** The command exists but is not built yet. Said plainly rather than failing as if it broke. */
|
|
43
|
+
export class NotBuiltYetError extends NmtsError {
|
|
44
|
+
constructor(what) {
|
|
45
|
+
super(`${what} is not built yet.`, {
|
|
46
|
+
exitCode: 4,
|
|
47
|
+
nextStep: `This is not a failure — the command is announced but unfinished. Do not retry.`,
|
|
48
|
+
});
|
|
49
|
+
this.name = "NotBuiltYetError";
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/** Render a failure for a terminal an agent is reading. */
|
|
53
|
+
export function renderError(error, binary) {
|
|
54
|
+
if (error instanceof NmtsError) {
|
|
55
|
+
const lines = [`${binary}: ${error.message}`];
|
|
56
|
+
// ⛔ EVERY line of the next step is indented, not just the first. A multi-line explanation
|
|
57
|
+
// whose second line runs flush to the margin reads as a separate message, and the one
|
|
58
|
+
// place that matters is a warning somebody is deciding on.
|
|
59
|
+
if (error.nextStep) {
|
|
60
|
+
for (const line of error.nextStep.split("\n"))
|
|
61
|
+
lines.push(line === "" ? "" : ` ${line}`);
|
|
62
|
+
}
|
|
63
|
+
return lines.join("\n");
|
|
64
|
+
}
|
|
65
|
+
// Unknown failures print their message and nothing else — no stack, which is where paths,
|
|
66
|
+
// usernames and sometimes arguments leak into whatever the agent logs.
|
|
67
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
68
|
+
return `${binary}: ${message}`;
|
|
69
|
+
}
|
|
70
|
+
/** Exit code for an unknown failure, kept distinct from the ones above. */
|
|
71
|
+
export const UNKNOWN_FAILURE_EXIT = 1;
|