@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/api.js
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
// Talking to the NMTS server.
|
|
2
|
+
//
|
|
3
|
+
// ⛔ THIS DOES NOT RETRY THE HUMAN CHECK. The browser's client answers a CLEARANCE_REQUIRED by
|
|
4
|
+
// fetching a fresh captcha token and trying once more. In Node there is no captcha to fetch, so
|
|
5
|
+
// that path posts an empty token, fails again, and the whole thing surfaces as "the API rejects
|
|
6
|
+
// everything" with no mention of the real cause. Here a clearance refusal is returned as itself,
|
|
7
|
+
// named, with the reason a command-line tool cannot satisfy it.
|
|
8
|
+
//
|
|
9
|
+
// ⛔ EVERY REQUEST HAS A DEADLINE. An agent runs this in a loop with no person watching; a request
|
|
10
|
+
// that hangs forever is worse than one that fails, because nothing ever reports it.
|
|
11
|
+
//
|
|
12
|
+
// ⛔ THE TOKEN IS NEVER IN A MESSAGE, A URL OR A LOG. It goes in one header and nowhere else.
|
|
13
|
+
import { NmtsError } from "./errors.js";
|
|
14
|
+
import { isTransient, keepTrying } from "./net-retry.js";
|
|
15
|
+
/** Default deadline for a request that is not moving file bytes. */
|
|
16
|
+
export const DEFAULT_TIMEOUT_MS = 30_000;
|
|
17
|
+
/** A refusal the server explained. Carries its code so a caller can branch without string matching. */
|
|
18
|
+
export class ServerError extends NmtsError {
|
|
19
|
+
status;
|
|
20
|
+
code;
|
|
21
|
+
constructor(status, refusal, nextStep) {
|
|
22
|
+
super(refusal.message, { exitCode: 1, nextStep });
|
|
23
|
+
this.name = "ServerError";
|
|
24
|
+
this.status = status;
|
|
25
|
+
this.code = refusal.code;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/** What a caller does next about a refusal, when the tool knows something the message does not. */
|
|
29
|
+
function adviseFor(code) {
|
|
30
|
+
switch (code) {
|
|
31
|
+
case "CLEARANCE_REQUIRED":
|
|
32
|
+
case "TURNSTILE_FAILED":
|
|
33
|
+
return ("This account needs a human check, which a command-line tool cannot pass. An API key " +
|
|
34
|
+
"made on the account screen is what waives it — put it in NMTS_API_KEY. If that screen " +
|
|
35
|
+
"has no place to make one, this server does not have API keys switched on.");
|
|
36
|
+
case "UNAUTHORIZED":
|
|
37
|
+
return "The credential is missing or expired. Check NMTS_API_KEY, or make a new key.";
|
|
38
|
+
// ⛔ Each of these says something different on purpose, because the remedies are different
|
|
39
|
+
// and a program that cannot tell them apart will retry the one thing that cannot work.
|
|
40
|
+
case "SESSION_REVOKED":
|
|
41
|
+
return "That is a browser session somebody ended from another device, not an API key. A key is what a program should carry; NMTS_API_KEY is where it goes.";
|
|
42
|
+
case "API_KEY_REVOKED":
|
|
43
|
+
return "Somebody revoked this key. It will not start working again — make a new one.";
|
|
44
|
+
case "API_KEY_EXPIRED":
|
|
45
|
+
return "This key reached the end of the lifetime it was given. Make a new one.";
|
|
46
|
+
case "API_KEY_SCOPE":
|
|
47
|
+
return ("The key is valid and was not given permission for this. Nothing here will succeed with " +
|
|
48
|
+
"it — a key with the right permissions has to be made on the account screen.");
|
|
49
|
+
case "API_KEY_MALFORMED":
|
|
50
|
+
return ("What was sent is not a well-formed key. Check that the whole string was copied, with " +
|
|
51
|
+
"no quotes or line break — it is one line of exactly 65 characters.");
|
|
52
|
+
case "ACCOUNT_CODE_NOT_A_CREDENTIAL":
|
|
53
|
+
return ("That was an account code, not an API key. The code never goes to the server; it stays " +
|
|
54
|
+
"on this machine and opens the files. Put the code in NMTS_ACCOUNT_CODE and the key in " +
|
|
55
|
+
"NMTS_API_KEY.");
|
|
56
|
+
case "AGENT_VERIFY_REQUIRED":
|
|
57
|
+
return ("This was refused because nothing has checked lately that a person is behind this " +
|
|
58
|
+
"account's key. Ask the person to run `nmts verify` and to follow what it prints — it " +
|
|
59
|
+
"gives them a code to type at a browser, and nothing here can pass that check for them.");
|
|
60
|
+
// ⛔ THE REFUSAL IS CORRECT AND THERE IS NOTHING HERE TO WORK AROUND. Accepting terms is a
|
|
61
|
+
// person reading a document and agreeing to it; a program doing it for them would be
|
|
62
|
+
// signing on somebody else's behalf, and this tool holds an API key, not a person. So the
|
|
63
|
+
// only thing missing was the advice — without it an agent gets a bare 403 and starts
|
|
64
|
+
// trying credentials, which is the one thing that cannot be the cause.
|
|
65
|
+
//
|
|
66
|
+
// ⚠ It does not say WHICH requests are refused. The server gates some and not others (reading
|
|
67
|
+
// and deleting are not gated today), that line has moved twice, and a sentence here naming
|
|
68
|
+
// the list would be a copy of it that nothing keeps true.
|
|
69
|
+
case "TERMS_ACCEPTANCE_REQUIRED":
|
|
70
|
+
return ("This account has not accepted the terms now in force, and the server refuses this " +
|
|
71
|
+
"request until it does. Nothing on this machine can accept them. Ask the person to open " +
|
|
72
|
+
"the account screen at nmts.me and accept there. Other requests may still work in the " +
|
|
73
|
+
"meantime.");
|
|
74
|
+
// ⛔ A KEY IS NOT ENOUGH HERE AND NEVER WILL BE. These routes rebuild what makes the account
|
|
75
|
+
// recoverable without NMTS, and the owner's rule is that the code is re-entered for them.
|
|
76
|
+
// An agent that reads this as "my key is wrong" starts making new keys, which is the one
|
|
77
|
+
// remedy that cannot work.
|
|
78
|
+
case "ACCOUNT_PROOF_REQUIRED":
|
|
79
|
+
return ("This request needs proof of the account code as well as the key, and what was sent was " +
|
|
80
|
+
"missing or did not match. Check that the code this machine is holding belongs to the " +
|
|
81
|
+
"same account as the key. Wrong attempts are counted, and three of them lock these " +
|
|
82
|
+
"routes for a while.");
|
|
83
|
+
case "ACCOUNT_BANNED":
|
|
84
|
+
return "This account is suspended. Nothing here will succeed until that is lifted.";
|
|
85
|
+
// ── Getting to the starting line ──────────────────────────────────────────────────────────
|
|
86
|
+
case "ACCOUNT_EXISTS":
|
|
87
|
+
return "An account already exists for that. Use the one you have rather than making another.";
|
|
88
|
+
case "ALPHA_NOT_OPEN":
|
|
89
|
+
return ("This build asks the server for a channel it does not open. This is not something to " +
|
|
90
|
+
"retry or to fix with a different credential — use a release build.");
|
|
91
|
+
case "API_KEY_CAP":
|
|
92
|
+
return ("The account holds as many live keys as it is allowed. Nothing here can raise the limit: " +
|
|
93
|
+
"the person has to revoke a key they no longer use, on the account screen at nmts.me.");
|
|
94
|
+
case "API_KEY_CHANNEL":
|
|
95
|
+
return ("This account is enrolled on a preview build, and keys are not issued while it is. Ask " +
|
|
96
|
+
"the person to leave the preview on the account screen, then make the key.");
|
|
97
|
+
case "INVALID_CREDENTIALS":
|
|
98
|
+
return ("The server did not accept what was sent. Check the key rather than the account code — " +
|
|
99
|
+
"the code never goes to the server and cannot be the cause.");
|
|
100
|
+
case "LOCKED_OUT":
|
|
101
|
+
return ("Too many failed attempts, so this is shut for a while. Retrying now makes it longer, " +
|
|
102
|
+
"not shorter. The refusal carries the moment it lifts; wait for it.");
|
|
103
|
+
case "RATE_LIMITED":
|
|
104
|
+
return ("Too many requests too quickly. Wait and send fewer — the refusal carries how long. This " +
|
|
105
|
+
"is not a credential problem, so changing keys will not help.");
|
|
106
|
+
case "SURFACE_MISMATCH":
|
|
107
|
+
return ("This account acts through a different build than the one calling. The refusal names " +
|
|
108
|
+
"which; nothing on this machine can change it, and the person switches it at nmts.me.");
|
|
109
|
+
// ── The terms ─────────────────────────────────────────────────────────────────────────────
|
|
110
|
+
case "TERMS_VERSION_MISMATCH":
|
|
111
|
+
return ("The versions sent are not the ones in force; the refusal carries the ones that are. " +
|
|
112
|
+
"This is a stale copy, not a refusal to serve — read the current versions and send those.");
|
|
113
|
+
case "TERMS_NOT_IN_FORCE":
|
|
114
|
+
return ("There is nothing to accept, so accepting cannot be what is missing. This is a server " +
|
|
115
|
+
"condition; report it rather than retrying.");
|
|
116
|
+
// ── Credits and the free trial ────────────────────────────────────────────────────────────
|
|
117
|
+
case "CREDIT_FILE_CAP":
|
|
118
|
+
return ("One file may cost at most the published cap in credits, and this one costs more. The " +
|
|
119
|
+
"refusal carries both numbers. Splitting the file is the way through; more credits is not.");
|
|
120
|
+
case "CREDIT_DAILY_CAP":
|
|
121
|
+
return ("The account has spent its allowance for today. The refusal carries the cap and what is " +
|
|
122
|
+
"spent. Waiting for the day to turn is the only remedy — buying credits does not lift it.");
|
|
123
|
+
case "TRIAL_CLOSED":
|
|
124
|
+
return "The free trial is not open at all right now. Credits have to come from a funded wallet.";
|
|
125
|
+
case "TRIAL_FULL":
|
|
126
|
+
return "This week's free-trial places are taken. Applying again this week cannot succeed; next week can.";
|
|
127
|
+
case "TRIAL_ALREADY":
|
|
128
|
+
return "This account already took the free trial this week. It comes round weekly, not once.";
|
|
129
|
+
case "TRIAL_HELD":
|
|
130
|
+
return "Free-trial applications are paused pending review. Retrying does not move it.";
|
|
131
|
+
case "TRIAL_LINE_CAPPED":
|
|
132
|
+
return ("This internet connection has taken its share of this week's places today — the limit is " +
|
|
133
|
+
"on the connection, not on the account, so another account here hits it too.");
|
|
134
|
+
// ── Storage, the chain, and what is safe to retry ─────────────────────────────────────────
|
|
135
|
+
// ⛔ THE THREE OUTCOMES ARE DIFFERENT AND AN AGENT MUST NOT COLLAPSE THEM. Refused means it did
|
|
136
|
+
// not happen. Failed means it did not finish. Uncertain means nobody knows — and that is the
|
|
137
|
+
// one where retrying blindly can spend money twice.
|
|
138
|
+
case "CHAIN_REQUEST_REFUSED":
|
|
139
|
+
return ("The storage service refused the request itself, so nothing was spent and nothing was " +
|
|
140
|
+
"stored. Retrying the same request will be refused the same way.");
|
|
141
|
+
case "CHAIN_REGISTER_FAILED":
|
|
142
|
+
return "Registering the storage did not go through. Nothing is stored; the upload can be tried again.";
|
|
143
|
+
case "CHAIN_CERTIFY_FAILED":
|
|
144
|
+
return ("The bytes went out but the storage was never certified, so the file is not safely stored. " +
|
|
145
|
+
"Try the upload again.");
|
|
146
|
+
case "CHAIN_UNCERTAIN":
|
|
147
|
+
return ("⛔ Nobody knows whether the storage was registered. Do NOT simply retry: doing so can pay " +
|
|
148
|
+
"twice for the same file. Run `nmts ls` first and see whether the file is there.");
|
|
149
|
+
case "CHAIN_SPEND_CAP":
|
|
150
|
+
return ("The service has stopped spending on storage for today. This is not about this account " +
|
|
151
|
+
"and no credential or credit changes it. Try tomorrow.");
|
|
152
|
+
case "CHAIN_DELETE_FAILED":
|
|
153
|
+
return ("The storage could not be released. The file's record is gone from this side either way, " +
|
|
154
|
+
"so nothing here is stuck — the storage runs out on its own when its time is up.");
|
|
155
|
+
case "RELEASE_NOT_SPONSORED":
|
|
156
|
+
return ("This file's storage was not paid for with credits, so it is not the server's to release. " +
|
|
157
|
+
"Storage bought from a wallet is released by that wallet.");
|
|
158
|
+
case "SPONSORED_STATE":
|
|
159
|
+
return ("The upload is not at the step that call belongs to — the steps have an order and one was " +
|
|
160
|
+
"skipped or already done. Start the upload again rather than repeating this call.");
|
|
161
|
+
// ── Two callers, one drive ────────────────────────────────────────────────────────────────
|
|
162
|
+
case "VERSION_CONFLICT":
|
|
163
|
+
return ("Something else changed the drive since this was read. Nothing is lost and nothing is " +
|
|
164
|
+
"wrong with the credential: read the current state and apply the change to that.");
|
|
165
|
+
case "ERASE_BLOCKED":
|
|
166
|
+
return ("The account cannot be erased while retained records still point at it. This will not " +
|
|
167
|
+
"clear by retrying; the records have their own retention and it has to run out.");
|
|
168
|
+
case "CREDITS_SHORT":
|
|
169
|
+
return "The account does not have enough credits for this upload.";
|
|
170
|
+
default:
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
function isRefusal(value) {
|
|
175
|
+
if (typeof value !== "object" || value === null || !("error" in value))
|
|
176
|
+
return false;
|
|
177
|
+
const error = Reflect.get(value, "error");
|
|
178
|
+
if (typeof error !== "object" || error === null)
|
|
179
|
+
return false;
|
|
180
|
+
return typeof Reflect.get(error, "code") === "string" && typeof Reflect.get(error, "message") === "string";
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* One request to the NMTS server, returning parsed JSON or throwing a named refusal.
|
|
184
|
+
*
|
|
185
|
+
* `path` starts with `/v1/`. It is joined to the base without any normalising, so a caller cannot
|
|
186
|
+
* accidentally send a request to a different host by passing an absolute URL.
|
|
187
|
+
*/
|
|
188
|
+
export async function request(base, path, options = {}) {
|
|
189
|
+
if (!path.startsWith("/"))
|
|
190
|
+
throw new NmtsError(`A request path must start with "/": ${path}`);
|
|
191
|
+
// ⛔ REPEATED ONLY WHERE REPEATING IS THE SAME REQUEST. A read always is. A write is only when it
|
|
192
|
+
// carries an idempotency key, because a request that reached the server and died on the way
|
|
193
|
+
// back looks exactly like one that never arrived -- and guessing wrong there spends money
|
|
194
|
+
// twice. Everything else fails once and says so, exactly as it did before.
|
|
195
|
+
const safeToRepeat = options.method === undefined ||
|
|
196
|
+
options.method === "GET" ||
|
|
197
|
+
options.idempotencyKey !== undefined;
|
|
198
|
+
if (!safeToRepeat)
|
|
199
|
+
return await once(base, path, options);
|
|
200
|
+
return await keepTrying(() => once(base, path, options), {
|
|
201
|
+
retryable: (error) => isTransient(error, error instanceof ServerError ? error.status : undefined),
|
|
202
|
+
...(options.onWait === undefined ? {} : { onWait: options.onWait }),
|
|
203
|
+
...(options.signal === undefined ? {} : { signal: options.signal }),
|
|
204
|
+
...(options.retryBudgetMs === undefined ? {} : { budgetMs: options.retryBudgetMs }),
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
/** One attempt. `request` above decides whether there may be another. */
|
|
208
|
+
async function once(base, path, options) {
|
|
209
|
+
const { method = "GET", body, token, timeoutMs = DEFAULT_TIMEOUT_MS } = options;
|
|
210
|
+
const controller = new AbortController();
|
|
211
|
+
const deadline = setTimeout(() => controller.abort(), timeoutMs);
|
|
212
|
+
if (options.signal)
|
|
213
|
+
options.signal.addEventListener("abort", () => controller.abort(), { once: true });
|
|
214
|
+
const headers = { accept: "application/json" };
|
|
215
|
+
if (body !== undefined)
|
|
216
|
+
headers["content-type"] = "application/json";
|
|
217
|
+
if (token !== undefined && token.length > 0)
|
|
218
|
+
headers["authorization"] = `Bearer ${token}`;
|
|
219
|
+
if (options.idempotencyKey !== undefined)
|
|
220
|
+
headers["idempotency-key"] = options.idempotencyKey;
|
|
221
|
+
// ⛔ THE HEADER NAME IS THE SERVER'S, spelled once. It is enforced inside `from_request_parts`,
|
|
222
|
+
// which sees headers and never a body — which is why the proof is a header and not a field.
|
|
223
|
+
if (options.accountProof !== undefined)
|
|
224
|
+
headers["x-nmts-account-proof"] = options.accountProof;
|
|
225
|
+
let response;
|
|
226
|
+
try {
|
|
227
|
+
// ⛔ Built conditionally rather than passing `body: undefined`: with exactOptionalPropertyTypes
|
|
228
|
+
// the two are different, and a GET carrying an explicit undefined body is not the same
|
|
229
|
+
// request as a GET with no body at all.
|
|
230
|
+
const init = { method, headers, signal: controller.signal };
|
|
231
|
+
if (body !== undefined)
|
|
232
|
+
init.body = JSON.stringify(body);
|
|
233
|
+
response = await fetch(`${base}${path}`, init);
|
|
234
|
+
}
|
|
235
|
+
catch (error) {
|
|
236
|
+
// ⛔ The cause is named, not swallowed: "fetch failed" alone sends an agent looking at its own
|
|
237
|
+
// code. A timeout and a refused connection are different problems with different fixes.
|
|
238
|
+
const timedOut = controller.signal.aborted;
|
|
239
|
+
throw new NmtsError(timedOut ? `The server did not answer within ${timeoutMs}ms.` : `Could not reach ${base}.`, {
|
|
240
|
+
exitCode: 1,
|
|
241
|
+
nextStep: timedOut
|
|
242
|
+
? "The server may be slow or unreachable. Try again."
|
|
243
|
+
: `Check the address and the network. Cause: ${error instanceof Error ? error.message : String(error)}`,
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
finally {
|
|
247
|
+
clearTimeout(deadline);
|
|
248
|
+
}
|
|
249
|
+
const text = await response.text();
|
|
250
|
+
let parsed = null;
|
|
251
|
+
if (text.length > 0) {
|
|
252
|
+
try {
|
|
253
|
+
parsed = JSON.parse(text);
|
|
254
|
+
}
|
|
255
|
+
catch {
|
|
256
|
+
// A non-JSON body from an NMTS route means something in front of it answered — a proxy, an
|
|
257
|
+
// access page, an error page. Saying "invalid JSON" would point at the wrong thing.
|
|
258
|
+
throw new NmtsError(`${base} answered ${response.status} with something that is not JSON.`, {
|
|
259
|
+
exitCode: 1,
|
|
260
|
+
nextStep: "Something in front of the server answered. Check the address.",
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
if (!response.ok) {
|
|
265
|
+
if (isRefusal(parsed)) {
|
|
266
|
+
throw new ServerError(response.status, parsed.error, adviseFor(parsed.error.code));
|
|
267
|
+
}
|
|
268
|
+
throw new NmtsError(`${base} answered ${response.status}.`, { exitCode: 1 });
|
|
269
|
+
}
|
|
270
|
+
return parsed;
|
|
271
|
+
}
|
package/dist/args.js
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// Turning argv into a command and its options.
|
|
2
|
+
//
|
|
3
|
+
// ⛔ NO SECRET IS EVER AN OPTION. There is no --code and no --api-key, and adding one would undo
|
|
4
|
+
// the reason the credentials module exists: on Linux any process can read another's command
|
|
5
|
+
// line, and the shell records it. A test asserts that no option name here looks like a secret.
|
|
6
|
+
//
|
|
7
|
+
// ⛔ AN UNKNOWN OPTION IS AN ERROR, NOT A SHRUG. Ignoring it means `--serverr https://…` silently
|
|
8
|
+
// talks to the live server, and an agent retrying with a typo would never learn why.
|
|
9
|
+
//
|
|
10
|
+
// ⛔ THE OPTIONS ARE A TABLE, NOT A LADDER OF `if`s. Every option used to be written three times —
|
|
11
|
+
// once in the list a test reads, once as `--x value` and once as `--x=value` — and adding one
|
|
12
|
+
// meant remembering all three. Now the table below is the only place, so an option cannot exist
|
|
13
|
+
// in one spelling and not the other.
|
|
14
|
+
import { NmtsError } from "./errors.js";
|
|
15
|
+
/** Which field a value-taking option fills. */
|
|
16
|
+
const VALUE_OPTIONS = {
|
|
17
|
+
"--server": "server",
|
|
18
|
+
"--network": "network",
|
|
19
|
+
"--out": "out",
|
|
20
|
+
"--name": "name",
|
|
21
|
+
"--to": "to",
|
|
22
|
+
"--part-size": "partSize",
|
|
23
|
+
"--on-collision": "onCollision",
|
|
24
|
+
"--find": "find",
|
|
25
|
+
"--sort": "sort",
|
|
26
|
+
"--epochs": "epochs",
|
|
27
|
+
"--port": "port",
|
|
28
|
+
"--accept-terms": "acceptTerms",
|
|
29
|
+
"--accept-privacy": "acceptPrivacy",
|
|
30
|
+
};
|
|
31
|
+
/** Which field a flag sets to true. */
|
|
32
|
+
const FLAG_OPTIONS = {
|
|
33
|
+
"--help": "help",
|
|
34
|
+
"-h": "help",
|
|
35
|
+
"--version": "version",
|
|
36
|
+
"-V": "version",
|
|
37
|
+
"--json": "json",
|
|
38
|
+
"--all": "all",
|
|
39
|
+
"--force": "force",
|
|
40
|
+
"--dry-run": "dryRun",
|
|
41
|
+
"--yes": "yes",
|
|
42
|
+
"-y": "yes",
|
|
43
|
+
"--publish": "publish",
|
|
44
|
+
"--plain": "plain",
|
|
45
|
+
"--env": "env",
|
|
46
|
+
"--status": "status",
|
|
47
|
+
"--i-accept-the-risk": "iAcceptTheRisk",
|
|
48
|
+
"--desc": "desc",
|
|
49
|
+
"--hidden": "hidden",
|
|
50
|
+
};
|
|
51
|
+
// ⛔ Derived from the tables, not written again. A hand-kept list is how an option ends up tested
|
|
52
|
+
// for one property and accepted with another.
|
|
53
|
+
export const OPTIONS_TAKING_A_VALUE = Object.keys(VALUE_OPTIONS);
|
|
54
|
+
export const FLAGS = Object.keys(FLAG_OPTIONS);
|
|
55
|
+
function isValueOption(token) {
|
|
56
|
+
return Object.hasOwn(VALUE_OPTIONS, token);
|
|
57
|
+
}
|
|
58
|
+
function isFlag(token) {
|
|
59
|
+
return Object.hasOwn(FLAG_OPTIONS, token);
|
|
60
|
+
}
|
|
61
|
+
export function parseArgs(argv) {
|
|
62
|
+
const parsed = {
|
|
63
|
+
command: null,
|
|
64
|
+
operands: [],
|
|
65
|
+
help: false,
|
|
66
|
+
version: false,
|
|
67
|
+
json: false,
|
|
68
|
+
all: false,
|
|
69
|
+
force: false,
|
|
70
|
+
publish: false,
|
|
71
|
+
dryRun: false,
|
|
72
|
+
yes: false,
|
|
73
|
+
plain: false,
|
|
74
|
+
env: false,
|
|
75
|
+
status: false,
|
|
76
|
+
iAcceptTheRisk: false,
|
|
77
|
+
desc: false,
|
|
78
|
+
hidden: false,
|
|
79
|
+
};
|
|
80
|
+
let index = 0;
|
|
81
|
+
// ⛔ EVERYTHING AFTER `--` IS A NAME, NOT AN OPTION. Files in a drive are named by people and by
|
|
82
|
+
// other programs, and a name is allowed to start with a dash. Without this, `nmts rm -h`
|
|
83
|
+
// printed the help text and EXITED 0 — a silent false success on a deletion, for a path
|
|
84
|
+
// `nmts ls --json` had just handed the caller (2026-08-23).
|
|
85
|
+
let optionsEnded = false;
|
|
86
|
+
while (index < argv.length) {
|
|
87
|
+
const token = argv[index];
|
|
88
|
+
if (token === undefined)
|
|
89
|
+
break;
|
|
90
|
+
index += 1;
|
|
91
|
+
if (optionsEnded) {
|
|
92
|
+
parsed.operands.push(token);
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if (token === "--") {
|
|
96
|
+
optionsEnded = true;
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (isFlag(token)) {
|
|
100
|
+
parsed[FLAG_OPTIONS[token]] = true;
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
if (isValueOption(token)) {
|
|
104
|
+
const value = argv[index];
|
|
105
|
+
// ⛔ A LONE `-` IS A VALUE, NOT AN OPTION. It is how every tool spells "the standard
|
|
106
|
+
// streams", and `--out -` is what sends a fetched file to stdout instead of the disk.
|
|
107
|
+
// The rest of the test is unchanged and still catches `--out --force`, which is a
|
|
108
|
+
// missing value; this is the same exception the unknown-option check below already
|
|
109
|
+
// makes for a bare dash.
|
|
110
|
+
if (value === undefined || (value.startsWith("-") && value !== "-")) {
|
|
111
|
+
throw new NmtsError(`${token} needs a value after it.`, { exitCode: 2 });
|
|
112
|
+
}
|
|
113
|
+
index += 1;
|
|
114
|
+
parsed[VALUE_OPTIONS[token]] = value;
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
const equals = token.indexOf("=");
|
|
118
|
+
if (equals > 0) {
|
|
119
|
+
const head = token.slice(0, equals);
|
|
120
|
+
if (isValueOption(head)) {
|
|
121
|
+
parsed[VALUE_OPTIONS[head]] = token.slice(equals + 1);
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
if (token.startsWith("-") && token !== "-") {
|
|
126
|
+
throw new NmtsError(`Unknown option: ${token}`, {
|
|
127
|
+
exitCode: 2,
|
|
128
|
+
nextStep: `Run with --help to see the options this version accepts.`,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
if (parsed.command === null)
|
|
132
|
+
parsed.command = token;
|
|
133
|
+
else
|
|
134
|
+
parsed.operands.push(token);
|
|
135
|
+
}
|
|
136
|
+
return parsed;
|
|
137
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// WHAT THIS FILE IS · WHAT OPENS IT · WHERE TO GET THAT · WHO WROTE IT — the block every recovery
|
|
2
|
+
// artefact this tool writes carries in its plaintext header.
|
|
3
|
+
//
|
|
4
|
+
// ⛔ WHY IT IS WORTH BYTES. Three artefacts leave this tool and live in an ordinary folder rather
|
|
5
|
+
// than on the storage network: the recovery list (`.nmtsmap`), this machine's copy of the file
|
|
6
|
+
// list (`.nmtslist`), and the recovery kit (`.txt`). Nothing around them supplies context — no
|
|
7
|
+
// address, no account screen, no site. Years later the only question that matters is whether
|
|
8
|
+
// the person holding one can act on it alone, and these fields are what answer it: which
|
|
9
|
+
// product wrote it, which program reads it, where to get that program, and where the format is
|
|
10
|
+
// written down.
|
|
11
|
+
//
|
|
12
|
+
// ⛔ TWO LAYERS, AND THE SPLIT IS A PRIVACY RULE. This is the PLAINTEXT layer, so it carries only
|
|
13
|
+
// what a reader needs BEFORE opening anything: never a file name, never a count, never a total.
|
|
14
|
+
// A leaked wrapper must not say what is inside it, or even how much. Everything here is a
|
|
15
|
+
// constant of the product plus the version of the program that wrote the file.
|
|
16
|
+
//
|
|
17
|
+
// ⚠ EVERY FIELD IS A CLAIM, NEVER A REQUIREMENT. No reader can check them and none should refuse a
|
|
18
|
+
// file over them — a URL can die and a repository can move. They are here to save a person a
|
|
19
|
+
// search, not to decide whether a recovery may proceed.
|
|
20
|
+
//
|
|
21
|
+
// ⚠ MIRRORS `web/src/lib/recovery/provenance.ts`, which this package cannot import: the two trees
|
|
22
|
+
// share no code by design. The VALUES are taken from the modules that already own them
|
|
23
|
+
// (`product.ts`, `recovery-release.ts`) so a rename lands here without a second edit.
|
|
24
|
+
import { HOME_URL, VERSION } from "./product.js";
|
|
25
|
+
import { RECOVERY_TOOL, RECOVERY_TOOL_URL } from "./recovery-release.js";
|
|
26
|
+
/** The product these artefacts come from, spelled as the formats carry it. */
|
|
27
|
+
export const PRODUCT = "NMTS";
|
|
28
|
+
/** Where the recovery list's format is written down, in the copy anybody can reach. */
|
|
29
|
+
export const RECOVERY_SPEC_URL = `${RECOVERY_TOOL_URL}/blob/main/docs/RECOVERY-MANIFEST.md`;
|
|
30
|
+
/** Where the envelope format is — key derivation, header layout, domain separators. */
|
|
31
|
+
export const CRYPTO_SPEC_URL = `${RECOVERY_TOOL_URL}/blob/main/docs/CRYPTO-FORMAT-NCF3.md`;
|
|
32
|
+
/**
|
|
33
|
+
* Which build wrote the file.
|
|
34
|
+
*
|
|
35
|
+
* ⚠ A CLAIM, NEVER A REQUIREMENT. It names THIS PROGRAM rather than the site release, because that
|
|
36
|
+
* is the field's own contract — what the writer says about itself — and a person holding two
|
|
37
|
+
* copies of one account's artefacts can then tell which program made each.
|
|
38
|
+
*/
|
|
39
|
+
export const WRITTEN_BY = `nmts-cli ${VERSION}`;
|
|
40
|
+
/** The NCF-3 domain separator each sealed artefact uses. */
|
|
41
|
+
const SEALED_CONTEXT = {
|
|
42
|
+
"recovery-list": "nmts/v3/recovery-map",
|
|
43
|
+
"file-list": "nmts/v3/file-list",
|
|
44
|
+
};
|
|
45
|
+
/** The block for one wrapper. */
|
|
46
|
+
export function artifactAbout(artifact) {
|
|
47
|
+
const about = {
|
|
48
|
+
product: PRODUCT,
|
|
49
|
+
product_url: HOME_URL,
|
|
50
|
+
app_version: WRITTEN_BY,
|
|
51
|
+
artifact,
|
|
52
|
+
tool: RECOVERY_TOOL,
|
|
53
|
+
tool_url: RECOVERY_TOOL_URL,
|
|
54
|
+
spec_url: artifact === "file-list" ? CRYPTO_SPEC_URL : RECOVERY_SPEC_URL,
|
|
55
|
+
};
|
|
56
|
+
if (artifact === "recovery-kit") {
|
|
57
|
+
// ⛔ Said out loud because this is the artefact that is dangerous to hold. A reader — a person
|
|
58
|
+
// or a program — must be able to tell from the header alone that this file carries the code.
|
|
59
|
+
about.contains = ["account-code", "recovery-list"];
|
|
60
|
+
return about;
|
|
61
|
+
}
|
|
62
|
+
about.sealed = {
|
|
63
|
+
format: "ncf3",
|
|
64
|
+
context: SEALED_CONTEXT[artifact],
|
|
65
|
+
encoding: "base64url",
|
|
66
|
+
opened_with: "nmts-account-code",
|
|
67
|
+
spec_url: CRYPTO_SPEC_URL,
|
|
68
|
+
};
|
|
69
|
+
return about;
|
|
70
|
+
}
|
package/dist/autonomy.js
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// How much this machine's owner has said an agent may decide on its own.
|
|
2
|
+
//
|
|
3
|
+
// ⛔ THIS IS A DIFFERENT AXIS FROM CONSENT, and mixing the two would make both harder to reason
|
|
4
|
+
// about. A consent key says "this machine agreed that this tool may do X" -- it is about the
|
|
5
|
+
// CAPABILITY, it is recorded, and it stands until it is revoked. A mode says "an agent driving
|
|
6
|
+
// this tool may decide for me" -- it is about WHO CHOOSES, and it changes nothing about what
|
|
7
|
+
// the tool is able to do. They are stored in different files for that reason: a mode is not a
|
|
8
|
+
// sixth consent, and the count of consents is a number this package deliberately holds down.
|
|
9
|
+
//
|
|
10
|
+
// ⛔ NOTHING IS ON BY DEFAULT, AND TURNING ONE ON TAKES A FLAG THAT SAYS SO. The person is the one
|
|
11
|
+
// who bears what an unattended agent does with their files and their money, so the sentence
|
|
12
|
+
// that turns it on has to be one nobody types by accident.
|
|
13
|
+
//
|
|
14
|
+
// ⛔ WHAT A MODE DOES NOT DO: it does not grant a consent, and it cannot grant itself. Every
|
|
15
|
+
// capability that costs money or cannot be undone is still recorded as its own agreement. What
|
|
16
|
+
// `skip-permissions` changes is WHO MAY RECORD IT -- with it on, an agent may run the grant
|
|
17
|
+
// command, which without it the instructions forbid. The record still says what was agreed and
|
|
18
|
+
// when, so a person reading it afterwards sees the same thing either way.
|
|
19
|
+
//
|
|
20
|
+
// ⚠ AND WHAT NO COMMAND-LINE TOOL CAN DO: tell whether a person or a program typed this. The
|
|
21
|
+
// protection here is that the choice is explicit, written down, dated, and announced on every
|
|
22
|
+
// run that uses it -- not that it cannot be automated.
|
|
23
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync, chmodSync } from "node:fs";
|
|
24
|
+
import { join } from "node:path";
|
|
25
|
+
import { configDir, modesAreEnforced } from "./credentials.js";
|
|
26
|
+
export const AUTONOMY_MODES = ["off", "auto", "skip-permissions"];
|
|
27
|
+
/** What each mode means, in the words the tool prints. One line each, no more. */
|
|
28
|
+
export const MODE_MEANS = {
|
|
29
|
+
off: "The agent asks you before anything that has not been agreed to. This is the default.",
|
|
30
|
+
auto: "The agent decides whether you asked for it, or whether it is fine to do unasked, and goes ahead.",
|
|
31
|
+
"skip-permissions": "The agent goes ahead. There is no judgement step and nothing waits for you.",
|
|
32
|
+
};
|
|
33
|
+
/** The flag that has to be typed to turn a mode on. Spelled out so nobody types it by accident. */
|
|
34
|
+
export const RISK_FLAG = "--i-accept-the-risk";
|
|
35
|
+
function path() {
|
|
36
|
+
return join(configDir(), "autonomy.json");
|
|
37
|
+
}
|
|
38
|
+
function isMode(value) {
|
|
39
|
+
return typeof value === "string" && AUTONOMY_MODES.includes(value);
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* What this machine is set to.
|
|
43
|
+
*
|
|
44
|
+
* ⛔ Unreadable counts as `off`. The fail-safe direction for "I do not know" is the one where
|
|
45
|
+
* somebody is still asked -- a file that switches autonomy on when it cannot be parsed is worse
|
|
46
|
+
* than no file at all.
|
|
47
|
+
*/
|
|
48
|
+
export function currentMode() {
|
|
49
|
+
try {
|
|
50
|
+
const parsed = JSON.parse(readFileSync(path(), "utf8"));
|
|
51
|
+
if (typeof parsed !== "object" || parsed === null)
|
|
52
|
+
return "off";
|
|
53
|
+
const mode = Reflect.get(parsed, "mode");
|
|
54
|
+
return isMode(mode) ? mode : "off";
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return "off";
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
/** When it was set, or null when it is off or unreadable. */
|
|
61
|
+
export function setAt() {
|
|
62
|
+
try {
|
|
63
|
+
const parsed = JSON.parse(readFileSync(path(), "utf8"));
|
|
64
|
+
if (typeof parsed !== "object" || parsed === null)
|
|
65
|
+
return null;
|
|
66
|
+
const at = Reflect.get(parsed, "setAt");
|
|
67
|
+
return typeof at === "string" ? at : null;
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/** Write the choice down, with the date and the version that was asked. */
|
|
74
|
+
export function setMode(mode, version, now) {
|
|
75
|
+
if (mode === "off") {
|
|
76
|
+
if (existsSync(path()))
|
|
77
|
+
rmSync(path(), { force: true });
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
const body = { mode, setAt: now.toISOString(), byVersion: version };
|
|
81
|
+
mkdirSync(configDir(), { recursive: true, mode: 0o700 });
|
|
82
|
+
writeFileSync(path(), `${JSON.stringify(body, null, 2)}\n`, { mode: 0o600 });
|
|
83
|
+
if (modesAreEnforced())
|
|
84
|
+
chmodSync(path(), 0o600);
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* The line every run prints when a mode is on.
|
|
88
|
+
*
|
|
89
|
+
* ⛔ IT IS PRINTED EVERY TIME, not once. A setting that stops announcing itself is a setting people
|
|
90
|
+
* forget they turned on, and this one decides whether anybody is asked before money is spent.
|
|
91
|
+
* ⚠ It goes to stderr: stdout belongs to whatever is reading this tool's output.
|
|
92
|
+
*/
|
|
93
|
+
export function announcement(mode) {
|
|
94
|
+
if (mode === "off")
|
|
95
|
+
return null;
|
|
96
|
+
return `${BANNER_PREFIX}${mode} — ${MODE_MEANS[mode]} Turn it off with \`nmts mode off\`.`;
|
|
97
|
+
}
|
|
98
|
+
const BANNER_PREFIX = "nmts: autonomy is ";
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// Getting the account code this run should actually use — including opening a sealed one.
|
|
2
|
+
//
|
|
3
|
+
// ⛔ WHY IT IS NOT IN `credentials.ts`. That module answers "where is the code"; this one answers
|
|
4
|
+
// "may this run have it, and can it be opened". Putting the second question in the first module
|
|
5
|
+
// would make `credentials.ts` import `consent.ts`, which already imports `credentials.ts` for
|
|
6
|
+
// the config directory — a cycle that works until the day it does not.
|
|
7
|
+
//
|
|
8
|
+
// ⚠ EVERY COMMAND THAT USES THE CODE GOES THROUGH `openAccountCode` — with ONE exception, and it
|
|
9
|
+
// is written here rather than left to be discovered. `login` does not: it is the command that
|
|
10
|
+
// RECEIVES a code rather than one that uses a stored one, so it reads the ways in for itself
|
|
11
|
+
// (`commands/login.ts`, `readTheCode`). It asks for the same `plain-env` agreement at that
|
|
12
|
+
// point, because an adversarial review found that it did not, and two commands were then enough
|
|
13
|
+
// to launder a code from the environment into a store that asks for nothing.
|
|
14
|
+
//
|
|
15
|
+
// ⛔ THAT EXCEPTION IS THE WHOLE RISK OF THIS SHAPE. A rule enforced at each call site has as many
|
|
16
|
+
// holes as there are call sites, and the hole is always the newest one.
|
|
17
|
+
import { requireConsent } from "./consent.js";
|
|
18
|
+
import { unlockCode } from "./code-vault.js";
|
|
19
|
+
import { CODE_ENV_VAR, PASSPHRASE_ENV_VAR, credentialsPath, resolveAccountCode, } from "./credentials.js";
|
|
20
|
+
import { NmtsError, NotLoggedInError } from "./errors.js";
|
|
21
|
+
import { promptSecret, stdinIsATerminal } from "./prompt.js";
|
|
22
|
+
import { BINARY_NAME } from "./product.js";
|
|
23
|
+
export async function openAccountCode(options = {}) {
|
|
24
|
+
const found = resolveAccountCode();
|
|
25
|
+
if (found === null)
|
|
26
|
+
return null;
|
|
27
|
+
if (found.source === "env") {
|
|
28
|
+
// ⛔ The one credential path that asks first. See the `plain-env` entry in consent.ts — the
|
|
29
|
+
// variable is readable in ways a file is not, and the person gets to decide that once.
|
|
30
|
+
requireConsent("plain-env");
|
|
31
|
+
return { code: found.code, source: "env" };
|
|
32
|
+
}
|
|
33
|
+
if (found.source !== "file-locked")
|
|
34
|
+
return { code: found.code, source: found.source };
|
|
35
|
+
const passphrase = await readPassphrase(options.allowPrompt !== false);
|
|
36
|
+
return { code: unlockCode(found.locked, passphrase), source: "file-locked" };
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Where the passphrase comes from: the environment first, then a terminal.
|
|
40
|
+
*
|
|
41
|
+
* ⛔ THE ENVIRONMENT WINS so that an unattended agent works at all. It is a weaker arrangement and
|
|
42
|
+
* `consent.ts` says why; refusing it would not make anybody safer, it would make them write a
|
|
43
|
+
* wrapper that types the passphrase in, which is worse and invisible.
|
|
44
|
+
*/
|
|
45
|
+
async function readPassphrase(allowPrompt) {
|
|
46
|
+
const fromEnv = process.env[PASSPHRASE_ENV_VAR];
|
|
47
|
+
if (fromEnv !== undefined && fromEnv.length > 0)
|
|
48
|
+
return fromEnv;
|
|
49
|
+
if (!allowPrompt || !stdinIsATerminal()) {
|
|
50
|
+
throw new NmtsError(`The stored account code is sealed with a passphrase.`, {
|
|
51
|
+
exitCode: 3,
|
|
52
|
+
nextStep: [
|
|
53
|
+
allowPrompt
|
|
54
|
+
? `There is no terminal to type it into, so one of these has to supply it:`
|
|
55
|
+
: `This command reads its own protocol from the terminal, so it cannot ask. Supply it:`,
|
|
56
|
+
` · set ${PASSPHRASE_ENV_VAR} for this run`,
|
|
57
|
+
` · name a file holding the code instead: NMTS_ACCOUNT_CODE_FILE=/path`,
|
|
58
|
+
` · store it unsealed: ${BINARY_NAME} login --plain (this asks for an agreement first)`,
|
|
59
|
+
``,
|
|
60
|
+
`The sealed file is ${credentialsPath()}.`,
|
|
61
|
+
].join("\n"),
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
return promptSecret(`Passphrase for the stored account code: `, PASSPHRASE_ENV_VAR);
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* The code, or a refusal naming what to do about it. For commands that cannot proceed without it.
|
|
68
|
+
*
|
|
69
|
+
* ⛔ The message never mentions the code itself, only where one could come from.
|
|
70
|
+
*/
|
|
71
|
+
export async function requireAccountCode(options = {}) {
|
|
72
|
+
const opened = await openAccountCode(options);
|
|
73
|
+
if (opened === null)
|
|
74
|
+
throw new NotLoggedInError(BINARY_NAME, CODE_ENV_VAR);
|
|
75
|
+
return opened;
|
|
76
|
+
}
|