@needmoretruth/nmts-cli 0.35.0 → 0.36.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 +11 -12
- package/CHANGELOG.md +35 -0
- package/README.ko.md +19 -21
- package/README.md +21 -22
- package/dist/account.js +2 -1
- package/dist/arg-options.d.ts +3 -1
- package/dist/artifact-about.d.ts +1 -1
- package/dist/autonomy.d.ts +3 -10
- package/dist/autonomy.js +15 -17
- package/dist/bytes.d.ts +15 -0
- package/dist/bytes.js +63 -0
- package/dist/collision.d.ts +5 -5
- package/dist/collision.js +19 -22
- package/dist/commands/delete-account.js +1 -1
- package/dist/commands/erase.js +1 -1
- package/dist/commands/get.js +1 -1
- package/dist/commands/listfile.js +1 -1
- package/dist/commands/mcp.js +5 -1
- package/dist/commands/mode.js +2 -2
- package/dist/commands/on-collision.d.ts +1 -1
- package/dist/commands/on-collision.js +3 -3
- package/dist/commands/platform.d.ts +9 -0
- package/dist/commands/platform.js +153 -0
- package/dist/commands/pull.js +1 -1
- package/dist/commands/push-wallet.js +2 -1
- package/dist/commands/push.js +6 -4
- package/dist/commands/put-wallet.js +5 -3
- package/dist/commands/put.js +8 -5
- package/dist/commands/receive.js +1 -1
- package/dist/commands/settings.js +1 -1
- package/dist/commands/share.js +1 -1
- package/dist/commands/support.js +3 -3
- package/dist/commands/unlock.js +2 -2
- package/dist/commands/wallet-hall.d.ts +4 -1
- package/dist/commands/wallet-hall.js +16 -8
- package/dist/commands/wallet-storage-ops.d.ts +4 -1
- package/dist/commands/wallet-storage-ops.js +7 -2
- package/dist/commands/wallet-swap.d.ts +3 -0
- package/dist/commands/wallet-swap.js +8 -4
- package/dist/commands/wallet.d.ts +1 -1
- package/dist/crypto.d.ts +8 -5
- package/dist/crypto.js +15 -61
- package/dist/download-sink-node.d.ts +49 -0
- package/dist/download-sink-node.js +204 -0
- package/dist/download-sink.d.ts +0 -48
- package/dist/download-sink.js +6 -199
- package/dist/download.js +5 -4
- package/dist/engine-node.d.ts +19 -0
- package/dist/engine-node.js +77 -0
- package/dist/env-vars.d.ts +20 -0
- package/dist/env-vars.js +27 -0
- package/dist/gate.js +1 -1
- package/dist/help.js +9 -4
- package/dist/host-contract.d.ts +7 -0
- package/dist/host-contract.js +95 -0
- package/dist/host-node.d.ts +27 -0
- package/dist/host-node.js +192 -0
- package/dist/host.d.ts +99 -0
- package/dist/host.js +59 -0
- package/dist/index.d.ts +6 -49
- package/dist/index.js +20 -45
- package/dist/main.js +7 -6
- package/dist/manifest-chunk-cache.d.ts +3 -3
- package/dist/manifest-chunk-cache.js +39 -44
- package/dist/manifest-chunk-flow.js +15 -14
- package/dist/manifest-write.js +1 -1
- package/dist/manifest.d.ts +2 -2
- package/dist/manifest.js +37 -34
- package/dist/mcp-tools/tiers.js +1 -1
- package/dist/network.d.ts +1 -1
- package/dist/network.js +6 -2
- package/dist/platform-sign.d.ts +143 -0
- package/dist/platform-sign.js +220 -0
- package/dist/portable.d.ts +59 -0
- package/dist/portable.js +82 -0
- package/dist/product.d.ts +1 -1
- package/dist/product.js +1 -1
- package/dist/progress-node.d.ts +2 -0
- package/dist/progress-node.js +14 -0
- package/dist/progress.d.ts +0 -1
- package/dist/progress.js +4 -8
- package/dist/redact.js +3 -2
- package/dist/registration.js +5 -2
- package/dist/risk.d.ts +7 -0
- package/dist/risk.js +10 -0
- package/dist/run-log.d.ts +4 -4
- package/dist/run-log.js +29 -36
- package/dist/seal.js +5 -4
- package/dist/server.d.ts +1 -1
- package/dist/server.js +6 -2
- package/dist/setup-questions.js +2 -2
- package/dist/upload-file-node.d.ts +3 -0
- package/dist/upload-file-node.js +38 -0
- package/dist/upload-file.d.ts +2 -2
- package/dist/upload-file.js +15 -45
- package/dist/upload-price-node.d.ts +8 -0
- package/dist/upload-price-node.js +37 -0
- package/dist/upload-price.d.ts +0 -8
- package/dist/upload-price.js +4 -33
- package/dist/upload-steps.js +4 -4
- package/dist/upload-store.d.ts +14 -14
- package/dist/upload-store.js +68 -75
- package/dist/upload-wallet-chain.js +3 -2
- package/dist/upload-wallet-put.js +2 -2
- package/dist/upload-wallet.js +16 -15
- package/dist/upload.js +16 -15
- package/dist/wallet-grant.js +1 -1
- package/dist/wallet-sign-seams.d.ts +12 -0
- package/dist/wallet-sign.js +10 -13
- package/dist/walrus-write.js +3 -2
- package/dist/walrus.d.ts +1 -16
- package/dist/walrus.js +11 -22
- package/docs/commands/credits.md +1 -1
- package/docs/commands/platform.md +15 -0
- package/docs/commands/support.md +1 -1
- package/docs/commands/trash.md +1 -1
- package/docs/commands/wallet.md +11 -7
- package/package.json +13 -2
|
@@ -23,6 +23,7 @@ import { explorerTxUrl } from "../shared/lib/wallet/activity.js";
|
|
|
23
23
|
import { isValidSuiAddress } from "../shared/lib/wallet/send-rules.js";
|
|
24
24
|
import { storageOpsReads } from "../storage-control-chain.js";
|
|
25
25
|
import { coinAmount, walletAddress } from "../wallet.js";
|
|
26
|
+
import { payingWalletIndex } from "../wallet-pay-index.js";
|
|
26
27
|
import { recordWalletSpend, requireWalletGrant } from "../wallet-grant.js";
|
|
27
28
|
import { formatBytes } from "./wallet-storage.js";
|
|
28
29
|
const FUSE_WHY = {
|
|
@@ -34,7 +35,10 @@ const FUSE_WHY = {
|
|
|
34
35
|
export async function walletStorageOps(op, rest, options = {}) {
|
|
35
36
|
const say = options.write ?? ((line) => process.stdout.write(`${line}\n`));
|
|
36
37
|
const resolved = await requireAccountCode();
|
|
37
|
-
|
|
38
|
+
// ⛔ WHICH WALLET, FIRST — the resources are read from this address, the review names it, and the
|
|
39
|
+
// same wallet signs; a resource is reshaped by the wallet that holds it and by no other.
|
|
40
|
+
const wallet = await payingWalletIndex(options);
|
|
41
|
+
const address = await walletAddress(resolved.code, wallet);
|
|
38
42
|
const stored = resolved.source === "file" || resolved.source === "file-locked" ? readCredentialsFile() : null;
|
|
39
43
|
const server = resolveServer(options.server ?? stored?.server);
|
|
40
44
|
const network = resolveNetwork(server, options.network ?? stored?.network);
|
|
@@ -76,6 +80,7 @@ export async function walletStorageOps(op, rest, options = {}) {
|
|
|
76
80
|
// ④ The review.
|
|
77
81
|
if (!options.json) {
|
|
78
82
|
say(`Would ${lines.what}`);
|
|
83
|
+
say(` from ${address} (wallet ${wallet})`);
|
|
79
84
|
for (const l of lines.detail)
|
|
80
85
|
say(` ${l}`);
|
|
81
86
|
say(feeMist === null
|
|
@@ -106,7 +111,7 @@ export async function walletStorageOps(op, rest, options = {}) {
|
|
|
106
111
|
const spend = { walFrost: 0n, suiMist: feeMist ?? 0n };
|
|
107
112
|
requireWalletGrant(action, spend, new Date(options.now ?? Date.now()));
|
|
108
113
|
const sign = options.signStorage ?? (await import("../wallet-sign.js")).signStorageOp;
|
|
109
|
-
const digest = await sign({ network, code: resolved.code, shape, walrusPackageId });
|
|
114
|
+
const digest = await sign({ network, code: resolved.code, wallet, shape, walrusPackageId });
|
|
110
115
|
recordWalletSpend(spend);
|
|
111
116
|
if (options.json) {
|
|
112
117
|
say(JSON.stringify({ ...facts, signed: true, digest, explorerUrl: explorerTxUrl(digest, network) }));
|
|
@@ -22,8 +22,11 @@ export interface WalletSwapOptions {
|
|
|
22
22
|
slippageBps?: string | undefined;
|
|
23
23
|
/** `--accept-extremes`: go on past the extremes gate. A person's act — refused outside mode off. */
|
|
24
24
|
acceptExtremes?: boolean;
|
|
25
|
+
/** `--wallet N`: which wallet swaps, this run only. Absent = the account's own number. */
|
|
26
|
+
wallet?: string | undefined;
|
|
25
27
|
now?: number;
|
|
26
28
|
/** ⚠ SEAMS, NOT OPTIONS — no flag reaches them. */
|
|
29
|
+
readActiveWallet?: () => Promise<number>;
|
|
27
30
|
readChain?: (network: Network) => SwapReads | Promise<SwapReads>;
|
|
28
31
|
readPrices?: (server: string) => Promise<MarketPrices | null>;
|
|
29
32
|
sign?: SignSwap;
|
|
@@ -25,6 +25,7 @@ import { explorerTxUrl } from "../shared/lib/wallet/activity.js";
|
|
|
25
25
|
import { clampGasBudgetMist, parseTokenAmountToBaseUnits, SUI_GAS_RESERVE_MIST } from "../shared/lib/wallet/send-rules.js";
|
|
26
26
|
import { clampSlippageBps, impliedRate, marketRate, maxSwappableSuiMist, minOutFromQuote, priceDeviationBps, SLIPPAGE_BPS_DEFAULT, SLIPPAGE_BPS_MAX, SLIPPAGE_BPS_MIN, swapExtremes, } from "../shared/lib/wallet/swap-rules.js";
|
|
27
27
|
import { coinAmount, walletAddress } from "../wallet.js";
|
|
28
|
+
import { payingWalletIndex } from "../wallet-pay-index.js";
|
|
28
29
|
import { recordWalletSpend, requireWalletGrant } from "../wallet-grant.js";
|
|
29
30
|
import { railsFor } from "../wallet-swap-chain.js";
|
|
30
31
|
const EXTREME_WORDS = {
|
|
@@ -73,7 +74,10 @@ export async function walletSwap(operands, options = {}) {
|
|
|
73
74
|
throw new NmtsError("Say how much.", { exitCode: 2, nextStep: `\`${BINARY_NAME} wallet swap ${coinIn} <amount|max>\` — "max" swaps everything that can be swapped.` });
|
|
74
75
|
}
|
|
75
76
|
const resolved = await requireAccountCode();
|
|
76
|
-
|
|
77
|
+
// ⛔ WHICH WALLET SWAPS, FIRST — the coins that are read, the quote that is priced, the address
|
|
78
|
+
// the review names and the key that signs are all this one wallet's.
|
|
79
|
+
const wallet = await payingWalletIndex(options);
|
|
80
|
+
const address = await walletAddress(resolved.code, wallet);
|
|
77
81
|
const stored = resolved.source === "file" || resolved.source === "file-locked" ? readCredentialsFile() : null;
|
|
78
82
|
const server = resolveServer(options.server ?? stored?.server);
|
|
79
83
|
const network = resolveNetwork(server, options.network ?? stored?.network);
|
|
@@ -211,7 +215,7 @@ export async function walletSwap(operands, options = {}) {
|
|
|
211
215
|
// ⑥ The review, every time.
|
|
212
216
|
if (!options.json) {
|
|
213
217
|
say(`Swapping ${facts.amountIn} ${coinIn} for ${coinOut} on ${venueName(venue)}`);
|
|
214
|
-
say(` from ${address}`);
|
|
218
|
+
say(` from ${address} (wallet ${wallet})`);
|
|
215
219
|
say(` Quoted ${facts.quotedOut} ${coinOut} — the chain's answer just now, not a promise${quote.leftoverInUnits > 0n ? `; ${coinAmount(quote.leftoverInUnits)} ${coinIn} would come back unused` : ""}`);
|
|
216
220
|
if (venue === "exchange")
|
|
217
221
|
say(` Rate ${exchange?.rateWal} WAL per ${exchange?.rateSui} SUI, read off the facility; it takes no minimum`);
|
|
@@ -244,7 +248,7 @@ export async function walletSwap(operands, options = {}) {
|
|
|
244
248
|
nextStep: `Choose again (--slippage-bps 50, a different --fee-cap, the other venue, or later), or — as a person — add --accept-extremes to go on anyway.`,
|
|
245
249
|
});
|
|
246
250
|
}
|
|
247
|
-
const mode = currentMode();
|
|
251
|
+
const mode = await currentMode();
|
|
248
252
|
if (mode === "auto-low" || mode === "auto-high") {
|
|
249
253
|
throw new NmtsError("Going past the extremes gate is a person's act.", { exitCode: 5, nextStep: `Nothing was signed. --accept-extremes is refused in mode auto and with --skip-permissions.` });
|
|
250
254
|
}
|
|
@@ -273,7 +277,7 @@ export async function walletSwap(operands, options = {}) {
|
|
|
273
277
|
requireWalletGrant("exchange", spend, new Date(options.now ?? Date.now()));
|
|
274
278
|
// ⑩ The signature.
|
|
275
279
|
const sign = options.sign ?? (await import("../wallet-sign.js")).signSwap;
|
|
276
|
-
const digest = await sign({ network, code: resolved.code, shape });
|
|
280
|
+
const digest = await sign({ network, code: resolved.code, wallet, shape });
|
|
277
281
|
recordWalletSpend(spend);
|
|
278
282
|
if (options.json) {
|
|
279
283
|
say(JSON.stringify({ ...facts, signed: true, digest, explorerUrl: explorerTxUrl(digest, network) }));
|
|
@@ -9,7 +9,7 @@ export interface WalletOptions {
|
|
|
9
9
|
qr?: boolean;
|
|
10
10
|
/** `wallet address --index N`: which of this key's wallets to print. Absent = the first one. */
|
|
11
11
|
index?: string | undefined;
|
|
12
|
-
/**
|
|
12
|
+
/** `--wallet N` on `send`/`donate`/`swap`/`storage`/`hall`: which wallet, this run only. */
|
|
13
13
|
wallet?: string | undefined;
|
|
14
14
|
/** `wallet send`: the operands after "send", and its flags (`wallet-send.ts`). */
|
|
15
15
|
rest?: readonly string[];
|
package/dist/crypto.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { CryptoGlue } from "./crypto-surface.ts";
|
|
2
2
|
export type { CryptoGlue, StreamOpener, StreamSealer } from "./crypto-surface.ts";
|
|
3
3
|
/**
|
|
4
4
|
* The associated-data strings of NCF-3, for the envelopes this tool opens.
|
|
@@ -74,8 +74,11 @@ export declare const DERIVED: {
|
|
|
74
74
|
*/
|
|
75
75
|
readonly aiAccountRoot: readonly [256, 288];
|
|
76
76
|
};
|
|
77
|
-
|
|
78
|
-
|
|
77
|
+
/**
|
|
78
|
+
* The engine, loaded once by whichever host this program registered.
|
|
79
|
+
*
|
|
80
|
+
* Every derivation in the package goes through this one call, so a program that has not registered
|
|
81
|
+
* a host is told so here rather than somewhere deeper, where the message would be about a missing
|
|
82
|
+
* function instead of a missing entry point.
|
|
83
|
+
*/
|
|
79
84
|
export declare function loadCrypto(): Promise<CryptoGlue>;
|
|
80
|
-
/** For tests that need a fresh load. */
|
|
81
|
-
export declare function forgetCrypto(): void;
|
package/dist/crypto.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
//
|
|
1
|
+
// The frozen tables of NCF-3, and the one door to the engine that reads them.
|
|
2
2
|
//
|
|
3
3
|
// ⛔ NOTHING IS RE-IMPLEMENTED HERE. The bytes that derive an account's keys are the same
|
|
4
4
|
// WebAssembly the browser runs, built from the same Rust crate. Two harnesses in this repo
|
|
@@ -7,24 +7,12 @@
|
|
|
7
7
|
// be a second implementation that can drift from the first, silently and in the direction that
|
|
8
8
|
// loses files.
|
|
9
9
|
//
|
|
10
|
-
// ⛔
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
|
|
17
|
-
return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
|
|
18
|
-
});
|
|
19
|
-
}
|
|
20
|
-
return path;
|
|
21
|
-
};
|
|
22
|
-
import { readFile } from "node:fs/promises";
|
|
23
|
-
import { existsSync } from "node:fs";
|
|
24
|
-
import { dirname, join } from "node:path";
|
|
25
|
-
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
26
|
-
import { NmtsError } from "./errors.js";
|
|
27
|
-
import { isCryptoGlue, missingExports } from "./crypto-surface.js";
|
|
10
|
+
// ⛔ WHERE THE ENGINE COMES FROM IS THE HOST'S BUSINESS, not this file's. Node finds it on disk
|
|
11
|
+
// and a browser fetches it, and both answers land in `host().engine` — so the tables below, and
|
|
12
|
+
// every module that derives anything from them, are the same code in both. The shape check
|
|
13
|
+
// stays on the host's side of the door for the same reason: `crypto-surface.ts` is one gate
|
|
14
|
+
// that both loads pass, rather than two guards that can come to disagree.
|
|
15
|
+
import { host } from "./host.js";
|
|
28
16
|
/**
|
|
29
17
|
* The associated-data strings of NCF-3, for the envelopes this tool opens.
|
|
30
18
|
*
|
|
@@ -104,47 +92,13 @@ export const DERIVED = {
|
|
|
104
92
|
*/
|
|
105
93
|
aiAccountRoot: [256, 288],
|
|
106
94
|
};
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
for (const dir of candidates) {
|
|
115
|
-
if (existsSync(join(dir, "nmts_crypto_wasm_bg.wasm")))
|
|
116
|
-
return dir;
|
|
117
|
-
}
|
|
118
|
-
throw new NmtsError("The NMTS crypto engine is missing from this installation.", {
|
|
119
|
-
exitCode: 1,
|
|
120
|
-
nextStep: "Reinstall the package. Nothing can be encrypted or decrypted without it.",
|
|
121
|
-
});
|
|
122
|
-
}
|
|
123
|
-
let cached = null;
|
|
124
|
-
/** Load the engine once per process. */
|
|
95
|
+
/**
|
|
96
|
+
* The engine, loaded once by whichever host this program registered.
|
|
97
|
+
*
|
|
98
|
+
* Every derivation in the package goes through this one call, so a program that has not registered
|
|
99
|
+
* a host is told so here rather than somewhere deeper, where the message would be about a missing
|
|
100
|
+
* function instead of a missing entry point.
|
|
101
|
+
*/
|
|
125
102
|
export async function loadCrypto() {
|
|
126
|
-
|
|
127
|
-
return cached;
|
|
128
|
-
const dir = engineDir();
|
|
129
|
-
const module = await import(__rewriteRelativeImportExtension(pathToFileURL(join(dir, "nmts_crypto_wasm.js")).href));
|
|
130
|
-
if (typeof module !== "object" || module === null || !("default" in module)) {
|
|
131
|
-
throw new NmtsError("The NMTS crypto engine did not load (no initialiser).", { exitCode: 1 });
|
|
132
|
-
}
|
|
133
|
-
const init = Reflect.get(module, "default");
|
|
134
|
-
if (typeof init !== "function") {
|
|
135
|
-
throw new NmtsError("The NMTS crypto engine did not load (initialiser is not callable).", {
|
|
136
|
-
exitCode: 1,
|
|
137
|
-
});
|
|
138
|
-
}
|
|
139
|
-
await init({ module_or_path: await readFile(join(dir, "nmts_crypto_wasm_bg.wasm")) });
|
|
140
|
-
if (!isCryptoGlue(module)) {
|
|
141
|
-
const missing = missingExports(module);
|
|
142
|
-
throw new NmtsError(`The NMTS crypto engine is missing: ${missing.join(", ")}. This build does not match this tool.`, { exitCode: 1, nextStep: "Reinstall the package." });
|
|
143
|
-
}
|
|
144
|
-
cached = module;
|
|
145
|
-
return module;
|
|
146
|
-
}
|
|
147
|
-
/** For tests that need a fresh load. */
|
|
148
|
-
export function forgetCrypto() {
|
|
149
|
-
cached = null;
|
|
103
|
+
return host().engine.load();
|
|
150
104
|
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { PlaintextSink } from "./download-sink.ts";
|
|
2
|
+
import { type ByteDestination } from "./stdout.ts";
|
|
3
|
+
/**
|
|
4
|
+
* Write to a file, through a temporary name beside it, renamed into place only when the whole
|
|
5
|
+
* file has been proved.
|
|
6
|
+
*
|
|
7
|
+
* ⛔ THE TEMPORARY FILE IS IN THE SAME DIRECTORY AS THE DESTINATION, not in a system temporary
|
|
8
|
+
* directory, and that is the whole mechanism: `rename` is atomic only within one filesystem,
|
|
9
|
+
* and only an atomic rename guarantees that a reader either sees nothing under the real name or
|
|
10
|
+
* sees the finished file. A temporary directory can be — and on the machines this runs on
|
|
11
|
+
* usually is — a different filesystem, where the same call becomes copy-then-delete and a
|
|
12
|
+
* reader can catch the copy half done.
|
|
13
|
+
*
|
|
14
|
+
* ⛔ IT IS CREATED 0600 AND THE RENAME KEEPS THAT. The file is somebody's plaintext; it must not
|
|
15
|
+
* be readable by other accounts on the machine for the seconds it takes to download, any more
|
|
16
|
+
* than it may be afterwards.
|
|
17
|
+
*/
|
|
18
|
+
export declare function fileSink(destination: string, options: {
|
|
19
|
+
force: boolean;
|
|
20
|
+
}): PlaintextSink;
|
|
21
|
+
/**
|
|
22
|
+
* How much plaintext `--out -` will hold before it hands anything over. One part's worth.
|
|
23
|
+
*
|
|
24
|
+
* ⛔ NOT AN ARBITRARY NUMBER: it is the upload path's default part size, which is the most memory
|
|
25
|
+
* this tool has ever asked for. Keeping the stdout ceiling there means the whole tool's bound
|
|
26
|
+
* is one part plus one chunk whichever direction the bytes are going.
|
|
27
|
+
*/
|
|
28
|
+
export declare const STDOUT_HOLD_LIMIT: number;
|
|
29
|
+
/**
|
|
30
|
+
* Hand the whole file to whatever is reading stdout — after it has been proved, not during.
|
|
31
|
+
*
|
|
32
|
+
* ⛔ THERE IS NO RENAME ON A PIPE, so the trick the file path uses is not available: a byte handed
|
|
33
|
+
* to a reader cannot be taken back, and a reader that has already consumed half a file cannot
|
|
34
|
+
* be told afterwards that the half was wrong. An exit code does not fix that — `nmts get x
|
|
35
|
+
* --out - > y` leaves the truncated bytes in `y` whatever the exit code says, and an agent
|
|
36
|
+
* reading a pipe usually acts on what it read. So this branch keeps the ORIGINAL guarantee
|
|
37
|
+
* rather than the original size: the file is held, checked, and only then handed over, exactly
|
|
38
|
+
* as before streaming existed.
|
|
39
|
+
*
|
|
40
|
+
* ⛔ WHICH MEANS IT HAS TO REFUSE SOMEWHERE, AND IT REFUSES OUT LOUD AND EARLY. Above the ceiling
|
|
41
|
+
* the answer is a refusal naming `--out <name>`, which streams and is checked before the file
|
|
42
|
+
* appears — not a silent trade of the guarantee for the size, and not the out-of-memory crash
|
|
43
|
+
* that this used to be. `put` already refuses this way rather than discovering a limit halfway.
|
|
44
|
+
*
|
|
45
|
+
* ⚠ Every protection this branch already had is still here and still applies to the whole file:
|
|
46
|
+
* bytes a terminal would act on are refused (`readableOnATerminal`), a reader that closed the
|
|
47
|
+
* pipe first is an ordinary end and not a failure, and any other write failure throws.
|
|
48
|
+
*/
|
|
49
|
+
export declare function stdoutSink(to: ByteDestination, limit?: number): PlaintextSink;
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
// The two places this tool can put a downloaded file: a real file, and its own stdout.
|
|
2
|
+
//
|
|
3
|
+
// ⛔ THE SHAPE IS `download-sink.ts`'S AND THE PROMISE IS KEPT HERE. A sink is the thing that
|
|
4
|
+
// decides what "delivered" means, and these two decide it with a rename and with a hold — both
|
|
5
|
+
// of which need Node. A browser's sink is the caller's, written against the same interface.
|
|
6
|
+
import { randomBytes } from "node:crypto";
|
|
7
|
+
import { createWriteStream, existsSync } from "node:fs";
|
|
8
|
+
import { rename, rm } from "node:fs/promises";
|
|
9
|
+
import { basename, dirname, join } from "node:path";
|
|
10
|
+
import { NmtsError } from "./errors.js";
|
|
11
|
+
import { handOver } from "./stdout.js";
|
|
12
|
+
/** The one refusal text for a destination that is already occupied. Shared by every command. */
|
|
13
|
+
function alreadyThere(destination) {
|
|
14
|
+
return new NmtsError(`${destination} already exists.`, {
|
|
15
|
+
exitCode: 4,
|
|
16
|
+
nextStep: "Nothing was written. Pass --out to choose another name, or --force to replace it.",
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Write to a file, through a temporary name beside it, renamed into place only when the whole
|
|
21
|
+
* file has been proved.
|
|
22
|
+
*
|
|
23
|
+
* ⛔ THE TEMPORARY FILE IS IN THE SAME DIRECTORY AS THE DESTINATION, not in a system temporary
|
|
24
|
+
* directory, and that is the whole mechanism: `rename` is atomic only within one filesystem,
|
|
25
|
+
* and only an atomic rename guarantees that a reader either sees nothing under the real name or
|
|
26
|
+
* sees the finished file. A temporary directory can be — and on the machines this runs on
|
|
27
|
+
* usually is — a different filesystem, where the same call becomes copy-then-delete and a
|
|
28
|
+
* reader can catch the copy half done.
|
|
29
|
+
*
|
|
30
|
+
* ⛔ IT IS CREATED 0600 AND THE RENAME KEEPS THAT. The file is somebody's plaintext; it must not
|
|
31
|
+
* be readable by other accounts on the machine for the seconds it takes to download, any more
|
|
32
|
+
* than it may be afterwards.
|
|
33
|
+
*/
|
|
34
|
+
export function fileSink(destination, options) {
|
|
35
|
+
// ⛔ Asked BEFORE the download rather than at the end, which is where the old code found out.
|
|
36
|
+
// Fetching a file to discover it had nowhere to go spent somebody's time and the storage
|
|
37
|
+
// network's bandwidth for a refusal that was knowable up front.
|
|
38
|
+
if (!options.force && existsSync(destination))
|
|
39
|
+
throw alreadyThere(destination);
|
|
40
|
+
const temporary = join(dirname(destination), `.${basename(destination)}.nmts-${randomBytes(6).toString("hex")}.part`);
|
|
41
|
+
let stream = null;
|
|
42
|
+
let failure = null;
|
|
43
|
+
let renamed = false;
|
|
44
|
+
// ⚠ Created on the first write rather than up front: a download that fails before it produces a
|
|
45
|
+
// byte — a wrong key, a part on a network this build cannot read — then leaves no file at all,
|
|
46
|
+
// not even one that appears and disappears.
|
|
47
|
+
const opened = () => {
|
|
48
|
+
if (stream !== null)
|
|
49
|
+
return stream;
|
|
50
|
+
const fresh = createWriteStream(temporary, { flags: "wx", mode: 0o600 });
|
|
51
|
+
// ⛔ An 'error' with no listener is thrown out of the event loop and kills the process, which
|
|
52
|
+
// would turn "the disk is full" into a crash with no next step. Kept, and reported by the
|
|
53
|
+
// next call that can report it.
|
|
54
|
+
fresh.on("error", (error) => {
|
|
55
|
+
failure = error;
|
|
56
|
+
});
|
|
57
|
+
stream = fresh;
|
|
58
|
+
return fresh;
|
|
59
|
+
};
|
|
60
|
+
const closed = async () => {
|
|
61
|
+
const open = stream;
|
|
62
|
+
if (open === null)
|
|
63
|
+
return;
|
|
64
|
+
stream = null;
|
|
65
|
+
// ⚠ Waits for 'close', not 'finish': a stream that failed to open never finishes, and waiting
|
|
66
|
+
// for the wrong event there is a hang rather than an error.
|
|
67
|
+
await new Promise((resolve) => {
|
|
68
|
+
if (open.closed) {
|
|
69
|
+
resolve();
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
open.once("close", () => resolve());
|
|
73
|
+
open.end();
|
|
74
|
+
});
|
|
75
|
+
};
|
|
76
|
+
const abandon = async () => {
|
|
77
|
+
if (renamed)
|
|
78
|
+
return;
|
|
79
|
+
try {
|
|
80
|
+
await closed();
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
// Nothing to do about it: the file below is going away either way.
|
|
84
|
+
}
|
|
85
|
+
await rm(temporary, { force: true }).catch(() => undefined);
|
|
86
|
+
};
|
|
87
|
+
return {
|
|
88
|
+
// Nothing to refuse: what a disk cannot take, the write itself reports, and the temporary
|
|
89
|
+
// file means a disk that fills up leaves no file under the name somebody asked for.
|
|
90
|
+
expect: () => undefined,
|
|
91
|
+
write: async (bytes) => {
|
|
92
|
+
const target = opened();
|
|
93
|
+
await new Promise((resolve, reject) => {
|
|
94
|
+
const known = failure;
|
|
95
|
+
if (known !== null) {
|
|
96
|
+
reject(known);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
target.write(bytes, (error) => {
|
|
100
|
+
if (error === undefined || error === null)
|
|
101
|
+
resolve();
|
|
102
|
+
else
|
|
103
|
+
reject(error);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
},
|
|
107
|
+
commit: async () => {
|
|
108
|
+
// A file of zero bytes is still a file, so the stream is opened even if nothing was written.
|
|
109
|
+
opened();
|
|
110
|
+
await closed();
|
|
111
|
+
const known = failure;
|
|
112
|
+
if (known !== null) {
|
|
113
|
+
await abandon();
|
|
114
|
+
throw new NmtsError(`${destination} could not be written: ${known.message}`, {
|
|
115
|
+
exitCode: 1,
|
|
116
|
+
nextStep: "Nothing was left under that name. The download itself was fine — this is the disk.",
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
// ⛔ Asked a second time, as late as possible. The first check was before the download; a
|
|
120
|
+
// file that appeared in the meantime is somebody else's, and rename would silently
|
|
121
|
+
// replace it. The window is now the width of one system call rather than a download.
|
|
122
|
+
if (!options.force && existsSync(destination)) {
|
|
123
|
+
await abandon();
|
|
124
|
+
throw alreadyThere(destination);
|
|
125
|
+
}
|
|
126
|
+
await rename(temporary, destination);
|
|
127
|
+
renamed = true;
|
|
128
|
+
return true;
|
|
129
|
+
},
|
|
130
|
+
abandon,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* How much plaintext `--out -` will hold before it hands anything over. One part's worth.
|
|
135
|
+
*
|
|
136
|
+
* ⛔ NOT AN ARBITRARY NUMBER: it is the upload path's default part size, which is the most memory
|
|
137
|
+
* this tool has ever asked for. Keeping the stdout ceiling there means the whole tool's bound
|
|
138
|
+
* is one part plus one chunk whichever direction the bytes are going.
|
|
139
|
+
*/
|
|
140
|
+
export const STDOUT_HOLD_LIMIT = 64 * 2 ** 20;
|
|
141
|
+
/**
|
|
142
|
+
* Hand the whole file to whatever is reading stdout — after it has been proved, not during.
|
|
143
|
+
*
|
|
144
|
+
* ⛔ THERE IS NO RENAME ON A PIPE, so the trick the file path uses is not available: a byte handed
|
|
145
|
+
* to a reader cannot be taken back, and a reader that has already consumed half a file cannot
|
|
146
|
+
* be told afterwards that the half was wrong. An exit code does not fix that — `nmts get x
|
|
147
|
+
* --out - > y` leaves the truncated bytes in `y` whatever the exit code says, and an agent
|
|
148
|
+
* reading a pipe usually acts on what it read. So this branch keeps the ORIGINAL guarantee
|
|
149
|
+
* rather than the original size: the file is held, checked, and only then handed over, exactly
|
|
150
|
+
* as before streaming existed.
|
|
151
|
+
*
|
|
152
|
+
* ⛔ WHICH MEANS IT HAS TO REFUSE SOMEWHERE, AND IT REFUSES OUT LOUD AND EARLY. Above the ceiling
|
|
153
|
+
* the answer is a refusal naming `--out <name>`, which streams and is checked before the file
|
|
154
|
+
* appears — not a silent trade of the guarantee for the size, and not the out-of-memory crash
|
|
155
|
+
* that this used to be. `put` already refuses this way rather than discovering a limit halfway.
|
|
156
|
+
*
|
|
157
|
+
* ⚠ Every protection this branch already had is still here and still applies to the whole file:
|
|
158
|
+
* bytes a terminal would act on are refused (`readableOnATerminal`), a reader that closed the
|
|
159
|
+
* pipe first is an ordinary end and not a failure, and any other write failure throws.
|
|
160
|
+
*/
|
|
161
|
+
export function stdoutSink(to, limit = STDOUT_HOLD_LIMIT) {
|
|
162
|
+
const held = [];
|
|
163
|
+
let total = 0;
|
|
164
|
+
const tooLarge = (size) => new NmtsError(`This file is ${size} bytes, and \`--out -\` hands over at most ${limit}.`, {
|
|
165
|
+
exitCode: 4,
|
|
166
|
+
nextStep: "Nothing was written and nothing was sent. A pipe cannot be taken back, so this mode " +
|
|
167
|
+
"proves the whole file before it sends a byte, which means holding it. Use `--out <name>` " +
|
|
168
|
+
"— that writes any size, and the file only appears under that name once it has been checked.",
|
|
169
|
+
});
|
|
170
|
+
const forget = () => {
|
|
171
|
+
for (const run of held)
|
|
172
|
+
run.fill(0);
|
|
173
|
+
held.length = 0;
|
|
174
|
+
};
|
|
175
|
+
return {
|
|
176
|
+
expect: (size) => {
|
|
177
|
+
if (size > limit)
|
|
178
|
+
throw tooLarge(size);
|
|
179
|
+
},
|
|
180
|
+
write: async (bytes) => {
|
|
181
|
+
// ⚠ Copied, because the caller zeroes what it handed over as soon as this resolves.
|
|
182
|
+
total += bytes.length;
|
|
183
|
+
if (total > limit) {
|
|
184
|
+
forget();
|
|
185
|
+
throw tooLarge(total);
|
|
186
|
+
}
|
|
187
|
+
held.push(new Uint8Array(bytes));
|
|
188
|
+
},
|
|
189
|
+
commit: async () => {
|
|
190
|
+
const whole = new Uint8Array(total);
|
|
191
|
+
let at = 0;
|
|
192
|
+
for (const run of held) {
|
|
193
|
+
whole.set(run, at);
|
|
194
|
+
at += run.length;
|
|
195
|
+
}
|
|
196
|
+
forget();
|
|
197
|
+
// ⚠ NOT zeroed afterwards, deliberately: `ByteDestination` promises only that the bytes have
|
|
198
|
+
// been handed over, and a destination is entitled to keep the array it was given — the
|
|
199
|
+
// tests use one that does. Wiping it here would hand a reader a buffer of zeroes.
|
|
200
|
+
return await handOver(whole, to);
|
|
201
|
+
},
|
|
202
|
+
abandon: async () => forget(),
|
|
203
|
+
};
|
|
204
|
+
}
|
package/dist/download-sink.d.ts
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { type ByteDestination } from "./stdout.ts";
|
|
2
1
|
/**
|
|
3
2
|
* Somewhere a file's plaintext is delivered in order, and made visible only once.
|
|
4
3
|
*
|
|
@@ -27,50 +26,3 @@ export interface PlaintextSink {
|
|
|
27
26
|
/** Something did not check out: leave nothing behind. Never throws. */
|
|
28
27
|
abandon(): Promise<void>;
|
|
29
28
|
}
|
|
30
|
-
/**
|
|
31
|
-
* Write to a file, through a temporary name beside it, renamed into place only when the whole
|
|
32
|
-
* file has been proved.
|
|
33
|
-
*
|
|
34
|
-
* ⛔ THE TEMPORARY FILE IS IN THE SAME DIRECTORY AS THE DESTINATION, not in a system temporary
|
|
35
|
-
* directory, and that is the whole mechanism: `rename` is atomic only within one filesystem,
|
|
36
|
-
* and only an atomic rename guarantees that a reader either sees nothing under the real name or
|
|
37
|
-
* sees the finished file. A temporary directory can be — and on the machines this runs on
|
|
38
|
-
* usually is — a different filesystem, where the same call becomes copy-then-delete and a
|
|
39
|
-
* reader can catch the copy half done.
|
|
40
|
-
*
|
|
41
|
-
* ⛔ IT IS CREATED 0600 AND THE RENAME KEEPS THAT. The file is somebody's plaintext; it must not
|
|
42
|
-
* be readable by other accounts on the machine for the seconds it takes to download, any more
|
|
43
|
-
* than it may be afterwards.
|
|
44
|
-
*/
|
|
45
|
-
export declare function fileSink(destination: string, options: {
|
|
46
|
-
force: boolean;
|
|
47
|
-
}): PlaintextSink;
|
|
48
|
-
/**
|
|
49
|
-
* How much plaintext `--out -` will hold before it hands anything over. One part's worth.
|
|
50
|
-
*
|
|
51
|
-
* ⛔ NOT AN ARBITRARY NUMBER: it is the upload path's default part size, which is the most memory
|
|
52
|
-
* this tool has ever asked for. Keeping the stdout ceiling there means the whole tool's bound
|
|
53
|
-
* is one part plus one chunk whichever direction the bytes are going.
|
|
54
|
-
*/
|
|
55
|
-
export declare const STDOUT_HOLD_LIMIT: number;
|
|
56
|
-
/**
|
|
57
|
-
* Hand the whole file to whatever is reading stdout — after it has been proved, not during.
|
|
58
|
-
*
|
|
59
|
-
* ⛔ THERE IS NO RENAME ON A PIPE, so the trick the file path uses is not available: a byte handed
|
|
60
|
-
* to a reader cannot be taken back, and a reader that has already consumed half a file cannot
|
|
61
|
-
* be told afterwards that the half was wrong. An exit code does not fix that — `nmts get x
|
|
62
|
-
* --out - > y` leaves the truncated bytes in `y` whatever the exit code says, and an agent
|
|
63
|
-
* reading a pipe usually acts on what it read. So this branch keeps the ORIGINAL guarantee
|
|
64
|
-
* rather than the original size: the file is held, checked, and only then handed over, exactly
|
|
65
|
-
* as before streaming existed.
|
|
66
|
-
*
|
|
67
|
-
* ⛔ WHICH MEANS IT HAS TO REFUSE SOMEWHERE, AND IT REFUSES OUT LOUD AND EARLY. Above the ceiling
|
|
68
|
-
* the answer is a refusal naming `--out <name>`, which streams and is checked before the file
|
|
69
|
-
* appears — not a silent trade of the guarantee for the size, and not the out-of-memory crash
|
|
70
|
-
* that this used to be. `put` already refuses this way rather than discovering a limit halfway.
|
|
71
|
-
*
|
|
72
|
-
* ⚠ Every protection this branch already had is still here and still applies to the whole file:
|
|
73
|
-
* bytes a terminal would act on are refused (`readableOnATerminal`), a reader that closed the
|
|
74
|
-
* pipe first is an ordinary end and not a failure, and any other write failure throws.
|
|
75
|
-
*/
|
|
76
|
-
export declare function stdoutSink(to: ByteDestination, limit?: number): PlaintextSink;
|