@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/stdout.js
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// Handing a file's bytes to whatever is reading this program's stdout, instead of to the disk.
|
|
2
|
+
//
|
|
3
|
+
// ⛔ THIS EXISTS SO THAT READING A FILE DOES NOT MEAN LEAVING ONE. Everything else in this tool
|
|
4
|
+
// works to keep plaintext in as few places as possible, and until this was here an agent that
|
|
5
|
+
// wanted to read one stored note had to write that note to the disk first — a copy nobody asked
|
|
6
|
+
// for, in a directory nobody chose, that outlives the read.
|
|
7
|
+
//
|
|
8
|
+
// ⛔ A WHOLE FILE OR NOTHING — AND ON THIS PATH THAT STILL MEANS HOLDING IT. The disk path no
|
|
9
|
+
// longer does: a download is written as it is decrypted, under a temporary name, and renamed
|
|
10
|
+
// into place only once the whole-file digest matches, so it is bounded by one part rather than
|
|
11
|
+
// by the file. A pipe has no rename. A byte handed to a reader cannot be taken back, and a
|
|
12
|
+
// reader that has already consumed half a file cannot be told afterwards that the half was
|
|
13
|
+
// wrong — so bytes still reach this module only after `download.ts` has proved the file whole,
|
|
14
|
+
// which is why this takes a buffer and not a stream. The ceiling that comes with that, and the
|
|
15
|
+
// refusal above it, are in `download-sink.ts` (`stdoutSink`).
|
|
16
|
+
//
|
|
17
|
+
// ⛔ A TERMINAL IS NOT A PIPE, AND THIS TELLS THEM APART. See `readableOnATerminal`.
|
|
18
|
+
import { NmtsError } from "./errors.js";
|
|
19
|
+
/** What `--out` is spelled as when the file goes to stdout. The spelling every tool shares. */
|
|
20
|
+
export const STDOUT_TARGET = "-";
|
|
21
|
+
/**
|
|
22
|
+
* Whether these bytes can go to a terminal without the terminal acting on them.
|
|
23
|
+
*
|
|
24
|
+
* ⛔ A TERMINAL EXECUTES WHAT IT IS SENT. An escape sequence stored in a file can retitle the
|
|
25
|
+
* window, blank the screen, change what the reader's next keystrokes mean, and on terminals
|
|
26
|
+
* that answer queries it can put text of its choosing into their next command line. So the
|
|
27
|
+
* question is not "will this look like rubbish" — it is "does this contain instructions", and
|
|
28
|
+
* the answer decides whether the bytes are refused or written. A pipe is a different question
|
|
29
|
+
* and gets a different answer: a program reading this is expected to handle whatever it asked
|
|
30
|
+
* for, and refusing binary there would make the whole option useless.
|
|
31
|
+
*
|
|
32
|
+
* ⚠ Tab, newline and carriage return are the three control characters that mean in a terminal
|
|
33
|
+
* exactly what they mean in a text file, so they pass.
|
|
34
|
+
*
|
|
35
|
+
* ⚠ Bytes that are not well-formed UTF-8 fail too, and not for tidiness: what a terminal does with
|
|
36
|
+
* a broken sequence is its own business, and this is somebody else's terminal.
|
|
37
|
+
*/
|
|
38
|
+
export function readableOnATerminal(bytes) {
|
|
39
|
+
for (const byte of bytes) {
|
|
40
|
+
if (byte === 0x09 || byte === 0x0a || byte === 0x0d)
|
|
41
|
+
continue;
|
|
42
|
+
if (byte < 0x20 || byte === 0x7f)
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
try {
|
|
46
|
+
// Checked after the scan above, which is cheap and turns away most binary before this
|
|
47
|
+
// allocates a string the size of the file.
|
|
48
|
+
new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
/** This program's own stdout, as a destination. */
|
|
56
|
+
export function processStdout() {
|
|
57
|
+
return {
|
|
58
|
+
isTerminal: process.stdout.isTTY === true,
|
|
59
|
+
write: (bytes) => new Promise((resolve, reject) => {
|
|
60
|
+
// ⛔ WAITED FOR, NOT FIRED AND FORGOTTEN. On a pipe Node's stdout is asynchronous: a
|
|
61
|
+
// caller that returned as soon as `write` did could not tell a full disk from a
|
|
62
|
+
// finished write, and would report success for bytes that never arrived.
|
|
63
|
+
process.stdout.write(bytes, (error) => {
|
|
64
|
+
if (error === undefined || error === null)
|
|
65
|
+
resolve();
|
|
66
|
+
else
|
|
67
|
+
reject(error);
|
|
68
|
+
});
|
|
69
|
+
}),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Hand the whole file over, refusing rather than writing bytes a terminal would act on.
|
|
74
|
+
*
|
|
75
|
+
* Answers false when the program reading it closed the pipe before the file was done — `nmts get
|
|
76
|
+
* big.bin --out - | head -c 100` is an ordinary thing to do, and the reader that stopped listening
|
|
77
|
+
* is the one that decided it had enough. Every other failure throws: a caller must not be able to
|
|
78
|
+
* mistake "the disk filled up half way" for "handed over".
|
|
79
|
+
*/
|
|
80
|
+
export async function handOver(bytes, to) {
|
|
81
|
+
if (to.isTerminal && !readableOnATerminal(bytes)) {
|
|
82
|
+
throw new NmtsError("These bytes are not text and stdout is a terminal.", {
|
|
83
|
+
exitCode: 4,
|
|
84
|
+
nextStep: "Nothing was written. A terminal acts on some of what it is sent, so this refuses rather " +
|
|
85
|
+
"than sending it. Redirect it (`--out - > file`), pipe it into something that reads " +
|
|
86
|
+
"bytes, or pass `--out <name>` to write the file instead.",
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
try {
|
|
90
|
+
await to.write(bytes);
|
|
91
|
+
}
|
|
92
|
+
catch (error) {
|
|
93
|
+
const code = error instanceof Error && "code" in error ? Reflect.get(error, "code") : null;
|
|
94
|
+
if (code === "EPIPE")
|
|
95
|
+
return false;
|
|
96
|
+
// ⛔ Named, and never reported as success. Some of the file may already have gone; what is
|
|
97
|
+
// certain is that the whole file did not, and the exit code has to say so.
|
|
98
|
+
//
|
|
99
|
+
// ⚠ The cause is safe to interpolate, and that is worth saying because `errors.ts` explains
|
|
100
|
+
// how it usually is not: a stream's failure is its own errno line (`write ENOSPC`), which
|
|
101
|
+
// never quotes the bytes it was handed.
|
|
102
|
+
throw new NmtsError(`The file could not be handed over: ${error instanceof Error ? error.message : String(error)}`, {
|
|
103
|
+
exitCode: 1,
|
|
104
|
+
nextStep: "Whatever was reading this did not get the whole file. If it was redirected, what is " +
|
|
105
|
+
"there now is part of one.",
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// The thirty days, on the half of the trash this tool owns.
|
|
2
|
+
//
|
|
3
|
+
// ⛔ THE KEY THAT OPENS A FILE EXISTS TWICE, AND EACH SIDE SWEEPS ITS OWN COPY. One copy is the
|
|
4
|
+
// item row on the server; the other is `dekWrapped` inside the sealed file list, which the
|
|
5
|
+
// server cannot read and therefore cannot edit. Erasing one and leaving the other is not a
|
|
6
|
+
// deletion — it is a deletion somebody was told about while a working key stayed behind. So the
|
|
7
|
+
// server sweeps its rows on a timer, and a signed-in client drops the matching list entries.
|
|
8
|
+
//
|
|
9
|
+
// ⛔ THIS TOOL CANNOT DO THE SERVER'S HALF, and that is a deliberate line rather than a gap: the
|
|
10
|
+
// endpoint that erases for good refuses an API key outright, because a soft delete is
|
|
11
|
+
// recoverable and an erase is not. What is left for this side is to drop list entries for
|
|
12
|
+
// things the server has ALREADY let go of — which is why the sweep asks what the server still
|
|
13
|
+
// holds before it writes anything, and holds back what it finds.
|
|
14
|
+
//
|
|
15
|
+
// ⛔ AND IT DROPS WHOLE BRANCHES OR NOTHING. Trashing a folder marks the folder alone; everything
|
|
16
|
+
// under it is trash by inheritance and shares the folder's instant. Drop the folder while one
|
|
17
|
+
// file under it is held back and that file's parent chain breaks — after which nothing carries
|
|
18
|
+
// a `deletedAt` above it any more, so it reads as LIVE, is never swept again, and `ls` prints
|
|
19
|
+
// it as a file the server has already erased the key for. That is the one state this tool is
|
|
20
|
+
// built to avoid, and it is reachable from an ordinary half-finished sweep, so the plan below
|
|
21
|
+
// keeps every ancestor of anything it keeps.
|
|
22
|
+
//
|
|
23
|
+
// PURE: no network and no clock of its own. The server's answer and the current time both arrive
|
|
24
|
+
// as arguments, so `node --test` can put this in states a real account takes a month to reach.
|
|
25
|
+
import { KIND_FILE, trashedAt } from "./drive-paths.js";
|
|
26
|
+
const DAY_MS = 86_400_000;
|
|
27
|
+
/**
|
|
28
|
+
* How long a trashed item stays restorable.
|
|
29
|
+
*
|
|
30
|
+
* ⛔ It must match the browser's `web/src/lib/drive/manifest-views.ts::TRASH_RETENTION_DAYS`, and
|
|
31
|
+
* both of those describe the same promise the server enforces on its own rows. A tool that
|
|
32
|
+
* counted to a different number would either drop an entry whose file the person could still
|
|
33
|
+
* have restored, or leave one behind for a file whose key the server had already destroyed.
|
|
34
|
+
*/
|
|
35
|
+
export const TRASH_RETENTION_DAYS = 30;
|
|
36
|
+
/**
|
|
37
|
+
* Whole days left of the thirty, counting up rather than down.
|
|
38
|
+
*
|
|
39
|
+
* Rounded UP so a row with four hours left says "1 day left" rather than "0 days left" — the
|
|
40
|
+
* number is what somebody decides whether to restore on, and rounding it to nothing reads as
|
|
41
|
+
* "already gone".
|
|
42
|
+
*/
|
|
43
|
+
export function daysLeftInTrash(trashedAtMs, nowMs) {
|
|
44
|
+
return Math.ceil((trashedAtMs + TRASH_RETENTION_DAYS * DAY_MS - nowMs) / DAY_MS);
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Entries whose thirty days have run out.
|
|
48
|
+
*
|
|
49
|
+
* Measured from the INHERITED instant, so a file inside a trashed folder is measured from the
|
|
50
|
+
* moment the folder was thrown away — which is also the moment the server stamped on its row.
|
|
51
|
+
* Reading each entry's own `deletedAt` would leave every file under a swept folder in the list.
|
|
52
|
+
*/
|
|
53
|
+
export function expiredTrashEntries(index, nowMs) {
|
|
54
|
+
const out = [];
|
|
55
|
+
for (const entry of index.all) {
|
|
56
|
+
const at = trashedAt(index, entry);
|
|
57
|
+
if (at === null)
|
|
58
|
+
continue;
|
|
59
|
+
// ⚠ SUBTRACTION, NOT A DISTANCE. An instant in the FUTURE is a clock that was corrected
|
|
60
|
+
// backwards rather than an expiry, and it falls out here because the difference goes
|
|
61
|
+
// negative. Anything that measured how far apart the two are instead would sweep it, which
|
|
62
|
+
// destroys a key thirty days early on a machine whose date is simply wrong.
|
|
63
|
+
if (nowMs - at >= TRASH_RETENTION_DAYS * DAY_MS)
|
|
64
|
+
out.push(entry);
|
|
65
|
+
}
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Split the expired entries into what may go and what may not.
|
|
70
|
+
*
|
|
71
|
+
* `stillOnServer` is every item id the server still has a row for, live or trashed. An id in that
|
|
72
|
+
* set has a key the server has not destroyed, so dropping this side's copy would hide a file that
|
|
73
|
+
* is still stored and still being paid for.
|
|
74
|
+
*
|
|
75
|
+
* ⚠ Folder entries never appear in that set — the server holds no row for a folder — so a folder
|
|
76
|
+
* is held back only by the branch rule below, which is exactly when it should be.
|
|
77
|
+
*/
|
|
78
|
+
export function planPurge(index, expired, stillOnServer) {
|
|
79
|
+
const going = new Set(expired.map((e) => e.id));
|
|
80
|
+
const waiting = [];
|
|
81
|
+
for (const entry of expired) {
|
|
82
|
+
if (!stillOnServer.has(entry.id))
|
|
83
|
+
continue;
|
|
84
|
+
going.delete(entry.id);
|
|
85
|
+
waiting.push(entry);
|
|
86
|
+
}
|
|
87
|
+
// ⛔ Everything ABOVE anything that stays has to stay as well — see the header. Walking up from
|
|
88
|
+
// every entry that is not going covers it whatever order they come in: removing an ancestor
|
|
89
|
+
// only ever adds to the set of things that stay, and the walk that removed it carries on to
|
|
90
|
+
// the root, so the entries it removes on the way are covered by the same pass.
|
|
91
|
+
for (const entry of index.all) {
|
|
92
|
+
if (going.has(entry.id))
|
|
93
|
+
continue;
|
|
94
|
+
let parentId = entry.parentId;
|
|
95
|
+
const seen = new Set([entry.id]);
|
|
96
|
+
while (parentId !== null) {
|
|
97
|
+
if (seen.has(parentId))
|
|
98
|
+
break;
|
|
99
|
+
seen.add(parentId);
|
|
100
|
+
going.delete(parentId);
|
|
101
|
+
const parent = index.byId.get(parentId);
|
|
102
|
+
if (parent === undefined)
|
|
103
|
+
break;
|
|
104
|
+
parentId = parent.parentId;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
const held = new Set(waiting.map((e) => e.id));
|
|
108
|
+
return {
|
|
109
|
+
drop: expired.filter((e) => going.has(e.id)),
|
|
110
|
+
waiting,
|
|
111
|
+
tangled: expired.filter((e) => !going.has(e.id) && !held.has(e.id)),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* How many of the entries about to be dropped hold bytes.
|
|
116
|
+
*
|
|
117
|
+
* Said out loud before the write, because a count of ENTRIES reads as harmless — most of a trashed
|
|
118
|
+
* branch is folders, which hold nothing at all — and the number that matters is how many keys are
|
|
119
|
+
* being destroyed.
|
|
120
|
+
*/
|
|
121
|
+
export function filesAmong(entries) {
|
|
122
|
+
return entries.filter((e) => e.kind === KIND_FILE).length;
|
|
123
|
+
}
|
package/dist/units.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Numbers as a person reads them.
|
|
2
|
+
//
|
|
3
|
+
// WHY A MODULE FOR ONE FUNCTION. It began inside `ls`, and the second command that had to print a
|
|
4
|
+
// size would have copied it — after which one table says "1.2 MB" and the other says "1.15 MiB"
|
|
5
|
+
// for the same file, and nobody can tell which of the two is what the account is charged for.
|
|
6
|
+
// Decimal units, because that is the unit storage is sold in.
|
|
7
|
+
/** Bytes as a person reads them: 1 kB is 1,000 bytes, the way the storage is priced. */
|
|
8
|
+
export function humanSize(bytes) {
|
|
9
|
+
if (bytes < 1000)
|
|
10
|
+
return `${bytes} B`;
|
|
11
|
+
const units = ["kB", "MB", "GB", "TB"];
|
|
12
|
+
let value = bytes / 1000;
|
|
13
|
+
let unit = 0;
|
|
14
|
+
while (value >= 1000 && unit < units.length - 1) {
|
|
15
|
+
value /= 1000;
|
|
16
|
+
unit += 1;
|
|
17
|
+
}
|
|
18
|
+
return `${value < 10 ? value.toFixed(1) : Math.round(value)} ${units[unit]}`;
|
|
19
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// Noticing that a newer `nmts` has been published, without getting in the way of the command.
|
|
2
|
+
//
|
|
3
|
+
// ⛔ WHY IT IS NOT A REQUEST PER RUN. An agent runs this tool in a loop; a network round trip
|
|
4
|
+
// added to every invocation is paid thousands of times, and paid to a host that has nothing to
|
|
5
|
+
// do with the command being run. So the answer is written down and re-read, and a fresh one is
|
|
6
|
+
// asked for at most once a day.
|
|
7
|
+
//
|
|
8
|
+
// ⛔ WHY THE NOTICE IS ONE RUN BEHIND. What this run prints comes from the file; what it looks up
|
|
9
|
+
// goes into the file for the next run. The alternative is making every command wait on a host
|
|
10
|
+
// that may be slow or unreachable before it can print its own answer, which trades the thing
|
|
11
|
+
// somebody asked for against a thing they did not.
|
|
12
|
+
//
|
|
13
|
+
// ⛔ STDERR, ALWAYS. The answer to `nmts ls --json` is on stdout and something is parsing it. A
|
|
14
|
+
// version notice is not part of any command's answer.
|
|
15
|
+
//
|
|
16
|
+
// ⛔ IT NEVER THROWS AND NEVER CHANGES AN EXIT CODE. A version check that can fail a command is a
|
|
17
|
+
// version check that eventually breaks somebody's script for a reason unrelated to what they
|
|
18
|
+
// asked for. ⚠ That is a swallowed failure, which this repository generally treats as a defect
|
|
19
|
+
// — so it is not swallowed silently: the reason is written into the file and `nmts env` prints
|
|
20
|
+
// it. Quiet where it is implicit, loud where it was asked for, and never invisible.
|
|
21
|
+
//
|
|
22
|
+
// ⛔ AND IT CAN BE TURNED OFF. This is the only thing in the tool that talks to a host other than
|
|
23
|
+
// the NMTS server and the storage network, so it says so in `nmts env` and in the help, and one
|
|
24
|
+
// environment variable stops it.
|
|
25
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
26
|
+
import { join } from "node:path";
|
|
27
|
+
import { configDir } from "./credentials.js";
|
|
28
|
+
import { isRecord } from "./guards.js";
|
|
29
|
+
import { BINARY_NAME } from "./product.js";
|
|
30
|
+
import { checkingIsOff, isNewer, isVersion, LATEST_RELEASE_URL, newerVersionLine, versionFromLocation, } from "./update-source.js";
|
|
31
|
+
// ⛔ The switch lives in `update-source.ts` and is re-exported rather than re-declared: the help
|
|
32
|
+
// text needs its name, and importing this module to get it would make `nmts --help` load the
|
|
33
|
+
// file machinery to print one line.
|
|
34
|
+
export { checkingIsOff, NO_CHECK_ENV_VAR } from "./update-source.js";
|
|
35
|
+
/** How long an answer is kept before another is asked for. */
|
|
36
|
+
export const CHECK_EVERY_MS = 24 * 60 * 60 * 1000;
|
|
37
|
+
/**
|
|
38
|
+
* How long one lookup may take.
|
|
39
|
+
*
|
|
40
|
+
* ⚠ Short on purpose. This runs after a command has already done its work, so every millisecond
|
|
41
|
+
* here is a millisecond somebody waits for a process that has nothing left to say. A host that
|
|
42
|
+
* cannot answer in this long is treated as not having answered, and asked again tomorrow.
|
|
43
|
+
*/
|
|
44
|
+
const LOOKUP_TIMEOUT_MS = 2_000;
|
|
45
|
+
export function checkPath() {
|
|
46
|
+
return join(configDir(), "update-check.json");
|
|
47
|
+
}
|
|
48
|
+
/** What is on disk, or null when there is nothing readable there. */
|
|
49
|
+
export function readCheck() {
|
|
50
|
+
let parsed;
|
|
51
|
+
try {
|
|
52
|
+
parsed = JSON.parse(readFileSync(checkPath(), "utf8"));
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
// ⛔ Unreadable counts as "never checked", which asks again. The other direction would let a
|
|
56
|
+
// corrupt file switch the check off for good.
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
if (!isRecord(parsed))
|
|
60
|
+
return null;
|
|
61
|
+
const checkedAt = parsed["checkedAt"];
|
|
62
|
+
if (typeof checkedAt !== "string" || Number.isNaN(Date.parse(checkedAt)))
|
|
63
|
+
return null;
|
|
64
|
+
const record = { checkedAt };
|
|
65
|
+
const latest = parsed["latest"];
|
|
66
|
+
// ⛔ The version is shape-checked coming OUT of the file as well as going in. This file sits in
|
|
67
|
+
// a directory anything running as you can write, and what it holds ends up in a printed line.
|
|
68
|
+
if (typeof latest === "string" && isVersion(latest))
|
|
69
|
+
record.latest = latest;
|
|
70
|
+
const failed = parsed["failed"];
|
|
71
|
+
if (typeof failed === "string" && failed.length > 0)
|
|
72
|
+
record.failed = failed.slice(0, 200);
|
|
73
|
+
return record;
|
|
74
|
+
}
|
|
75
|
+
/** Write the attempt down. Failing to write is itself ignored: there is nothing to fall back to. */
|
|
76
|
+
export function writeCheck(record) {
|
|
77
|
+
try {
|
|
78
|
+
mkdirSync(configDir(), { recursive: true, mode: 0o700 });
|
|
79
|
+
writeFileSync(checkPath(), `${JSON.stringify(record, null, 2)}\n`, { mode: 0o600 });
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
// Nothing to do about it and nothing worth failing a command over. The next run simply asks
|
|
83
|
+
// again, which is the same behaviour as never having checked.
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/** Is it time to ask again? Never having asked counts as due. */
|
|
87
|
+
export function dueForCheck(record, nowMs) {
|
|
88
|
+
if (record === null)
|
|
89
|
+
return true;
|
|
90
|
+
const last = Date.parse(record.checkedAt);
|
|
91
|
+
if (Number.isNaN(last))
|
|
92
|
+
return true;
|
|
93
|
+
// A file stamped in the future is a clock that moved, not a check from tomorrow.
|
|
94
|
+
return nowMs - last >= CHECK_EVERY_MS || last > nowMs;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Ask which release is newest.
|
|
98
|
+
*
|
|
99
|
+
* ⛔ THE REDIRECT IS THE ANSWER, so it is not followed. `releases/latest` replies with the address
|
|
100
|
+
* of the tagged page and an empty body; following it would download a page this has no use for.
|
|
101
|
+
*/
|
|
102
|
+
export async function lookupLatest(url = LATEST_RELEASE_URL) {
|
|
103
|
+
let response;
|
|
104
|
+
try {
|
|
105
|
+
response = await fetch(url, {
|
|
106
|
+
redirect: "manual",
|
|
107
|
+
signal: AbortSignal.timeout(LOOKUP_TIMEOUT_MS),
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
catch (error) {
|
|
111
|
+
return { failed: error instanceof Error ? error.message : "no answer" };
|
|
112
|
+
}
|
|
113
|
+
// The body of a redirect is not part of the answer; reading it releases the connection.
|
|
114
|
+
await response.arrayBuffer().catch(() => undefined);
|
|
115
|
+
if (response.status < 300 || response.status >= 400) {
|
|
116
|
+
return { failed: `the releases page answered ${response.status}` };
|
|
117
|
+
}
|
|
118
|
+
const location = response.headers.get("location");
|
|
119
|
+
if (location === null || location === "")
|
|
120
|
+
return { failed: "the releases page redirected to nowhere" };
|
|
121
|
+
const version = versionFromLocation(location);
|
|
122
|
+
if (version === null)
|
|
123
|
+
return { failed: "the newest release is not named in a shape this can read" };
|
|
124
|
+
return { version };
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Print the notice this run has earned, then refresh the file for the next one.
|
|
128
|
+
*
|
|
129
|
+
* The order is deliberate: whatever is already known is said first, so a slow or unreachable host
|
|
130
|
+
* cannot delay it, and the run that pays for the lookup is not the run that reads its result.
|
|
131
|
+
*/
|
|
132
|
+
export async function noteUpdate(options) {
|
|
133
|
+
const env = options.env ?? process.env;
|
|
134
|
+
if (checkingIsOff(env))
|
|
135
|
+
return;
|
|
136
|
+
const say = options.say ?? ((line) => process.stderr.write(`${line}\n`));
|
|
137
|
+
const now = options.now ?? new Date();
|
|
138
|
+
try {
|
|
139
|
+
const record = readCheck();
|
|
140
|
+
if (record?.latest !== undefined && isNewer(record.latest, options.running)) {
|
|
141
|
+
say(newerVersionLine(record.latest, options.running, BINARY_NAME));
|
|
142
|
+
}
|
|
143
|
+
if (!dueForCheck(record, now.getTime()))
|
|
144
|
+
return;
|
|
145
|
+
const lookup = options.lookup ?? (() => lookupLatest());
|
|
146
|
+
const result = await lookup();
|
|
147
|
+
const written = { checkedAt: now.toISOString() };
|
|
148
|
+
if ("version" in result)
|
|
149
|
+
written.latest = result.version;
|
|
150
|
+
else {
|
|
151
|
+
// ⛔ A FAILED ATTEMPT STILL COUNTS AS AN ATTEMPT. Without the stamp, a machine that cannot
|
|
152
|
+
// reach the host would try again on every single command — turning an unreachable host
|
|
153
|
+
// into a request storm and a delay on every run.
|
|
154
|
+
written.failed = result.failed;
|
|
155
|
+
if (record?.latest !== undefined)
|
|
156
|
+
written.latest = record.latest;
|
|
157
|
+
}
|
|
158
|
+
writeCheck(written);
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
// Nothing a version check discovers is worth failing somebody's command over. The reason is
|
|
162
|
+
// in the file above where it can be read; there is nowhere else for it to go from here.
|
|
163
|
+
}
|
|
164
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// Where a newer `nmts` comes from, and what "newer" means.
|
|
2
|
+
//
|
|
3
|
+
// ⛔ THE RELEASES OF THIS TOOL'S OWN REPOSITORY, NOT A PACKAGE REGISTRY. That is where this tool
|
|
4
|
+
// is installed from today, so it is where "newest" is decided. Asking a registry it is not
|
|
5
|
+
// published to would answer a different question and answer it confidently — the worst shape a
|
|
6
|
+
// version check can have. If it is published to one as well, this file is where the two
|
|
7
|
+
// answers would have to be reconciled, and that will be a decision rather than a default.
|
|
8
|
+
//
|
|
9
|
+
// ⛔ NOTHING HERE READS A FILE OR THE NETWORK. The one hop across the network and the record it
|
|
10
|
+
// writes are in `update-check.ts`. What is here is what an answer MEANS — the shape it has to
|
|
11
|
+
// have, how two versions compare, the exact command that installs one, and whether any of it is
|
|
12
|
+
// switched on — so all of it is testable without a server.
|
|
13
|
+
//
|
|
14
|
+
// ⛔ AND THAT IS ALSO WHY THE SWITCH IS HERE. The help text has to name it, and the help text is
|
|
15
|
+
// what `nmts --help` prints: putting the name in the module that also opens files would make
|
|
16
|
+
// every run of the cheapest command load the file machinery to print one line. `check:cli-startup`
|
|
17
|
+
// measures exactly that, and it is how this was noticed.
|
|
18
|
+
//
|
|
19
|
+
// ⛔ NOTHING HERE PARSES A VERSION LOOSELY. A string that is not three numbers is not a version,
|
|
20
|
+
// and the answer to "is that one newer" is then "this cannot tell", never "yes". The direction
|
|
21
|
+
// matters: the failure of a loose parse is a tool that nags about an upgrade that does not
|
|
22
|
+
// exist, or worse, hands an unchecked string to a command line.
|
|
23
|
+
import { SOURCE_URL } from "./product.js";
|
|
24
|
+
/** Setting this to anything stops the lookup and the notice, both. */
|
|
25
|
+
export const NO_CHECK_ENV_VAR = "NMTS_NO_UPDATE_CHECK";
|
|
26
|
+
/** Is the version check switched off here? */
|
|
27
|
+
export function checkingIsOff(env = process.env) {
|
|
28
|
+
return (env[NO_CHECK_ENV_VAR] ?? "").length > 0;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* The address that names the newest release whatever it is called.
|
|
32
|
+
*
|
|
33
|
+
* It answers with a redirect to the tagged page, and the tag carries the version. That is one
|
|
34
|
+
* request with an empty body — cheaper than the release listing, and it needs no credential.
|
|
35
|
+
*/
|
|
36
|
+
export const LATEST_RELEASE_URL = `${SOURCE_URL}/releases/latest`;
|
|
37
|
+
/** What that redirect must point at. Anything else is not an answer to the question asked. */
|
|
38
|
+
const TAG_PREFIX = `${SOURCE_URL}/releases/tag/`;
|
|
39
|
+
/**
|
|
40
|
+
* A version, exactly.
|
|
41
|
+
*
|
|
42
|
+
* ⛔ THREE NUMBERS AND NOTHING ELSE. Releases of this tool are tagged `v1.2.3`; a tag with a
|
|
43
|
+
* suffix is not a release of it, and treating one as a version would put whatever it contains
|
|
44
|
+
* into a URL and then onto a command line.
|
|
45
|
+
*/
|
|
46
|
+
const VERSION_SHAPE = /^(\d+)\.(\d+)\.(\d+)$/;
|
|
47
|
+
/** The published file that carries a whole release, under a name that does not change. */
|
|
48
|
+
const PACKAGE_ASSET = "nmts.tgz";
|
|
49
|
+
/** Is this string a version this file is willing to reason about? */
|
|
50
|
+
export function isVersion(value) {
|
|
51
|
+
return VERSION_SHAPE.test(value);
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* The version a `releases/latest` redirect landed on, or null.
|
|
55
|
+
*
|
|
56
|
+
* ⛔ THE ADDRESS IS CHECKED, NOT JUST READ. It arrives from the network, and a redirect that
|
|
57
|
+
* pointed somewhere else entirely would otherwise decide what this tool calls the newest
|
|
58
|
+
* release. It has to be the tag page of this repository, over an encrypted connection, and the
|
|
59
|
+
* tag itself has to be a version.
|
|
60
|
+
*/
|
|
61
|
+
export function versionFromLocation(location) {
|
|
62
|
+
let resolved;
|
|
63
|
+
try {
|
|
64
|
+
resolved = new URL(location, LATEST_RELEASE_URL).toString();
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
if (!resolved.startsWith(TAG_PREFIX))
|
|
70
|
+
return null;
|
|
71
|
+
const tag = decodeURIComponent(resolved.slice(TAG_PREFIX.length));
|
|
72
|
+
const version = tag.startsWith("v") ? tag.slice(1) : tag;
|
|
73
|
+
return isVersion(version) ? version : null;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* -1, 0 or 1 — or null when either side is not a version this can compare.
|
|
77
|
+
*
|
|
78
|
+
* ⚠ Null is not "equal". A caller that treats it as equal says nothing; one that treats it as
|
|
79
|
+
* "newer" nags forever about a release that does not exist. Both callers here read it as
|
|
80
|
+
* "no answer".
|
|
81
|
+
*/
|
|
82
|
+
export function compareVersions(left, right) {
|
|
83
|
+
const a = VERSION_SHAPE.exec(left);
|
|
84
|
+
const b = VERSION_SHAPE.exec(right);
|
|
85
|
+
if (a === null || b === null)
|
|
86
|
+
return null;
|
|
87
|
+
for (let part = 1; part <= 3; part += 1) {
|
|
88
|
+
const one = Number(a[part]);
|
|
89
|
+
const other = Number(b[part]);
|
|
90
|
+
if (one !== other)
|
|
91
|
+
return one > other ? 1 : -1;
|
|
92
|
+
}
|
|
93
|
+
return 0;
|
|
94
|
+
}
|
|
95
|
+
/** Is `candidate` a release later than the one running? Unanswerable counts as no. */
|
|
96
|
+
export function isNewer(candidate, running) {
|
|
97
|
+
return compareVersions(candidate, running) === 1;
|
|
98
|
+
}
|
|
99
|
+
/** The page a person can read about one release on. */
|
|
100
|
+
export function releasePageUrl(version) {
|
|
101
|
+
return isVersion(version) ? `${TAG_PREFIX}v${version}` : null;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Where one release's package is.
|
|
105
|
+
*
|
|
106
|
+
* ⛔ THE TAGGED ADDRESS, NEVER "LATEST". This is handed to another program to install, and
|
|
107
|
+
* "latest" would install whatever is newest at the moment that program runs — which is not
|
|
108
|
+
* necessarily the version this tool just named on the screen. Naming the release means what
|
|
109
|
+
* was reported and what gets installed are the same thing.
|
|
110
|
+
*/
|
|
111
|
+
export function packageUrl(version) {
|
|
112
|
+
if (!isVersion(version))
|
|
113
|
+
return null;
|
|
114
|
+
return `${SOURCE_URL}/releases/download/v${version}/${PACKAGE_ASSET}`;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* The exact command that installs one release, as an argument list.
|
|
118
|
+
*
|
|
119
|
+
* ⛔ A LIST AND NOT A STRING, because it is both printed and run. A string would have to be split
|
|
120
|
+
* again to be run, and splitting a string is where a shell gets involved — the one thing a
|
|
121
|
+
* command built partly from a value off the network must not touch. `packageUrl` has already
|
|
122
|
+
* refused anything that is not three numbers, so nothing here can carry a space, a quote or a
|
|
123
|
+
* semicolon, and it never needs to.
|
|
124
|
+
*/
|
|
125
|
+
export function installCommand(version) {
|
|
126
|
+
const url = packageUrl(version);
|
|
127
|
+
return url === null ? null : ["npm", "install", "--global", url];
|
|
128
|
+
}
|
|
129
|
+
/** The same command as one line, for printing. */
|
|
130
|
+
export function installCommandLine(version) {
|
|
131
|
+
const parts = installCommand(version);
|
|
132
|
+
return parts === null ? null : parts.join(" ");
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* The one line printed when a newer release exists.
|
|
136
|
+
*
|
|
137
|
+
* ⛔ IT SAYS WHAT IS PUBLISHED AND WHAT IS RUNNING, AND NOTHING ABOUT WHETHER TO UPGRADE. What is
|
|
138
|
+
* in a release is not known here, and a line that called it important, recommended or a fix
|
|
139
|
+
* would be inventing a reason to act.
|
|
140
|
+
*/
|
|
141
|
+
export function newerVersionLine(latest, running, binary) {
|
|
142
|
+
return `${binary} ${latest} is published; this is ${running}. \`${binary} update\` installs it.`;
|
|
143
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// The four server calls the credit-paid upload makes, narrowed to their shapes.
|
|
2
|
+
//
|
|
3
|
+
// ⛔ EACH ANSWER IS CHECKED, NOT CAST. These four replies decide whether money was spent and
|
|
4
|
+
// whether a file exists; believing a field that is not there would turn a server mistake into a
|
|
5
|
+
// silent one. What cannot be read is said so, in the caller's terms.
|
|
6
|
+
import { request } from "./api.js";
|
|
7
|
+
import { NmtsError } from "./errors.js";
|
|
8
|
+
function object(value, what) {
|
|
9
|
+
if (typeof value !== "object" || value === null) {
|
|
10
|
+
throw new NmtsError(`The server's answer about ${what} was not an object.`);
|
|
11
|
+
}
|
|
12
|
+
return value;
|
|
13
|
+
}
|
|
14
|
+
function reserveReply(value) {
|
|
15
|
+
const v = object(value, "the reservation");
|
|
16
|
+
const ledgerId = v["ledger_id"];
|
|
17
|
+
const state = v["state"];
|
|
18
|
+
if (typeof ledgerId !== "number" || typeof state !== "string") {
|
|
19
|
+
throw new NmtsError("The server answered a reservation this version cannot read.", {
|
|
20
|
+
nextStep: "Nothing was uploaded. Check whether an upload was charged before trying again.",
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
const out = {
|
|
24
|
+
ledger_id: ledgerId,
|
|
25
|
+
state,
|
|
26
|
+
credits_spent: typeof v["credits_spent"] === "number" ? v["credits_spent"] : 0,
|
|
27
|
+
};
|
|
28
|
+
if (typeof v["blob_object_id"] === "string")
|
|
29
|
+
out.blob_object_id = v["blob_object_id"];
|
|
30
|
+
if (typeof v["register_tx_digest"] === "string")
|
|
31
|
+
out.register_tx_digest = v["register_tx_digest"];
|
|
32
|
+
return out;
|
|
33
|
+
}
|
|
34
|
+
function statusReply(value) {
|
|
35
|
+
const v = object(value, "the reservation");
|
|
36
|
+
const ledgerId = v["ledger_id"];
|
|
37
|
+
const state = v["state"];
|
|
38
|
+
if (typeof ledgerId !== "number" || typeof state !== "string") {
|
|
39
|
+
throw new NmtsError("The server answered a reservation this version cannot read.");
|
|
40
|
+
}
|
|
41
|
+
const out = { ledger_id: ledgerId, state };
|
|
42
|
+
if (typeof v["blob_object_id"] === "string")
|
|
43
|
+
out.blob_object_id = v["blob_object_id"];
|
|
44
|
+
if (typeof v["register_tx_digest"] === "string")
|
|
45
|
+
out.register_tx_digest = v["register_tx_digest"];
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
function itemReply(value) {
|
|
49
|
+
const v = object(value, "the committed file");
|
|
50
|
+
const id = v["id"];
|
|
51
|
+
if (typeof id !== "string" || id === "") {
|
|
52
|
+
throw new NmtsError("The file was committed but the server did not say under which id.", {
|
|
53
|
+
nextStep: "The bytes are stored and paid for. Running the same command again asks the server again " +
|
|
54
|
+
"and does not spend anything more.",
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
return { id };
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* How long the certify and commit calls get.
|
|
61
|
+
*
|
|
62
|
+
* Longer than an ordinary request because both reach the chain, and because a timeout on either
|
|
63
|
+
* leaves a paid upload unfinished — a slow answer is much better than a lost one.
|
|
64
|
+
*/
|
|
65
|
+
const CHAIN_TIMEOUT_MS = 120_000;
|
|
66
|
+
/** Bind the four calls to one server and one credential. */
|
|
67
|
+
export function createUploadApi(base, apiKey) {
|
|
68
|
+
return {
|
|
69
|
+
async reserve(body) {
|
|
70
|
+
return reserveReply(await request(base, "/v1/sponsored/reserve", {
|
|
71
|
+
method: "POST",
|
|
72
|
+
token: apiKey,
|
|
73
|
+
body,
|
|
74
|
+
timeoutMs: CHAIN_TIMEOUT_MS,
|
|
75
|
+
}));
|
|
76
|
+
},
|
|
77
|
+
async status(ledgerId) {
|
|
78
|
+
return statusReply(await request(base, `/v1/sponsored/${ledgerId}`, { token: apiKey }));
|
|
79
|
+
},
|
|
80
|
+
async uploaded(ledgerId, certificate) {
|
|
81
|
+
return request(base, `/v1/sponsored/${ledgerId}/uploaded`, {
|
|
82
|
+
method: "POST",
|
|
83
|
+
token: apiKey,
|
|
84
|
+
body: { certificate },
|
|
85
|
+
timeoutMs: CHAIN_TIMEOUT_MS,
|
|
86
|
+
});
|
|
87
|
+
},
|
|
88
|
+
async createItem(body, idempotencyKey) {
|
|
89
|
+
return itemReply(await request(base, "/v1/items", {
|
|
90
|
+
method: "POST",
|
|
91
|
+
token: apiKey,
|
|
92
|
+
body,
|
|
93
|
+
idempotencyKey,
|
|
94
|
+
timeoutMs: CHAIN_TIMEOUT_MS,
|
|
95
|
+
}));
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
}
|