@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
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
// `nmts create` — bringing a NEW account into existence and handing its code to a person.
|
|
2
|
+
//
|
|
3
|
+
// ⛔ THE CODE IS MADE HERE AND IS THE ONLY COPY THAT WILL EVER EXIST. The server is told an
|
|
4
|
+
// account id and a one-way secret derived from the code; it stores a verifier of the second
|
|
5
|
+
// and never the code itself. Nothing on the far end can send it back, reset it, or recognise a
|
|
6
|
+
// replacement: if it is lost the account and every file in it are gone, for the holder and for
|
|
7
|
+
// NMTS alike. That is why the code is the LAST thing this prints, with nothing after it but
|
|
8
|
+
// the one command that would store it, and why nothing else in the output competes with it.
|
|
9
|
+
//
|
|
10
|
+
// ⛔ IT STORES NOTHING AND SWITCHES NOTHING OVER. `nmts login` is what puts a code on this
|
|
11
|
+
// machine, and it is a separate act on purpose: this command runs with the credentials of the
|
|
12
|
+
// account that is DOING the creating, and writing the new code into `credentials.json` would
|
|
13
|
+
// silently replace them — the next `nmts ls` would report an empty account and nothing would
|
|
14
|
+
// say why. So the code is printed (or written where you point it), the command that stores it
|
|
15
|
+
// is named, and this stops.
|
|
16
|
+
//
|
|
17
|
+
// ⛔ WITH `--json` THE CODE DOES NOT GO INTO THE OUTPUT. Machine-readable output is read by a
|
|
18
|
+
// program, which means it lands in a pipe, a file, a CI log or an agent's transcript — the
|
|
19
|
+
// exact places `credentials.ts` spends its whole header keeping the account code out of. So
|
|
20
|
+
// `--json` is refused unless `--out <file>` names somewhere for the code to go, and the JSON
|
|
21
|
+
// then carries the PATH and not the value. That is the same shape this tool already recommends
|
|
22
|
+
// for handing a secret to a container (`NMTS_ACCOUNT_CODE_FILE` — a variable holding a
|
|
23
|
+
// filename, never the value), and the same reasoning `stdout.ts` uses about handing bytes to
|
|
24
|
+
// something that is not a person: what is safe to show a human at a terminal and what is safe
|
|
25
|
+
// to hand a program are different questions. `--out -` is refused for the same reason.
|
|
26
|
+
//
|
|
27
|
+
// ⛔ AND THE FILE IS WRITTEN BEFORE THE ACCOUNT IS ASKED FOR. A full disk, a bad path or a name
|
|
28
|
+
// already taken must fail while there is still nothing to lose; discovering it AFTER the
|
|
29
|
+
// server has created the account would mean an account exists whose only key we are about to
|
|
30
|
+
// drop. If the creation then fails, the file is removed again — no account, no code, no trace.
|
|
31
|
+
//
|
|
32
|
+
// ⛔ THE FIRST DOOR OF `POST /v1/accounts` IS NOT AVAILABLE TO THIS TOOL AND NEVER WILL BE. The
|
|
33
|
+
// server takes either a solved human check with no credential, or an API key whose account had
|
|
34
|
+
// a PERSON pass that check within four of its weeks. A machine cannot solve the first, so this
|
|
35
|
+
// command works only for a caller that already has an account and a verified key — and it says
|
|
36
|
+
// that plainly, before it makes anything, rather than failing with a message about scopes.
|
|
37
|
+
import { mkdirSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
38
|
+
import { isAbsolute, resolve } from "node:path";
|
|
39
|
+
import { request, ServerError } from "../api.js";
|
|
40
|
+
import { readCredentialsFile } from "../credentials.js";
|
|
41
|
+
import { NmtsError } from "../errors.js";
|
|
42
|
+
import { isRecord } from "../guards.js";
|
|
43
|
+
import { askAPersonToVerify, humanCheck } from "../human-check.js";
|
|
44
|
+
import { resolveNetwork } from "../network.js";
|
|
45
|
+
import { BINARY_NAME, HOME_URL } from "../product.js";
|
|
46
|
+
import { newAccountCode, registrationProofOf } from "../registration.js";
|
|
47
|
+
import { resolveServer } from "../server.js";
|
|
48
|
+
import { requireApiKey } from "../session.js";
|
|
49
|
+
import { STDOUT_TARGET } from "../stdout.js";
|
|
50
|
+
export async function create(options = {}) {
|
|
51
|
+
const say = options.write ?? ((line) => process.stdout.write(`${line}\n`));
|
|
52
|
+
const apiKey = requireApiKey();
|
|
53
|
+
const stored = readCredentialsFile();
|
|
54
|
+
const server = resolveServer(options.server ?? stored?.server);
|
|
55
|
+
const network = resolveNetwork(server, options.network ?? stored?.network);
|
|
56
|
+
// ⛔ EVERY REFUSAL THAT DOES NOT NEED THE NETWORK HAPPENS FIRST, so a run that was never going
|
|
57
|
+
// to work does not spend one of the creator's two accounts for the day finding that out.
|
|
58
|
+
const codeFile = codeFileTarget(options.out);
|
|
59
|
+
if (options.json === true && codeFile === null)
|
|
60
|
+
throw jsonNeedsAFile();
|
|
61
|
+
const check = await humanCheck(server, apiKey);
|
|
62
|
+
if (!check.live)
|
|
63
|
+
throw askAPersonToVerify("No account can be created");
|
|
64
|
+
const inForce = await termsInForce(server, apiKey);
|
|
65
|
+
const accepted = inForce === null ? null : acceptanceOffered(inForce, options);
|
|
66
|
+
const code = await newAccountCode();
|
|
67
|
+
const proof = await registrationProofOf(code);
|
|
68
|
+
// The file, then the account — see the header. Nothing below this line may fail without either
|
|
69
|
+
// removing the file or leaving an account whose code is in it.
|
|
70
|
+
if (codeFile !== null)
|
|
71
|
+
writeCodeFile(codeFile, code);
|
|
72
|
+
let answer;
|
|
73
|
+
try {
|
|
74
|
+
answer = await request(server, "/v1/accounts", {
|
|
75
|
+
method: "POST",
|
|
76
|
+
token: apiKey,
|
|
77
|
+
body: {
|
|
78
|
+
account_id: proof.accountId,
|
|
79
|
+
auth_secret: proof.authSecret,
|
|
80
|
+
// ⚠ Absent when no documents are in force. The server ignores them in that case, and
|
|
81
|
+
// sending a pair it is not asking for would be recording an acceptance of nothing.
|
|
82
|
+
...(accepted === null ? {} : { terms_version: accepted.terms, privacy_version: accepted.privacy }),
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
// ⛔ "THE REQUEST FAILED" AND "THE ACCOUNT WAS NOT CREATED" ARE DIFFERENT FACTS. A refusal the
|
|
88
|
+
// server ANSWERED is it deciding: nothing exists, and a code file for it is a file somebody
|
|
89
|
+
// keeps forever for nothing. A dropped connection, a timeout, or an answer nothing could
|
|
90
|
+
// parse leaves the question open — the account may be there — and deleting the file then
|
|
91
|
+
// would destroy the only key to it, which is the one irreversible mistake on this path.
|
|
92
|
+
if (codeFile !== null && error instanceof ServerError)
|
|
93
|
+
rmSync(codeFile, { force: true });
|
|
94
|
+
throw codeFile !== null && !(error instanceof ServerError)
|
|
95
|
+
? uncertain(codeFile, error)
|
|
96
|
+
: explain(error, inForce);
|
|
97
|
+
}
|
|
98
|
+
const createdAt = accountField(answer, "created_at");
|
|
99
|
+
if (options.json === true && codeFile !== null) {
|
|
100
|
+
// ⛔ NO `account_code` FIELD, AND THERE NEVER WILL BE. See the header.
|
|
101
|
+
say(JSON.stringify({
|
|
102
|
+
account_id: proof.accountId,
|
|
103
|
+
created_at: createdAt,
|
|
104
|
+
code_file: codeFile,
|
|
105
|
+
server,
|
|
106
|
+
network,
|
|
107
|
+
}));
|
|
108
|
+
return 0;
|
|
109
|
+
}
|
|
110
|
+
sayCreated(say, proof.accountId, server, network);
|
|
111
|
+
if (codeFile === null)
|
|
112
|
+
sayTheCode(say, code);
|
|
113
|
+
else
|
|
114
|
+
sayWhereTheCodeWent(say, codeFile);
|
|
115
|
+
return 0;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Where the code goes, or `null` for the screen.
|
|
119
|
+
*
|
|
120
|
+
* ⛔ `-` IS REFUSED, WHICH IS THE OPPOSITE OF WHAT IT MEANS EVERYWHERE ELSE IN THIS TOOL. In
|
|
121
|
+
* `get` and `listfile` it means "hand the bytes to whatever is reading stdout", and that is
|
|
122
|
+
* right for a file somebody already has. Here it would mean putting the only copy of an
|
|
123
|
+
* account code into the same stream a program is parsing — which is the one place this command
|
|
124
|
+
* exists to keep it out of.
|
|
125
|
+
*/
|
|
126
|
+
function codeFileTarget(out) {
|
|
127
|
+
if (out === undefined || out === "")
|
|
128
|
+
return null;
|
|
129
|
+
if (out === STDOUT_TARGET) {
|
|
130
|
+
throw new NmtsError("The new account code will not be sent to stdout.", {
|
|
131
|
+
exitCode: 2,
|
|
132
|
+
nextStep: `Nothing was created. stdout is what a program reads and a log keeps, and this is the ` +
|
|
133
|
+
`only copy of the code. Name a file — \`--out ./account-code.txt\` — or leave --out off ` +
|
|
134
|
+
`and read it off the screen.`,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
const path = isAbsolute(out) ? out : resolve(process.cwd(), out);
|
|
138
|
+
let existing = null;
|
|
139
|
+
try {
|
|
140
|
+
existing = statSync(path);
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
// Not there is exactly what this wants.
|
|
144
|
+
}
|
|
145
|
+
if (existing !== null) {
|
|
146
|
+
// ⛔ NO `--force` HERE, DELIBERATELY. Everywhere else in this tool --force replaces a file
|
|
147
|
+
// that can be fetched again. The file this would replace may be the only copy of ANOTHER
|
|
148
|
+
// account's code, and overwriting it destroys that account with no way back.
|
|
149
|
+
throw new NmtsError(`${path} is already there.`, {
|
|
150
|
+
exitCode: 4,
|
|
151
|
+
nextStep: existing.isDirectory()
|
|
152
|
+
? `--out names the FILE the code goes into, not a directory.`
|
|
153
|
+
: `Nothing was created. That file is not replaced, whatever --force says: it may hold ` +
|
|
154
|
+
`the only copy of another account's code. Name one that does not exist.`,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
return path;
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Write the code where the caller pointed, readable by nobody else.
|
|
161
|
+
*
|
|
162
|
+
* ⚠ `wx` FAILS IF THE NAME APPEARED SINCE THE CHECK ABOVE, which is the point of using it rather
|
|
163
|
+
* than trusting that check: between the two, something else may have written there.
|
|
164
|
+
*
|
|
165
|
+
* ⚠ ON WINDOWS THE MODE IS IGNORED and the file inherits the folder's permissions — the same
|
|
166
|
+
* limit `credentials.ts` documents, and claiming otherwise would be claiming a guarantee the
|
|
167
|
+
* platform does not give.
|
|
168
|
+
*/
|
|
169
|
+
function writeCodeFile(path, code) {
|
|
170
|
+
const dir = resolve(path, "..");
|
|
171
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
172
|
+
try {
|
|
173
|
+
writeFileSync(path, `${code}\n`, { mode: 0o600, flag: "wx" });
|
|
174
|
+
}
|
|
175
|
+
catch (error) {
|
|
176
|
+
// ⛔ THE CAUSE IS NAMED BUT THE CODE IS NOT. `writeFileSync`'s errno line carries the path and
|
|
177
|
+
// never the contents, so it is safe to pass on; the code itself appears in no message here.
|
|
178
|
+
throw new NmtsError(`The account code could not be written to ${path}.`, {
|
|
179
|
+
exitCode: 1,
|
|
180
|
+
nextStep: `Nothing was created — the file is written before the account is asked for, so that a ` +
|
|
181
|
+
`failure here costs nothing. Cause: ${error instanceof Error ? error.message : String(error)}`,
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
/** What documents this server is enforcing, read from the one route a key may ask. */
|
|
186
|
+
async function termsInForce(server, apiKey) {
|
|
187
|
+
const answer = await request(server, "/v1/account/summary", { token: apiKey });
|
|
188
|
+
const terms = isRecord(answer) ? answer["terms"] : null;
|
|
189
|
+
if (!isRecord(terms))
|
|
190
|
+
return null;
|
|
191
|
+
const t = terms["required_terms_version"];
|
|
192
|
+
const p = terms["required_privacy_version"];
|
|
193
|
+
// ⚠ BOTH OR NEITHER. The server holds one pair or none (`RequiredTerms`), so a half-answer is a
|
|
194
|
+
// version of this API this tool does not understand — and guessing which half to send would
|
|
195
|
+
// be recording an acceptance of a document nobody named.
|
|
196
|
+
if (typeof t !== "string" || typeof p !== "string" || t === "" || p === "")
|
|
197
|
+
return null;
|
|
198
|
+
return { terms: t, privacy: p };
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* The acceptance a PERSON offered on the command line, checked against what is in force.
|
|
202
|
+
*
|
|
203
|
+
* ⛔ THIS TOOL NEVER FILLS THESE IN. It has just read the two versions from the server and could
|
|
204
|
+
* put them in the request without asking anybody — and that is precisely the thing it must not
|
|
205
|
+
* do. The server's own rule for a credential held by a machine is that a machine cannot
|
|
206
|
+
* consent: a key that could accept would produce exactly the unrecorded acceptance the record
|
|
207
|
+
* exists to prevent. What a command line CAN carry is a person's act, named by them, after
|
|
208
|
+
* they have read the documents — so the two versions have to be typed, and typing the version
|
|
209
|
+
* rather than a yes is what makes the acceptance name a document instead of a prompt.
|
|
210
|
+
*
|
|
211
|
+
* ⚠ AND NO MECHANISM HERE CAN TELL A PERSON FROM A PROGRAM. Nothing in a command-line tool can.
|
|
212
|
+
* That is why the refusal says out loud who is meant to type it, exactly as this tool's own
|
|
213
|
+
* agreements do — a rule, not a protection, and saying otherwise would be claiming one that is
|
|
214
|
+
* not there.
|
|
215
|
+
*/
|
|
216
|
+
function acceptanceOffered(inForce, options) {
|
|
217
|
+
const terms = options.acceptTerms?.trim() ?? "";
|
|
218
|
+
const privacy = options.acceptPrivacy?.trim() ?? "";
|
|
219
|
+
if (terms === inForce.terms && privacy === inForce.privacy)
|
|
220
|
+
return inForce;
|
|
221
|
+
throw termsRefusal(inForce, terms !== "" || privacy !== "");
|
|
222
|
+
}
|
|
223
|
+
function termsRefusal(inForce, named) {
|
|
224
|
+
return new NmtsError(named
|
|
225
|
+
? "The documents named on the command line are not the ones in force."
|
|
226
|
+
: "A new account is recorded as accepting the documents in force, and nobody has.", {
|
|
227
|
+
// ⛔ 5 — "waiting on the person's agreement". Not 1: nothing went wrong, and not 2: the
|
|
228
|
+
// command line is not malformed. What is missing is a decision only a person can take.
|
|
229
|
+
exitCode: 5,
|
|
230
|
+
nextStep: [
|
|
231
|
+
`Nothing was created and no account code was made.`,
|
|
232
|
+
``,
|
|
233
|
+
`In force now:`,
|
|
234
|
+
` Terms of Service ${inForce.terms}`,
|
|
235
|
+
` Privacy Policy ${inForce.privacy}`,
|
|
236
|
+
``,
|
|
237
|
+
// ⚠ THE DOCUMENTS ARE ON THE PRODUCT SITE, not on whatever --server names. A development
|
|
238
|
+
// server enforces versions and publishes no pages; sending somebody to read them there
|
|
239
|
+
// would send them nowhere.
|
|
240
|
+
`Read them at ${HOME_URL}/terms and ${HOME_URL}/privacy .`,
|
|
241
|
+
``,
|
|
242
|
+
`The server records the new account as having accepted that pair, and will not make one`,
|
|
243
|
+
`without it. This tool will not fill the two versions in for you: a machine cannot`,
|
|
244
|
+
`consent, and a credential that could accept would produce exactly the unrecorded`,
|
|
245
|
+
`acceptance the record exists to prevent.`,
|
|
246
|
+
``,
|
|
247
|
+
`A person who has read both can say so, naming what they read:`,
|
|
248
|
+
``,
|
|
249
|
+
` ${BINARY_NAME} create --accept-terms ${inForce.terms} --accept-privacy ${inForce.privacy}`,
|
|
250
|
+
``,
|
|
251
|
+
`⛔ If a program is reading this on somebody's behalf: show it to them and let them`,
|
|
252
|
+
` decide. Do not run that command yourself.`,
|
|
253
|
+
].join("\n"),
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* Turn the two refusals this command can hear into something a reader can act on.
|
|
258
|
+
*
|
|
259
|
+
* ⚠ EVERYTHING ELSE IS LEFT ALONE. `api.ts` already advises on a revoked key, a scope a key was
|
|
260
|
+
* not given, and an account that has not accepted the terms; repeating any of that here would
|
|
261
|
+
* be a second wording for one problem.
|
|
262
|
+
*/
|
|
263
|
+
function explain(error, inForce) {
|
|
264
|
+
if (!(error instanceof ServerError))
|
|
265
|
+
return error;
|
|
266
|
+
if (error.code === "TERMS_VERSION_MISMATCH" && inForce !== null) {
|
|
267
|
+
// The server moved between the read above and the write. Say that, rather than repeating the
|
|
268
|
+
// versions this run read — they are the stale ones.
|
|
269
|
+
return new NmtsError("The documents in force changed while this ran.", {
|
|
270
|
+
exitCode: 5,
|
|
271
|
+
nextStep: `Nothing was created. Run \`${BINARY_NAME} create\` again with no --accept flags: it will ` +
|
|
272
|
+
`print the pair that is in force now, for a person to read and name.`,
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
if (error.code === "RATE_LIMITED") {
|
|
276
|
+
return new NmtsError(error.message, {
|
|
277
|
+
exitCode: 4,
|
|
278
|
+
nextStep: `An account that creates accounts may make two a day and five a week, counted by the ` +
|
|
279
|
+
`server across restarts. Nothing was created and nothing was spent. Waiting is the only ` +
|
|
280
|
+
`thing that lifts it — a second key on the same account shares the same allowance.`,
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
return error;
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* The server never answered, so nobody here knows whether the account exists.
|
|
287
|
+
*
|
|
288
|
+
* ⛔ THE FILE IS KEPT AND THE DOUBT IS SAID OUT LOUD. Reporting this as a plain failure would
|
|
289
|
+
* leave somebody with a code file they think is rubbish, for an account that may hold the only
|
|
290
|
+
* thing they will ever be able to open it with.
|
|
291
|
+
*/
|
|
292
|
+
function uncertain(path, cause) {
|
|
293
|
+
return new NmtsError(`The server did not answer, so whether the account was created is not known here.`, {
|
|
294
|
+
exitCode: 1,
|
|
295
|
+
nextStep: [
|
|
296
|
+
`${path} was KEPT, and it holds the only code that account would have.`,
|
|
297
|
+
``,
|
|
298
|
+
`Keep it until you know. Nothing on this machine can tell the two cases apart, and the`,
|
|
299
|
+
`cost of being wrong is one small file against an account nobody could ever open.`,
|
|
300
|
+
`Running this again makes a DIFFERENT account; it does not retry this one.`,
|
|
301
|
+
``,
|
|
302
|
+
`Cause: ${cause instanceof Error ? cause.message : String(cause)}`,
|
|
303
|
+
].join("\n"),
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
function jsonNeedsAFile() {
|
|
307
|
+
return new NmtsError("--json needs --out, because the account code will not go into the output.", {
|
|
308
|
+
exitCode: 2,
|
|
309
|
+
nextStep: `Nothing was created. Machine-readable output is read by a program and kept by a log, and ` +
|
|
310
|
+
`the code is the only key this account will ever have. \`--out ./account-code.txt\` writes ` +
|
|
311
|
+
`it to a file only you can read; the JSON then names that file. Without --json the code is ` +
|
|
312
|
+
`printed on the screen for a person to keep.`,
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
/** One field of the answer's `account` object, or null. The server's shape, not ours. */
|
|
316
|
+
function accountField(answer, field) {
|
|
317
|
+
const account = isRecord(answer) ? answer["account"] : null;
|
|
318
|
+
if (!isRecord(account))
|
|
319
|
+
return null;
|
|
320
|
+
const value = account[field];
|
|
321
|
+
return typeof value === "string" && value.length > 0 ? value : null;
|
|
322
|
+
}
|
|
323
|
+
function sayCreated(say, accountId, server, network) {
|
|
324
|
+
say(`A new account exists on ${server} (${network}).`);
|
|
325
|
+
say(``);
|
|
326
|
+
say(` account id ${accountId}`);
|
|
327
|
+
say(``);
|
|
328
|
+
say(`Nothing on this machine changed. The account code you were signed in as is still the one`);
|
|
329
|
+
say(`this tool uses; this new account is not stored, and not switched to.`);
|
|
330
|
+
say(``);
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* The code, last, with the one thing that has to be understood before it.
|
|
334
|
+
*
|
|
335
|
+
* ⛔ NOTHING FOLLOWS IT BUT THE COMMAND THAT STORES IT. A screen of next steps under an account
|
|
336
|
+
* code is how a person scrolls past the only copy of it.
|
|
337
|
+
*/
|
|
338
|
+
function sayTheCode(say, code) {
|
|
339
|
+
say(`⛔ THIS IS THE ONLY COPY OF THE ACCOUNT CODE THAT WILL EVER EXIST.`);
|
|
340
|
+
say(` NMTS keeps a verifier and never the code. It cannot be reset, resent or replaced.`);
|
|
341
|
+
say(` Lose it and the account and every file in it are gone — for you and for NMTS.`);
|
|
342
|
+
say(``);
|
|
343
|
+
say(` ${code}`);
|
|
344
|
+
say(``);
|
|
345
|
+
say(`To use this machine as that account: \`${BINARY_NAME} logout\`, then \`${BINARY_NAME} login\`.`);
|
|
346
|
+
}
|
|
347
|
+
function sayWhereTheCodeWent(say, path) {
|
|
348
|
+
say(`⛔ THE ONLY COPY OF THE ACCOUNT CODE IS NOW IN ONE FILE, AND NOWHERE ELSE.`);
|
|
349
|
+
say(` NMTS keeps a verifier and never the code. It cannot be reset, resent or replaced.`);
|
|
350
|
+
say(` Lose that file and the account and every file in it are gone.`);
|
|
351
|
+
say(``);
|
|
352
|
+
say(` ${path}`);
|
|
353
|
+
say(``);
|
|
354
|
+
say(`It was written readable by you alone. To use this machine as that account:`);
|
|
355
|
+
say(`\`${BINARY_NAME} logout\`, then \`${BINARY_NAME} login\`.`);
|
|
356
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// `nmts env` — what this machine is, and what that means for the account code.
|
|
2
|
+
//
|
|
3
|
+
// ⛔ IT ASKS THE SERVER NOTHING AND NEEDS NO CREDENTIAL. It is the first command an agent can run
|
|
4
|
+
// on a machine it has never seen, and a command that needed to be signed in to say "there is
|
|
5
|
+
// nowhere safe to keep the code here" would say it too late.
|
|
6
|
+
import { adviseFor, readEnvironment } from "../environment.js";
|
|
7
|
+
import { PASSPHRASE_ENV_VAR, resolveAccountCode, resolveApiKey, } from "../credentials.js";
|
|
8
|
+
import { BINARY_NAME, SUPPORT_EMAIL, VERSION } from "../product.js";
|
|
9
|
+
import { checkingIsOff, NO_CHECK_ENV_VAR, readCheck } from "../update-check.js";
|
|
10
|
+
import { isNewer } from "../update-source.js";
|
|
11
|
+
/**
|
|
12
|
+
* Where a credential came from, in words rather than in the field name.
|
|
13
|
+
*
|
|
14
|
+
* ⛔ `--json` keeps the field name. A machine wants a value it can compare; a person reading the
|
|
15
|
+
* table wants to know whether the thing on disk is the code or a sealed copy of it, and
|
|
16
|
+
* "file-locked" does not say that to somebody who has never read this source.
|
|
17
|
+
*/
|
|
18
|
+
function sourceWords(source) {
|
|
19
|
+
switch (source) {
|
|
20
|
+
case "env":
|
|
21
|
+
return "environment variable — readable by anything running as you";
|
|
22
|
+
case "secret-file":
|
|
23
|
+
return "a file named by an environment variable";
|
|
24
|
+
case "file":
|
|
25
|
+
return "this tool's own file, unsealed";
|
|
26
|
+
case "file-locked":
|
|
27
|
+
return "this tool's own file, sealed with a passphrase";
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* What the version check last did, in one line.
|
|
32
|
+
*
|
|
33
|
+
* ⛔ IT IS IN HERE BECAUSE THE CHECK IS QUIET. It is the only request this tool makes that no
|
|
34
|
+
* command asked for, and it never fails a command — so if it has been failing for a month,
|
|
35
|
+
* this line is the only place that says so. A quiet mechanism with nowhere to report is
|
|
36
|
+
* indistinguishable from one that is not running.
|
|
37
|
+
*/
|
|
38
|
+
function updateCheckWords(running) {
|
|
39
|
+
if (checkingIsOff())
|
|
40
|
+
return `off — ${NO_CHECK_ENV_VAR} is set`;
|
|
41
|
+
const record = readCheck();
|
|
42
|
+
if (record === null)
|
|
43
|
+
return `has not run yet`;
|
|
44
|
+
const when = record.checkedAt.slice(0, 16).replace("T", " ");
|
|
45
|
+
if (record.failed !== undefined)
|
|
46
|
+
return `⛔ last try ${when} UTC did not answer — ${record.failed}`;
|
|
47
|
+
if (record.latest === undefined)
|
|
48
|
+
return `last ran ${when} UTC and found nothing to compare`;
|
|
49
|
+
return isNewer(record.latest, running)
|
|
50
|
+
? `${record.latest} is published (this is ${running}) — \`${BINARY_NAME} update\` installs it`
|
|
51
|
+
: `${record.latest} is newest, looked up ${when} UTC`;
|
|
52
|
+
}
|
|
53
|
+
export function env(options = {}) {
|
|
54
|
+
const say = options.write ?? ((line) => process.stdout.write(`${line}\n`));
|
|
55
|
+
const environment = readEnvironment();
|
|
56
|
+
// ⛔ WHETHER, NOT WHAT. Nothing about a credential's VALUE is read here, printed here, or
|
|
57
|
+
// returned by --json. What is reported is that one was found and where it came from.
|
|
58
|
+
let code = null;
|
|
59
|
+
let key = null;
|
|
60
|
+
// ⛔ REFUSED IS NOT ABSENT, AND THIS COMMAND IS THE ONE PLACE THAT MUST SAY WHICH. Both of these
|
|
61
|
+
// used to swallow the failure and report "not found" — so a credentials file gone
|
|
62
|
+
// world-readable, which is a leak nothing else in the system ever mentions, was invisible in
|
|
63
|
+
// the very command written to be run first on a machine nobody knows. The MESSAGE is kept
|
|
64
|
+
// (it names a path, never a value); the credential is not.
|
|
65
|
+
let codeProblem = null;
|
|
66
|
+
let keyProblem = null;
|
|
67
|
+
try {
|
|
68
|
+
code = resolveAccountCode();
|
|
69
|
+
}
|
|
70
|
+
catch (error) {
|
|
71
|
+
codeProblem = error instanceof Error ? error.message : String(error);
|
|
72
|
+
}
|
|
73
|
+
try {
|
|
74
|
+
key = resolveApiKey();
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
keyProblem = error instanceof Error ? error.message : String(error);
|
|
78
|
+
}
|
|
79
|
+
const advice = adviseFor(environment, code !== null);
|
|
80
|
+
if (options.json) {
|
|
81
|
+
say(JSON.stringify({
|
|
82
|
+
...environment,
|
|
83
|
+
accountCode: code === null
|
|
84
|
+
? codeProblem === null
|
|
85
|
+
? null
|
|
86
|
+
: { present: false, refused: codeProblem }
|
|
87
|
+
: { present: true, source: code.source },
|
|
88
|
+
apiKey: key === null
|
|
89
|
+
? keyProblem === null
|
|
90
|
+
? null
|
|
91
|
+
: { present: false, refused: keyProblem }
|
|
92
|
+
: { present: true, source: key.source },
|
|
93
|
+
advice,
|
|
94
|
+
updateCheck: checkingIsOff() ? { off: true } : (readCheck() ?? { ran: false }),
|
|
95
|
+
}));
|
|
96
|
+
return 0;
|
|
97
|
+
}
|
|
98
|
+
say(` system ${environment.os} ${environment.osRelease} · node ${environment.node}`);
|
|
99
|
+
const mapping = environment.rootMapped === null
|
|
100
|
+
? ""
|
|
101
|
+
: environment.rootMapped
|
|
102
|
+
? " · rootless (root here is an ordinary user on the host)"
|
|
103
|
+
: " · root here is root on the host";
|
|
104
|
+
say(` containment ${environment.containment}${mapping}`);
|
|
105
|
+
say(` running as uid ${environment.uid ?? "n/a"}`);
|
|
106
|
+
say(` private files ${environment.privateStorage ? "yes" : "NO — this filesystem does not keep a file mode"}`);
|
|
107
|
+
say(` config ${environment.configDir}`);
|
|
108
|
+
say(` terminal ${environment.interactive ? "yes" : "no — nothing here can be typed"}`);
|
|
109
|
+
say(` browser ${environment.browserReachable ? "reachable" : "not reachable"}`);
|
|
110
|
+
const found = (got, problem) => (got !== null ? `found — ${sourceWords(got.source)}` : problem !== null ? `⛔ REFUSED — ${problem}` : "not found");
|
|
111
|
+
say(` account code ${found(code, codeProblem)}`);
|
|
112
|
+
if (code?.source === "file-locked") {
|
|
113
|
+
// ⛔ THE ANSWER AN AGENT NEEDS BEFORE IT RUNS ANYTHING ELSE. A sealed code with no way to
|
|
114
|
+
// supply the passphrase is not a usable credential, and finding that out on the first
|
|
115
|
+
// upload — after the file has been read and sealed — is finding it out too late.
|
|
116
|
+
const havePassphrase = (process.env[PASSPHRASE_ENV_VAR] ?? "").length > 0;
|
|
117
|
+
const how = havePassphrase
|
|
118
|
+
? `${PASSPHRASE_ENV_VAR} is set`
|
|
119
|
+
: environment.interactive
|
|
120
|
+
? `it will be asked for on the terminal`
|
|
121
|
+
: `⛔ NOT AVAILABLE — no ${PASSPHRASE_ENV_VAR} and no terminal`;
|
|
122
|
+
say(` passphrase ${how}`);
|
|
123
|
+
}
|
|
124
|
+
say(` api key ${found(key, keyProblem)}`);
|
|
125
|
+
say(` version check ${updateCheckWords(VERSION)}`);
|
|
126
|
+
if (advice.length > 0) {
|
|
127
|
+
say(``);
|
|
128
|
+
for (const item of advice) {
|
|
129
|
+
say(` ${item.level === "warn" ? "⛔" : "·"} ${item.text}`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
say(``);
|
|
133
|
+
say(` \`${BINARY_NAME} env --json\` is the same thing in one line.`);
|
|
134
|
+
say(` Something here wrong or missing? ${SUPPORT_EMAIL}`);
|
|
135
|
+
return 0;
|
|
136
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// `nmts expiring` — which files are about to run out of the storage they were paid for.
|
|
2
|
+
//
|
|
3
|
+
// ⛔ IT IS ITS OWN COMMAND AND NOT A COLUMN IN `ls`. `ls` answers from the sealed file list alone
|
|
4
|
+
// and says so; this answers from three places at once — the chain for the epoch clock, the
|
|
5
|
+
// server for which files end when, and the list for what those files are called. Folding it in
|
|
6
|
+
// would make the cheapest and most-run command in this tool depend on a chain read that can be
|
|
7
|
+
// slow, blocked, or down, and the honest behaviour when that read fails is to STOP. A listing
|
|
8
|
+
// that stops because a storage network is unreachable is a worse listing.
|
|
9
|
+
//
|
|
10
|
+
// ⛔ NOTHING HERE IS DRAWN AS SAFE BECAUSE IT COULD NOT BE READ. An unreadable clock refuses; a
|
|
11
|
+
// file whose storage period was never recorded is reported as unrecorded and kept out of the
|
|
12
|
+
// ranking; a file the server names and the list does not is printed as an id rather than
|
|
13
|
+
// dropped. Silence is the failure this whole surface exists to prevent — the account screen and
|
|
14
|
+
// this command are the only two places an expiry warning ever appears, because NMTS holds no
|
|
15
|
+
// address to send one to.
|
|
16
|
+
//
|
|
17
|
+
// ⛔ AND IT BUYS NOTHING. What this does is make the deadline visible in time for somebody to act
|
|
18
|
+
// on it. Acting on it is `nmts extend`, which spends from the person's own wallet and asks for
|
|
19
|
+
// its own agreement first — this command reads, and no amount of it costs anything.
|
|
20
|
+
import { request } from "../api.js";
|
|
21
|
+
import { buildIndex, fullPathOf } from "../drive-paths.js";
|
|
22
|
+
import { NmtsError } from "../errors.js";
|
|
23
|
+
import { daysLeftInWords, daysLeftUntilEpoch, stageOf, warningCutoffEpoch, } from "../expiry.js";
|
|
24
|
+
import { readFileList } from "../manifest.js";
|
|
25
|
+
import { BINARY_NAME, HOME_URL } from "../product.js";
|
|
26
|
+
import { openSession } from "../session.js";
|
|
27
|
+
import { humanSize } from "../units.js";
|
|
28
|
+
export async function expiring(options = {}) {
|
|
29
|
+
const say = options.write ?? ((line) => process.stdout.write(`${line}\n`));
|
|
30
|
+
const now = options.now ?? Date.now();
|
|
31
|
+
const session = await openSession(options);
|
|
32
|
+
const readClock = options.readClock ??
|
|
33
|
+
(async () => (await import("../walrus-write.js")).readEpochWindow(session.network));
|
|
34
|
+
const clock = await readClock();
|
|
35
|
+
if (clock === null) {
|
|
36
|
+
// ⛔ Not "nothing is expiring". The two look identical from outside and mean opposite things.
|
|
37
|
+
throw new NmtsError(`The ${session.network} storage network's epoch clock could not be read.`, {
|
|
38
|
+
exitCode: 1,
|
|
39
|
+
nextStep: `Nothing was changed and nothing here can be answered without it: which epoch the network ` +
|
|
40
|
+
`is in is a fact only the chain has, and this tool will not count days from a constant. ` +
|
|
41
|
+
`Try again, or name a different Sui node in NMTS_SUI_RPC.`,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
const cutoff = warningCutoffEpoch(clock);
|
|
45
|
+
const answer = asExpiringAnswer(await request(session.server, `/v1/items/expiring?before_epoch=${cutoff}`, { token: session.apiKey }));
|
|
46
|
+
const list = await readFileList(session.server, session.apiKey, session.code, session.accountId);
|
|
47
|
+
const index = buildIndex(list.manifest?.entries ?? []);
|
|
48
|
+
const rows = answer.rows.map((row) => {
|
|
49
|
+
const entry = index.byId.get(row.id);
|
|
50
|
+
const stage = stageOf(clock, row.expiryEpoch, now);
|
|
51
|
+
const left = stage === "unrecorded" ? null : daysLeftUntilEpoch(clock, row.expiryEpoch, now);
|
|
52
|
+
return {
|
|
53
|
+
id: row.id,
|
|
54
|
+
path: entry === undefined ? null : fullPathOf(index, entry),
|
|
55
|
+
size: entry?.size ?? null,
|
|
56
|
+
expiryEpoch: row.expiryEpoch,
|
|
57
|
+
stage,
|
|
58
|
+
daysLeft: left === null ? null : left.days,
|
|
59
|
+
daysLeftExact: left?.exact ?? true,
|
|
60
|
+
};
|
|
61
|
+
});
|
|
62
|
+
// ⛔ SORTED BY DEADLINE, WITH THE UNKNOWNS AT THE BOTTOM. The server orders by the recorded epoch
|
|
63
|
+
// and an unrecorded one is 0, so taking that order would head a list called "running out" with
|
|
64
|
+
// the files nobody knows anything about.
|
|
65
|
+
const dated = rows.filter((r) => r.stage !== "unrecorded").sort((a, b) => a.expiryEpoch - b.expiryEpoch);
|
|
66
|
+
const unrecorded = rows.filter((r) => r.stage === "unrecorded");
|
|
67
|
+
if (options.json) {
|
|
68
|
+
say(JSON.stringify({
|
|
69
|
+
network: session.network,
|
|
70
|
+
epoch: clock.current,
|
|
71
|
+
epochDurationMs: clock.durationMs,
|
|
72
|
+
/** ⚠ Absent from the network's own answer most of the time — see `expiry.ts`. */
|
|
73
|
+
epochStartedMs: clock.startedMs,
|
|
74
|
+
beforeEpoch: cutoff,
|
|
75
|
+
truncated: answer.truncated,
|
|
76
|
+
files: [...dated, ...unrecorded],
|
|
77
|
+
}));
|
|
78
|
+
return 0;
|
|
79
|
+
}
|
|
80
|
+
const epochDays = clock.durationMs / 86_400_000;
|
|
81
|
+
say(`The ${session.network} storage network is at epoch ${clock.current}, and one epoch is ` +
|
|
82
|
+
`${epochDays === Math.round(epochDays) ? epochDays : epochDays.toFixed(1)} days.`);
|
|
83
|
+
if (dated.length === 0 && unrecorded.length === 0) {
|
|
84
|
+
say(`Nothing in this account runs out before epoch ${cutoff}.`);
|
|
85
|
+
return 0;
|
|
86
|
+
}
|
|
87
|
+
const width = Math.max(...[...dated, ...unrecorded].map((r) => nameOf(r).length));
|
|
88
|
+
if (dated.length > 0) {
|
|
89
|
+
say(``);
|
|
90
|
+
for (const row of dated) {
|
|
91
|
+
const left = row.daysLeft === null ? "" : daysLeftInWords({ days: row.daysLeft, exact: row.daysLeftExact });
|
|
92
|
+
const size = row.size === null ? "" : humanSize(row.size);
|
|
93
|
+
say(`${nameOf(row).padEnd(width)} ${size.padStart(9)} ends at epoch ${row.expiryEpoch}, ${left}` +
|
|
94
|
+
MARK[row.stage]);
|
|
95
|
+
}
|
|
96
|
+
say(``);
|
|
97
|
+
say(`${dated.length} file${dated.length === 1 ? "" : "s"} run out before epoch ${cutoff}.`);
|
|
98
|
+
}
|
|
99
|
+
if (unrecorded.length > 0) {
|
|
100
|
+
say(``);
|
|
101
|
+
for (const row of unrecorded) {
|
|
102
|
+
say(`${nameOf(row).padEnd(width)} ${(row.size === null ? "" : humanSize(row.size)).padStart(9)}`);
|
|
103
|
+
}
|
|
104
|
+
say(``);
|
|
105
|
+
say(`${unrecorded.length} file${unrecorded.length === 1 ? " has" : "s have"} no storage period ` +
|
|
106
|
+
`recorded. That is not a short one: whatever uploaded them could not read the network's ` +
|
|
107
|
+
`epoch clock, so nothing was written down. The chain still knows; a browser can show it.`);
|
|
108
|
+
}
|
|
109
|
+
if (rows.some((r) => r.path === null)) {
|
|
110
|
+
say(``);
|
|
111
|
+
say(` Rows marked "(not in the file list)" are files the server is holding that this account's`);
|
|
112
|
+
say(` list does not name. An upload can commit and then fail before the list was written.`);
|
|
113
|
+
say(` Nothing here can repair that; opening the account in a browser can.`);
|
|
114
|
+
}
|
|
115
|
+
if (answer.truncated) {
|
|
116
|
+
say(``);
|
|
117
|
+
say(` The server stopped at its limit, so there are more than these. Extending some of them`);
|
|
118
|
+
say(` and running this again shows the next ones.`);
|
|
119
|
+
}
|
|
120
|
+
say(``);
|
|
121
|
+
say(`Extending storage is paid for from your own wallet and signed on this machine: ` +
|
|
122
|
+
`\`${BINARY_NAME} extend <path>\`, or the browser at ${HOME_URL}. It spends WAL rather than ` +
|
|
123
|
+
`credits, and it asks once before it signs.`);
|
|
124
|
+
return 0;
|
|
125
|
+
}
|
|
126
|
+
/** What a row is called, with the reason spelled out when it has no name. */
|
|
127
|
+
function nameOf(row) {
|
|
128
|
+
return row.path ?? `(not in the file list) ${row.id}`;
|
|
129
|
+
}
|
|
130
|
+
/** The two stages that need a word beside them. `soon` is the warning itself and needs none. */
|
|
131
|
+
const MARK = {
|
|
132
|
+
lapsed: " — TERM ENDED",
|
|
133
|
+
urgent: " — EXTEND NOW",
|
|
134
|
+
soon: "",
|
|
135
|
+
later: "",
|
|
136
|
+
unrecorded: "",
|
|
137
|
+
};
|
|
138
|
+
/**
|
|
139
|
+
* What `GET /v1/items/expiring` answers, narrowed rather than trusted.
|
|
140
|
+
*
|
|
141
|
+
* ⛔ A ROW THIS CANNOT READ IS A REFUSAL, NOT A ROW TO SKIP. Skipping would turn a wire change into
|
|
142
|
+
* a shorter warning list, which is the one way this command can fail without anybody noticing.
|
|
143
|
+
*/
|
|
144
|
+
function asExpiringAnswer(value) {
|
|
145
|
+
const unreadable = () => {
|
|
146
|
+
throw new NmtsError("The server answered with an expiry list this version cannot read.", {
|
|
147
|
+
nextStep: "Update this tool. Nothing was changed, and the account is not in any different state.",
|
|
148
|
+
});
|
|
149
|
+
};
|
|
150
|
+
if (typeof value !== "object" || value === null)
|
|
151
|
+
return unreadable();
|
|
152
|
+
const raw = Reflect.get(value, "items");
|
|
153
|
+
if (!Array.isArray(raw))
|
|
154
|
+
return unreadable();
|
|
155
|
+
const items = raw;
|
|
156
|
+
const rows = [];
|
|
157
|
+
for (const item of items) {
|
|
158
|
+
if (typeof item !== "object" || item === null)
|
|
159
|
+
return unreadable();
|
|
160
|
+
const id = Reflect.get(item, "item_id");
|
|
161
|
+
const epoch = Reflect.get(item, "expiry_epoch");
|
|
162
|
+
if (typeof id !== "string" || typeof epoch !== "number")
|
|
163
|
+
return unreadable();
|
|
164
|
+
rows.push({ id, expiryEpoch: epoch });
|
|
165
|
+
}
|
|
166
|
+
return { rows, truncated: Reflect.get(value, "truncated") === true };
|
|
167
|
+
}
|