@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,169 @@
|
|
|
1
|
+
// `nmts update` — install the newest published release of this tool.
|
|
2
|
+
//
|
|
3
|
+
// ⛔ TYPING THE VERB IS THE AGREEMENT. Everything else in this tool that cannot be taken back
|
|
4
|
+
// stops and waits for `--yes`, because the destructive part is a side effect of asking for
|
|
5
|
+
// something else: `sweep` is asked for a tidy-up and erases rows, `rebuild` is asked for a file
|
|
6
|
+
// list and replaces one. Here the thing that happens IS the thing that was typed. A command
|
|
7
|
+
// called `update` that refuses to update until it is asked twice has not made anybody safer;
|
|
8
|
+
// it has taught them to type `--yes` without reading, which is the habit that matters.
|
|
9
|
+
//
|
|
10
|
+
// ⛔ IT NAMES A RELEASE, NEVER "LATEST". The version is resolved once, printed, and then installed
|
|
11
|
+
// from the address of THAT release. Handing the installer "latest" would mean the thing on the
|
|
12
|
+
// screen and the thing on the disk are two separate answers to one question, and a release
|
|
13
|
+
// published in between makes them different.
|
|
14
|
+
//
|
|
15
|
+
// ⛔ IT REFUSES WHEN THIS COPY IS NOT THE ONE IT WOULD REPLACE. Run from a source checkout, or
|
|
16
|
+
// from a copy somebody put somewhere by hand, installing would not update anything — it would
|
|
17
|
+
// put a SECOND copy on the PATH, and which one runs afterwards depends on the order of
|
|
18
|
+
// directories in an environment variable. That is the kind of confusion somebody debugs for an
|
|
19
|
+
// hour, so it is a refusal that says what to run instead.
|
|
20
|
+
//
|
|
21
|
+
// ⛔ IT ASKS THE SOURCE-HOSTING SITE, NOT NMTS. No account code, no API key, no session; the
|
|
22
|
+
// command works signed out, and it is one of the two places in this tool that talk to a host
|
|
23
|
+
// other than the NMTS server and the storage network. The other is `nmts recovery`.
|
|
24
|
+
import { spawnSync } from "node:child_process";
|
|
25
|
+
import { NmtsError } from "../errors.js";
|
|
26
|
+
import { BINARY_NAME, SOURCE_URL, VERSION } from "../product.js";
|
|
27
|
+
import { lookupLatest } from "../update-check.js";
|
|
28
|
+
import { compareVersions, installCommand, installCommandLine, isNewer, releasePageUrl, } from "../update-source.js";
|
|
29
|
+
/**
|
|
30
|
+
* Was this copy put here by the installer that would replace it?
|
|
31
|
+
*
|
|
32
|
+
* The test is the one thing that is actually true of every such copy and of nothing else: it
|
|
33
|
+
* lives in a directory named for the package, inside a `node_modules`.
|
|
34
|
+
*
|
|
35
|
+
* ⛔ BOTH SEPARATORS, AND THAT IS NOT DEFENSIVENESS. Windows accepts either, and Node hands out
|
|
36
|
+
* either: a path that came through a file URL arrives as `D:/a/…` while `import.meta.filename`
|
|
37
|
+
* gives `D:\a\…`. Splitting on the platform's own separator found nothing in the first shape,
|
|
38
|
+
* so an installed copy on Windows was told it was not installed and `update` refused to run —
|
|
39
|
+
* green on Linux and macOS, red only on the platform this repository tests last.
|
|
40
|
+
*/
|
|
41
|
+
export function installedAsPackage(moduleFile) {
|
|
42
|
+
const parts = moduleFile.split(/[\\/]/);
|
|
43
|
+
const at = parts.lastIndexOf("node_modules");
|
|
44
|
+
return at >= 0 && parts[at + 1] === BINARY_NAME;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Run the installer.
|
|
48
|
+
*
|
|
49
|
+
* ⛔ NO SHELL. The address is built from a version that has already been refused unless it is
|
|
50
|
+
* three numbers, so there is nothing in this command line a shell could find to interpret —
|
|
51
|
+
* and asking one to look would be inviting exactly the question this avoids. On Windows the
|
|
52
|
+
* installer is a `.cmd`, which is why its name differs there: naming it directly is what makes
|
|
53
|
+
* the shell unnecessary.
|
|
54
|
+
*/
|
|
55
|
+
function runInstaller(command, json) {
|
|
56
|
+
const [, ...rest] = command;
|
|
57
|
+
const binary = process.platform === "win32" ? "npm.cmd" : "npm";
|
|
58
|
+
// ⛔ Under --json the installer's own output cannot go to stdout: something is parsing that.
|
|
59
|
+
const result = spawnSync(binary, rest, {
|
|
60
|
+
stdio: json ? ["ignore", "pipe", "pipe"] : "inherit",
|
|
61
|
+
encoding: "utf8",
|
|
62
|
+
});
|
|
63
|
+
if (result.error !== undefined) {
|
|
64
|
+
return { code: 1, unstartable: result.error.message };
|
|
65
|
+
}
|
|
66
|
+
const outcome = { code: result.status ?? 1 };
|
|
67
|
+
if (json) {
|
|
68
|
+
const said = `${result.stdout ?? ""}${result.stderr ?? ""}`.trim();
|
|
69
|
+
if (said.length > 0)
|
|
70
|
+
outcome.output = said.slice(-2000);
|
|
71
|
+
}
|
|
72
|
+
return outcome;
|
|
73
|
+
}
|
|
74
|
+
export async function update(options = {}) {
|
|
75
|
+
const say = options.write ?? ((line) => process.stdout.write(`${line}\n`));
|
|
76
|
+
const running = options.running ?? VERSION;
|
|
77
|
+
const moduleFile = options.moduleFile ?? import.meta.filename;
|
|
78
|
+
const found = await (options.lookup ?? lookupLatest)();
|
|
79
|
+
if ("failed" in found)
|
|
80
|
+
throw couldNotAsk(found.failed);
|
|
81
|
+
const latest = found.version;
|
|
82
|
+
if (!isNewer(latest, running)) {
|
|
83
|
+
const ahead = compareVersions(running, latest) === 1;
|
|
84
|
+
if (options.json) {
|
|
85
|
+
say(JSON.stringify({ running, latest, newerAvailable: false, installed: false, ahead }));
|
|
86
|
+
return 0;
|
|
87
|
+
}
|
|
88
|
+
say(ahead
|
|
89
|
+
? `This is ${running}. The newest published release is ${latest}, which is older.`
|
|
90
|
+
: `This is ${running}, which is the newest published release.`);
|
|
91
|
+
return 0;
|
|
92
|
+
}
|
|
93
|
+
const command = installCommand(latest);
|
|
94
|
+
const line = installCommandLine(latest);
|
|
95
|
+
const page = releasePageUrl(latest);
|
|
96
|
+
// Unreachable while `lookupLatest` only returns shape-checked versions, and a refusal rather
|
|
97
|
+
// than a cast: the value came off the network, and this is the last place that can say no.
|
|
98
|
+
if (command === null || line === null || page === null) {
|
|
99
|
+
throw couldNotAsk(`the newest release is named ${latest}, which this version cannot install`);
|
|
100
|
+
}
|
|
101
|
+
const isPackage = installedAsPackage(moduleFile);
|
|
102
|
+
// ⛔ A DRY RUN THAT REFUSED WOULD BE REPORTING A FAILURE THAT DID NOT HAPPEN. It was asked to
|
|
103
|
+
// say what it would do and it said it, whichever copy this is — so it ends at 0. The refusal
|
|
104
|
+
// below belongs to the run that would actually have installed something.
|
|
105
|
+
const stopping = options.dryRun === true || !isPackage;
|
|
106
|
+
if (stopping) {
|
|
107
|
+
const code = options.dryRun === true ? 0 : 4;
|
|
108
|
+
if (options.json) {
|
|
109
|
+
say(JSON.stringify({
|
|
110
|
+
running,
|
|
111
|
+
latest,
|
|
112
|
+
newerAvailable: true,
|
|
113
|
+
installed: false,
|
|
114
|
+
installedAsPackage: isPackage,
|
|
115
|
+
command: line,
|
|
116
|
+
releasePage: page,
|
|
117
|
+
}));
|
|
118
|
+
return code;
|
|
119
|
+
}
|
|
120
|
+
say(`${running} is running. ${latest} is published: ${page}`);
|
|
121
|
+
say(``);
|
|
122
|
+
if (!isPackage) {
|
|
123
|
+
say(`This copy was not installed by the installer, so installing would leave two:`);
|
|
124
|
+
say(` this one ${moduleFile}`);
|
|
125
|
+
say(` the new one wherever the installer puts it, which may come first on your PATH.`);
|
|
126
|
+
say(``);
|
|
127
|
+
say(`Update this copy where it came from — or, to install alongside it anyway:`);
|
|
128
|
+
say(``);
|
|
129
|
+
}
|
|
130
|
+
say(` ${line}`);
|
|
131
|
+
return code;
|
|
132
|
+
}
|
|
133
|
+
if (!options.json) {
|
|
134
|
+
say(`${running} → ${latest} ${page}`);
|
|
135
|
+
say(``);
|
|
136
|
+
say(` ${line}`);
|
|
137
|
+
say(``);
|
|
138
|
+
}
|
|
139
|
+
const outcome = (options.install ?? runInstaller)(command, options.json === true);
|
|
140
|
+
if (outcome.unstartable !== undefined) {
|
|
141
|
+
throw new NmtsError(`The installer could not be started: ${outcome.unstartable}`, {
|
|
142
|
+
exitCode: 1,
|
|
143
|
+
nextStep: `Nothing was changed. \`npm\` has to be on the PATH for this command to install ` +
|
|
144
|
+
`anything. Once it is, this does the same thing:\n\n ${line}`,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
if (outcome.code !== 0) {
|
|
148
|
+
throw new NmtsError(`The installer stopped with code ${outcome.code}.`, {
|
|
149
|
+
exitCode: 1,
|
|
150
|
+
nextStep: (outcome.output !== undefined ? `${outcome.output}\n\n` : "") +
|
|
151
|
+
`${running} is still what runs. A global install writes outside your home directory on ` +
|
|
152
|
+
`some setups and needs the rights to do it; running the same command yourself shows the ` +
|
|
153
|
+
`whole reason:\n\n ${line}`,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
if (options.json) {
|
|
157
|
+
say(JSON.stringify({ running, latest, newerAvailable: true, installed: true, command: line }));
|
|
158
|
+
return 0;
|
|
159
|
+
}
|
|
160
|
+
say(`${latest} is installed. \`${BINARY_NAME} --version\` says which one runs.`);
|
|
161
|
+
return 0;
|
|
162
|
+
}
|
|
163
|
+
function couldNotAsk(why) {
|
|
164
|
+
return new NmtsError(`Could not find out which release is newest: ${why}.`, {
|
|
165
|
+
exitCode: 1,
|
|
166
|
+
nextStep: `Nothing was changed. This asks the site the releases are published on, not NMTS — the ` +
|
|
167
|
+
`releases are readable in a browser at:\n\n ${SOURCE_URL}/releases/latest`,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// `nmts usage` — how much this account is holding, added up from the sealed file list.
|
|
2
|
+
//
|
|
3
|
+
// ⚠ IT ANSWERS FROM THE LIST, LIKE `ls`. The list is what every device reads to know what exists,
|
|
4
|
+
// so its counts are the account's counts. It still proves nothing about whether the bytes behind
|
|
5
|
+
// any one entry are fetchable, and it is not a bill: the storage network holds more bytes than
|
|
6
|
+
// the files contain, and what was paid for is a chain fact this command never reads.
|
|
7
|
+
//
|
|
8
|
+
// ⛔ THE TRASH GETS ITS OWN LINE AND IS NOT IN THE TOTAL. "I deleted 4 GB and nothing changed" and
|
|
9
|
+
// "I deleted 4 GB and it is gone" are both wrong, and one number cannot say the true thing,
|
|
10
|
+
// which is that the bytes are out of the drive and still stored. So there are two numbers, and
|
|
11
|
+
// the trash one carries the two commands that act on it.
|
|
12
|
+
//
|
|
13
|
+
// ⛔ IT IS ITS OWN COMMAND RATHER THAN A LINE UNDER `ls`. `ls` prints a table whose length is the
|
|
14
|
+
// drive's, and an agent that only wants "am I near my limit" would have to fetch and parse all
|
|
15
|
+
// of it. This answers that in one small object, and both read the same list the same way.
|
|
16
|
+
import { buildIndex } from "../drive-paths.js";
|
|
17
|
+
import { readFileList } from "../manifest.js";
|
|
18
|
+
import { BINARY_NAME } from "../product.js";
|
|
19
|
+
import { openSession } from "../session.js";
|
|
20
|
+
import { humanSize } from "../units.js";
|
|
21
|
+
import { computeUsage } from "../usage-report.js";
|
|
22
|
+
/** An account that has never been written to. Nothing is unknown here — nothing was uploaded. */
|
|
23
|
+
const NOTHING = {
|
|
24
|
+
files: 0,
|
|
25
|
+
folders: 0,
|
|
26
|
+
bytes: 0,
|
|
27
|
+
trashedFiles: 0,
|
|
28
|
+
trashedBytes: 0,
|
|
29
|
+
biggest: [],
|
|
30
|
+
};
|
|
31
|
+
function plural(n, one, many) {
|
|
32
|
+
return `${n} ${n === 1 ? one : many}`;
|
|
33
|
+
}
|
|
34
|
+
export async function usage(options = {}) {
|
|
35
|
+
const say = options.write ?? ((line) => process.stdout.write(`${line}\n`));
|
|
36
|
+
const session = await openSession({ server: options.server, network: options.network });
|
|
37
|
+
const list = await readFileList(session.server, session.apiKey, session.code, session.accountId);
|
|
38
|
+
if (list.manifest === null) {
|
|
39
|
+
// ⛔ ZEROS, NOT A MISSING FIELD. An agent asking how much the account holds gets an answer it
|
|
40
|
+
// can compare against a number; making it special-case an absent shape is how "no list yet"
|
|
41
|
+
// turns into an unhandled branch on somebody's first run.
|
|
42
|
+
if (options.json) {
|
|
43
|
+
say(JSON.stringify({ state: "absent", ...NOTHING }));
|
|
44
|
+
return 0;
|
|
45
|
+
}
|
|
46
|
+
say(`This account has no file list yet. Nothing has been uploaded from any device.`);
|
|
47
|
+
return 0;
|
|
48
|
+
}
|
|
49
|
+
const report = computeUsage(buildIndex(list.manifest.entries));
|
|
50
|
+
if (options.json) {
|
|
51
|
+
say(JSON.stringify({ state: "present", seq: list.seq, ...report }));
|
|
52
|
+
return 0;
|
|
53
|
+
}
|
|
54
|
+
if (report.files === 0 && report.folders === 0 && report.trashedFiles === 0) {
|
|
55
|
+
say(`This account's file list is empty.`);
|
|
56
|
+
return 0;
|
|
57
|
+
}
|
|
58
|
+
say(`${plural(report.files, "file", "files")} · ` +
|
|
59
|
+
`${plural(report.folders, "folder", "folders")} · ` +
|
|
60
|
+
`${humanSize(report.bytes)}`);
|
|
61
|
+
if (report.biggest.length > 0) {
|
|
62
|
+
say(``);
|
|
63
|
+
say(`Largest`);
|
|
64
|
+
for (const file of report.biggest) {
|
|
65
|
+
say(` ${humanSize(file.size).padStart(9)} ${file.path}`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
say(``);
|
|
69
|
+
if (report.trashedFiles === 0) {
|
|
70
|
+
say(`Nothing is in the trash.`);
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
say(`In the trash: ${plural(report.trashedFiles, "file", "files")} · ` +
|
|
74
|
+
`${humanSize(report.trashedBytes)}. That is still stored and still paid for, and it is not`);
|
|
75
|
+
say(`counted above. \`${BINARY_NAME} ls --all\` lists it; \`${BINARY_NAME} sweep\` drops the ` +
|
|
76
|
+
`entries whose 30 days have run out.`);
|
|
77
|
+
}
|
|
78
|
+
say(``);
|
|
79
|
+
say(` Sizes are the plaintext the files hold, counted from this account's own list — the server`);
|
|
80
|
+
say(` was asked for nothing but the sealed blob, and it could not have answered this anyway.`);
|
|
81
|
+
say(` What the storage network holds is larger: sealing adds bytes, and storage is bought in`);
|
|
82
|
+
say(` fixed units. This is not what the account was charged.`);
|
|
83
|
+
return 0;
|
|
84
|
+
}
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
// `nmts verify` — the one thing this tool needs done that it cannot do itself.
|
|
2
|
+
//
|
|
3
|
+
// ⛔ WHY THERE IS A COMMAND FOR SOMETHING THIS TOOL CANNOT DO. An API key makes the server answer,
|
|
4
|
+
// and that is the whole of what it does. Behind the key the server keeps a second question —
|
|
5
|
+
// has anybody checked lately that a person is behind this account — and when the answer is no
|
|
6
|
+
// it does not stop the account, it makes it slower and closes the routes that hand out
|
|
7
|
+
// something for nothing. The only way to answer that question is for a person to open a page
|
|
8
|
+
// and type a short code. So this command asks the server for the code, prints it, and waits:
|
|
9
|
+
// the doing is theirs, the waiting is ours, and saying it any other way would be a lie about
|
|
10
|
+
// what a program can do.
|
|
11
|
+
//
|
|
12
|
+
// ⛔ THE CODE IS PRINTED ON PURPOSE, AND IT IS NOT THE ACCOUNT CODE. Everything else in this tool
|
|
13
|
+
// refuses to put a credential on the screen. This one is minted to be read out: it is single
|
|
14
|
+
// use, it stops working within minutes, the server keeps only a hash of it, and by itself it
|
|
15
|
+
// opens nothing — a person still has to pass the check on the page. The account code is a
|
|
16
|
+
// different thing entirely and still never appears here, which the text says out loud so that
|
|
17
|
+
// nobody reads "type this code" and reaches for the wrong one.
|
|
18
|
+
//
|
|
19
|
+
// ⛔ IT PRINTS THE MOMENT THE CHECK ENDS, NOT A NUMBER OF DAYS. The window is counted in the
|
|
20
|
+
// server's own weeks, so it ends on a week boundary rather than a fixed span after the code was
|
|
21
|
+
// typed: somebody who verifies ten minutes before that boundary gets ten minutes. There is no
|
|
22
|
+
// special case for that in the server and there should not be one here — a second clock is how
|
|
23
|
+
// two answers to one question start. The honest thing is the absolute moment, and then whoever
|
|
24
|
+
// is reading decides.
|
|
25
|
+
//
|
|
26
|
+
// ⚠ IT NEEDS THE API KEY AND NOT THE ACCOUNT CODE. Nothing here opens a file, so demanding the
|
|
27
|
+
// code would refuse a run over a credential the command never uses.
|
|
28
|
+
import { request } from "../api.js";
|
|
29
|
+
import { readCredentialsFile } from "../credentials.js";
|
|
30
|
+
import { NmtsError } from "../errors.js";
|
|
31
|
+
import { BINARY_NAME } from "../product.js";
|
|
32
|
+
import { resolveServer } from "../server.js";
|
|
33
|
+
import { requireApiKey } from "../session.js";
|
|
34
|
+
/**
|
|
35
|
+
* How long to wait between asks when the server does not say.
|
|
36
|
+
*
|
|
37
|
+
* ⛔ AND THE BOUNDS AROUND WHAT IT DOES SAY. The interval arrives from the server, which makes it
|
|
38
|
+
* data — and a loop that takes its own timing from data is a loop somebody else controls. Zero
|
|
39
|
+
* would be a hot loop against the server; an hour would outlive the code and look like a hang.
|
|
40
|
+
*/
|
|
41
|
+
const DEFAULT_POLL_SECS = 5;
|
|
42
|
+
const MIN_POLL_MS = 1_000;
|
|
43
|
+
const MAX_POLL_MS = 60_000;
|
|
44
|
+
export async function verify(options = {}) {
|
|
45
|
+
const say = options.write ?? ((line) => process.stdout.write(`${line}\n`));
|
|
46
|
+
const quiet = options.json === true;
|
|
47
|
+
const human = (line) => {
|
|
48
|
+
if (!quiet)
|
|
49
|
+
say(line);
|
|
50
|
+
};
|
|
51
|
+
const apiKey = requireApiKey();
|
|
52
|
+
const stored = readCredentialsFile();
|
|
53
|
+
const server = resolveServer(options.server ?? stored?.server);
|
|
54
|
+
// ⛔ THE QUESTION IS ASKED BEFORE ANY CODE IS MADE, and that ordering is the point. Minting is
|
|
55
|
+
// not free: it spends a small daily allowance, it replaces whatever code was outstanding, and
|
|
56
|
+
// above all it interrupts a person. An agent that runs this command whenever a request was
|
|
57
|
+
// refused would otherwise fetch a human being for an account that is already verified.
|
|
58
|
+
const standing = await ask(server, apiKey);
|
|
59
|
+
if (options.status === true) {
|
|
60
|
+
if (quiet) {
|
|
61
|
+
say(JSON.stringify({ verified: standing.verified, roundKey: standing.roundKey, verifiedUntil: standing.until?.iso ?? null }));
|
|
62
|
+
return 0;
|
|
63
|
+
}
|
|
64
|
+
sayStanding(say, standing);
|
|
65
|
+
return 0;
|
|
66
|
+
}
|
|
67
|
+
if (standing.verified) {
|
|
68
|
+
if (quiet) {
|
|
69
|
+
sayResult(say, standing, false);
|
|
70
|
+
return 0;
|
|
71
|
+
}
|
|
72
|
+
say(`Already verified${standing.until === null ? "" : ` until ${standing.until.iso} (${inWords(standing.until.ms - Date.now())})`}.`);
|
|
73
|
+
say(``);
|
|
74
|
+
say(` No code was made and nobody has to be interrupted. Run this again after that`);
|
|
75
|
+
say(` moment to renew the check.`);
|
|
76
|
+
return 0;
|
|
77
|
+
}
|
|
78
|
+
// ⛔ THE ADDRESS IS WRITTEN OUT AT EVERY CALL RATHER THAN HELD IN A CONSTANT, and that is the
|
|
79
|
+
// opposite of the usual advice for a reason: the gate that checks this tool's addresses
|
|
80
|
+
// against the server's own routes reads the literal in the call. A constant here compiles,
|
|
81
|
+
// reads better, and makes that check silently stop looking at this command — which is worse
|
|
82
|
+
// than the repetition. The two halves of this share one address, told apart by the method.
|
|
83
|
+
const minted = readMinted(await request(server, "/v1/agent/verify", { method: "POST", token: apiKey, body: {} }));
|
|
84
|
+
if (quiet) {
|
|
85
|
+
say(JSON.stringify({ event: "code", code: minted.code, verifyUrl: minted.verifyUrl, expiresAt: minted.expiresAt.iso, pollAfterSecs: Math.round(minted.pollMs / 1000) }));
|
|
86
|
+
}
|
|
87
|
+
// ⚠ ADDRESSED TO WHOEVER IS RUNNING THIS, WHICH IS OFTEN A PROGRAM. It says what has to happen
|
|
88
|
+
// and who has to do it, and it does not pretend that the tool has any way to do it instead.
|
|
89
|
+
human(`A person has to finish this at a browser. Nothing here can do it for them — what`);
|
|
90
|
+
human(`is being checked is that somebody is there.`);
|
|
91
|
+
human(``);
|
|
92
|
+
human(` Ask the person to open ${minted.verifyUrl}`);
|
|
93
|
+
human(` and to type this code ${minted.code}`);
|
|
94
|
+
human(``);
|
|
95
|
+
human(`This is not the account code. It works once, and it stops working at`);
|
|
96
|
+
human(`${minted.expiresAt.iso} (${inWords(minted.expiresAt.ms - Date.now())}).`);
|
|
97
|
+
human(``);
|
|
98
|
+
human(`Waiting here until it is used. Ctrl-C stops the waiting, not the code.`);
|
|
99
|
+
const wait = options.sleep ?? waitFor;
|
|
100
|
+
const controller = new AbortController();
|
|
101
|
+
const stop = () => controller.abort();
|
|
102
|
+
// ⛔ THE INTERRUPT KEY IS CAUGHT RATHER THAN LEFT TO END THE PROCESS. This command is the one
|
|
103
|
+
// that waits, so it is the one where somebody presses Ctrl-C — and what they get for it
|
|
104
|
+
// should be the tool's own cancelled code and a line saying the code they were given is still
|
|
105
|
+
// good, not a killed process and no explanation. The handler is removed again below: a
|
|
106
|
+
// listener that outlives the wait would swallow the next interrupt too.
|
|
107
|
+
process.on("SIGINT", stop);
|
|
108
|
+
if (options.signal !== undefined) {
|
|
109
|
+
if (options.signal.aborted)
|
|
110
|
+
controller.abort();
|
|
111
|
+
else
|
|
112
|
+
options.signal.addEventListener("abort", stop, { once: true });
|
|
113
|
+
}
|
|
114
|
+
try {
|
|
115
|
+
while (Date.now() < minted.expiresAt.ms) {
|
|
116
|
+
// Never sleep past the moment the code dies: the last ask belongs to the person who typed
|
|
117
|
+
// it with seconds to spare.
|
|
118
|
+
const left = minted.expiresAt.ms - Date.now();
|
|
119
|
+
await wait(Math.min(minted.pollMs, Math.max(left, 0)), controller.signal);
|
|
120
|
+
stopIfCancelled(controller.signal, minted.expiresAt);
|
|
121
|
+
const now = await askDuring(server, apiKey, controller.signal, minted.expiresAt);
|
|
122
|
+
if (now.verified) {
|
|
123
|
+
if (quiet) {
|
|
124
|
+
sayResult(say, now, false);
|
|
125
|
+
}
|
|
126
|
+
else {
|
|
127
|
+
say(``);
|
|
128
|
+
sayVerified(say, now);
|
|
129
|
+
}
|
|
130
|
+
return 0;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
finally {
|
|
135
|
+
process.off("SIGINT", stop);
|
|
136
|
+
// The caller's signal is the caller's: nothing of ours stays attached to it after this returns.
|
|
137
|
+
options.signal?.removeEventListener("abort", stop);
|
|
138
|
+
}
|
|
139
|
+
if (quiet)
|
|
140
|
+
sayResult(say, standing, true);
|
|
141
|
+
throw new NmtsError(`The code stopped working before anybody used it.`, {
|
|
142
|
+
exitCode: 1,
|
|
143
|
+
nextStep: `Nothing was verified and nothing was spent. Run \`${BINARY_NAME} verify\` again when the ` +
|
|
144
|
+
`person is ready to open the page and type the code it prints.`,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
/** ⛔ ONE PLACE DECIDES WHAT CANCELLING MEANS, whichever await was interrupted. */
|
|
148
|
+
function stopIfCancelled(signal, expiresAt) {
|
|
149
|
+
if (!signal.aborted)
|
|
150
|
+
return;
|
|
151
|
+
throw new NmtsError(`Cancelled.`, {
|
|
152
|
+
exitCode: 130,
|
|
153
|
+
nextStep: `The code is still good until ${expiresAt.iso}, so a person who types it after this still ` +
|
|
154
|
+
`passes the check. \`${BINARY_NAME} verify --status\` says whether they did.`,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
/** Ask the server about the standing check. */
|
|
158
|
+
async function ask(server, apiKey) {
|
|
159
|
+
return readStanding(await request(server, "/v1/agent/verify", { token: apiKey }));
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* The same ask, made while a wait can be cancelled.
|
|
163
|
+
*
|
|
164
|
+
* ⛔ AN ABORTED REQUEST IS NOT A BROKEN SERVER. Without this, pressing Ctrl-C mid-request surfaces
|
|
165
|
+
* as "the server did not answer" — a message that sends a reader to look at the network for a
|
|
166
|
+
* thing they did themselves.
|
|
167
|
+
*/
|
|
168
|
+
async function askDuring(server, apiKey, signal, expiresAt) {
|
|
169
|
+
try {
|
|
170
|
+
return readStanding(await request(server, "/v1/agent/verify", { token: apiKey, signal }));
|
|
171
|
+
}
|
|
172
|
+
catch (error) {
|
|
173
|
+
stopIfCancelled(signal, expiresAt);
|
|
174
|
+
throw error;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
function sayStanding(say, standing) {
|
|
178
|
+
if (standing.verified) {
|
|
179
|
+
sayVerified(say, standing);
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
say(`Not verified.`);
|
|
183
|
+
say(``);
|
|
184
|
+
say(` Until somebody passes the check, this account's key works under tighter limits and`);
|
|
185
|
+
say(` some requests are refused outright.`);
|
|
186
|
+
say(``);
|
|
187
|
+
say(` \`${BINARY_NAME} verify\` prints a short code for a person to type at a browser.`);
|
|
188
|
+
say(` Nothing here can pass the check without them.`);
|
|
189
|
+
}
|
|
190
|
+
function sayVerified(say, standing) {
|
|
191
|
+
const week = standing.roundKey === null ? `` : ` Week ${standing.roundKey}.`;
|
|
192
|
+
if (standing.until === null) {
|
|
193
|
+
say(`Verified.${week}`);
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
say(`Verified until ${standing.until.iso} (${inWords(standing.until.ms - Date.now())}).${week}`);
|
|
197
|
+
say(``);
|
|
198
|
+
say(` That moment is a boundary of the server's own weeks, not a fixed span from now, so`);
|
|
199
|
+
say(` a check passed just before one ends is a short one.`);
|
|
200
|
+
}
|
|
201
|
+
/** The machine-readable end of a run that waited. `codeExpired` says which of the two ways it ended. */
|
|
202
|
+
function sayResult(say, standing, codeExpired) {
|
|
203
|
+
say(JSON.stringify({
|
|
204
|
+
event: "result",
|
|
205
|
+
verified: standing.verified,
|
|
206
|
+
roundKey: standing.roundKey,
|
|
207
|
+
verifiedUntil: standing.until?.iso ?? null,
|
|
208
|
+
codeExpired,
|
|
209
|
+
}));
|
|
210
|
+
}
|
|
211
|
+
/** One field of a server answer, or null when it is absent or the wrong type. */
|
|
212
|
+
function stringField(source, field) {
|
|
213
|
+
if (typeof source !== "object" || source === null)
|
|
214
|
+
return null;
|
|
215
|
+
const value = Reflect.get(source, field);
|
|
216
|
+
return typeof value === "string" && value.length > 0 ? value : null;
|
|
217
|
+
}
|
|
218
|
+
function numberField(source, field) {
|
|
219
|
+
if (typeof source !== "object" || source === null)
|
|
220
|
+
return null;
|
|
221
|
+
const value = Reflect.get(source, field);
|
|
222
|
+
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
223
|
+
}
|
|
224
|
+
function booleanField(source, field) {
|
|
225
|
+
if (typeof source !== "object" || source === null)
|
|
226
|
+
return null;
|
|
227
|
+
const value = Reflect.get(source, field);
|
|
228
|
+
return typeof value === "boolean" ? value : null;
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* An instant, normalised to UTC.
|
|
232
|
+
*
|
|
233
|
+
* ⛔ UTC AND NOTHING ELSE. The window is decided in the server's calendar; printing a local time
|
|
234
|
+
* would make two machines in different places disagree about when the same moment is.
|
|
235
|
+
*/
|
|
236
|
+
function momentOf(raw) {
|
|
237
|
+
if (raw === null)
|
|
238
|
+
return null;
|
|
239
|
+
const ms = Date.parse(raw);
|
|
240
|
+
if (Number.isNaN(ms))
|
|
241
|
+
return null;
|
|
242
|
+
return { iso: new Date(ms).toISOString().replace(/\.\d{3}Z$/, "Z"), ms };
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* The address a person will be told to open — refused unless it is one a browser can trust.
|
|
246
|
+
*
|
|
247
|
+
* ⛔ THIS IS THE ONE PLACE THIS TOOL ASKS A HUMAN BEING TO GO SOMEWHERE. Everything else it prints
|
|
248
|
+
* is about files. The address arrives from whatever server `--server` names, so a run pointed
|
|
249
|
+
* at the wrong host by a stale config or a helpful agent could hand somebody a link to type a
|
|
250
|
+
* code into — and the person reading it has no way to tell it apart from ours. `https` is the
|
|
251
|
+
* floor; loopback over plain http is allowed because that is the development stack and it is
|
|
252
|
+
* not somewhere a stranger can stand.
|
|
253
|
+
*/
|
|
254
|
+
function trustedUrl(raw) {
|
|
255
|
+
if (raw === null)
|
|
256
|
+
return null;
|
|
257
|
+
let parsed;
|
|
258
|
+
try {
|
|
259
|
+
parsed = new URL(raw);
|
|
260
|
+
}
|
|
261
|
+
catch {
|
|
262
|
+
return null;
|
|
263
|
+
}
|
|
264
|
+
if (parsed.protocol === "https:")
|
|
265
|
+
return raw;
|
|
266
|
+
const loopback = parsed.hostname === "127.0.0.1" || parsed.hostname === "localhost" || parsed.hostname === "[::1]";
|
|
267
|
+
return parsed.protocol === "http:" && loopback ? raw : null;
|
|
268
|
+
}
|
|
269
|
+
function readMinted(answer) {
|
|
270
|
+
const code = stringField(answer, "code");
|
|
271
|
+
const verifyUrl = trustedUrl(stringField(answer, "verify_url"));
|
|
272
|
+
const expiresAt = momentOf(stringField(answer, "expires_at"));
|
|
273
|
+
if (code === null || verifyUrl === null || expiresAt === null) {
|
|
274
|
+
throw brokenAnswer("a code, an address a browser can trust to type it at, and when it stops working");
|
|
275
|
+
}
|
|
276
|
+
// ⛔ A MISSING INTERVAL IS NOT A REASON TO REFUSE — there is a sane one here. A missing DEADLINE
|
|
277
|
+
// is, because without it the waiting has no end and a hang is worse than a failure.
|
|
278
|
+
const seconds = numberField(answer, "poll_after_secs") ?? DEFAULT_POLL_SECS;
|
|
279
|
+
const pollMs = Math.min(Math.max(seconds * 1000, MIN_POLL_MS), MAX_POLL_MS);
|
|
280
|
+
return { code, verifyUrl, expiresAt, pollMs };
|
|
281
|
+
}
|
|
282
|
+
function readStanding(answer) {
|
|
283
|
+
const verified = booleanField(answer, "verified");
|
|
284
|
+
if (verified === null)
|
|
285
|
+
throw brokenAnswer("whether this account is verified");
|
|
286
|
+
return {
|
|
287
|
+
verified,
|
|
288
|
+
roundKey: stringField(answer, "round_key"),
|
|
289
|
+
until: momentOf(stringField(answer, "verified_until")),
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
/** ⚠ The answer's own contents are never quoted back: it came from the network, and it goes in a log. */
|
|
293
|
+
function brokenAnswer(wanted) {
|
|
294
|
+
return new NmtsError(`The server's answer did not carry ${wanted}.`, {
|
|
295
|
+
exitCode: 1,
|
|
296
|
+
nextStep: `This version of \`${BINARY_NAME}\` and that server do not agree about this. Update the tool, or check --server.`,
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* A gap in words, coarse on purpose.
|
|
301
|
+
*
|
|
302
|
+
* ⚠ Rounded, because the number beside it is exact. The absolute moment is what somebody acts on;
|
|
303
|
+
* this is only there so nobody has to subtract two timestamps in their head.
|
|
304
|
+
*/
|
|
305
|
+
function inWords(ms) {
|
|
306
|
+
if (ms <= 0)
|
|
307
|
+
return "already past";
|
|
308
|
+
const minutes = Math.round(ms / 60_000);
|
|
309
|
+
if (minutes < 1)
|
|
310
|
+
return "in under a minute";
|
|
311
|
+
if (minutes < 60)
|
|
312
|
+
return `in ${minutes} minute${minutes === 1 ? "" : "s"}`;
|
|
313
|
+
const hours = Math.round(ms / 3_600_000);
|
|
314
|
+
if (hours < 48)
|
|
315
|
+
return `in ${hours} hour${hours === 1 ? "" : "s"}`;
|
|
316
|
+
return `in ${Math.round(ms / 86_400_000)} days`;
|
|
317
|
+
}
|
|
318
|
+
/** The default wait: a timer that gives up quietly the moment the run is cancelled. */
|
|
319
|
+
function waitFor(ms, signal) {
|
|
320
|
+
if (signal.aborted)
|
|
321
|
+
return Promise.resolve();
|
|
322
|
+
return new Promise((resolve) => {
|
|
323
|
+
const done = () => {
|
|
324
|
+
clearTimeout(timer);
|
|
325
|
+
signal.removeEventListener("abort", done);
|
|
326
|
+
resolve();
|
|
327
|
+
};
|
|
328
|
+
const timer = setTimeout(done, ms);
|
|
329
|
+
signal.addEventListener("abort", done, { once: true });
|
|
330
|
+
});
|
|
331
|
+
}
|