@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,288 @@
|
|
|
1
|
+
// `nmts rm` and `nmts restore` — the two halves of the trash.
|
|
2
|
+
//
|
|
3
|
+
// ⛔ NEITHER OF THESE DESTROYS ANYTHING. `rm` moves everything it is given to the trash, where it
|
|
4
|
+
// stays restorable for thirty days; the endpoint that erases a stored row for good is closed to
|
|
5
|
+
// an API key and stays closed, so no command here can reach it.
|
|
6
|
+
//
|
|
7
|
+
// ⛔ BOTH TAKE MANY PATHS AND WRITE THE LIST ONCE. Everything named is trashed together or
|
|
8
|
+
// nothing is: a run that wrote once per path would be one lost compare-and-swap away from a
|
|
9
|
+
// drive with half of the deletion in it, and the message would still say the whole run had
|
|
10
|
+
// happened. The server rows still go one at a time — there is no batch endpoint — which is
|
|
11
|
+
// why a run that stops half way names how far it got.
|
|
12
|
+
//
|
|
13
|
+
// ⚠ ONE VERB IN THIS TOOL DOES HAVE NO UNDO, AND IT IS DELIBERATELY NOT THESE TWO. `nmts sweep`
|
|
14
|
+
// drops list entries whose thirty days have already run out, which destroys this account's copy
|
|
15
|
+
// of the key for files the server has already destroyed its own copy of. It is a separate
|
|
16
|
+
// command, it stops for an answer on every run rather than once per machine, and it can never
|
|
17
|
+
// touch anything still inside the window these two work on. The sentence that used to sit here
|
|
18
|
+
// said no verb without an undo would ever live in this tool; that stopped being true the day
|
|
19
|
+
// the sweep arrived, and a header that goes on describing the world before a change is the way
|
|
20
|
+
// the next reader is misled.
|
|
21
|
+
//
|
|
22
|
+
// ⛔ THE SERVER ROW GOES FIRST, AND "ALREADY DONE" COUNTS AS DONE. The order is not arbitrary and
|
|
23
|
+
// neither is the forgiveness:
|
|
24
|
+
// · A trashed item's bytes cannot be fetched — `GET /v1/items/{id}/parts` requires
|
|
25
|
+
// `deleted_at IS NULL`. So the state to avoid above all others is a drive that shows a file
|
|
26
|
+
// as live when the server has already trashed it: the person sees it, asks for it, and is
|
|
27
|
+
// told it does not exist.
|
|
28
|
+
// · Writing the list only after the server agreed means a failed server call leaves the
|
|
29
|
+
// drive exactly as it was, which is the state a person can act on.
|
|
30
|
+
// · And a 404 from the server means the row is already in the state being asked for, which
|
|
31
|
+
// is what an interrupted run leaves behind. Treating it as a failure would make the retry
|
|
32
|
+
// of a half-finished command impossible — the one moment the retry is needed.
|
|
33
|
+
import { setTrashed } from "../item-trash.js";
|
|
34
|
+
import { buildIndex, fullPathOf, isLive, KIND_FILE } from "../drive-paths.js";
|
|
35
|
+
import { NmtsError } from "../errors.js";
|
|
36
|
+
import { readFileList } from "../manifest.js";
|
|
37
|
+
import { applyManyToList, batchTargets } from "../manifest-write.js";
|
|
38
|
+
import { openSession } from "../session.js";
|
|
39
|
+
import { applyIntent } from "../shared/lib/drive/manifest-ops.js";
|
|
40
|
+
export async function rm(paths, options = {}) {
|
|
41
|
+
return run("rm", paths, options);
|
|
42
|
+
}
|
|
43
|
+
export async function restore(paths, options = {}) {
|
|
44
|
+
return run("restore", paths, options);
|
|
45
|
+
}
|
|
46
|
+
async function run(verb, paths, options) {
|
|
47
|
+
const say = options.write ?? ((line) => process.stdout.write(`${line}\n`));
|
|
48
|
+
if (paths.length === 0) {
|
|
49
|
+
throw new NmtsError(`\`nmts ${verb}\` needs the path of at least one thing in the drive.`, {
|
|
50
|
+
exitCode: 2,
|
|
51
|
+
nextStep: `\`nmts ls${verb === "restore" ? " --all" : ""}\` prints the paths as this expects them.`,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
const session = await openSession(options);
|
|
55
|
+
const list = await readFileList(session.server, session.apiKey, session.code, session.accountId);
|
|
56
|
+
const entries = list.manifest?.entries ?? [];
|
|
57
|
+
// ⛔ `rm` REFUSES what is already in the trash rather than quietly doing nothing, so the caller
|
|
58
|
+
// learns nothing was needed; `restore` has to be able to SEE the trash to act on it. That is
|
|
59
|
+
// why the two lookups differ. (The comment that used to sit here claimed a shared lookup would
|
|
60
|
+
// make `rm` say "no such path" — it would not: the refusal below names the trash either way.
|
|
61
|
+
// A reason that does not hold teaches the next reader to keep the wrong branch.)
|
|
62
|
+
//
|
|
63
|
+
// ⛔ AND ONE PATH THAT WILL NOT RESOLVE REFUSES THE WHOLE RUN, before a single server row is
|
|
64
|
+
// touched. Trashing four of the five things somebody named and exiting 0 is worse than
|
|
65
|
+
// trashing none: the run reads as done, and finding the odd one out means diffing the drive.
|
|
66
|
+
const index = buildIndex(entries);
|
|
67
|
+
const found = batchTargets(entries, paths, {
|
|
68
|
+
...(verb === "restore" ? { includeTrashed: true } : {}),
|
|
69
|
+
nothingHappened: "Nothing changed.",
|
|
70
|
+
});
|
|
71
|
+
const acting = [];
|
|
72
|
+
const skipped = [];
|
|
73
|
+
for (const entry of found) {
|
|
74
|
+
const at = fullPathOf(index, entry);
|
|
75
|
+
if (verb === "restore" && isLive(index, entry)) {
|
|
76
|
+
// Already in the state being asked for. Not a refusal — named, and left alone.
|
|
77
|
+
skipped.push(at);
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
if (verb === "restore" && entry.deletedAt === undefined) {
|
|
81
|
+
// In the trash, but only because something above it is. Restoring this row would clear a
|
|
82
|
+
// `deletedAt` it does not have and leave the person exactly where they were.
|
|
83
|
+
throw new NmtsError(`"${at}" is in the trash because a folder above it is.`, {
|
|
84
|
+
exitCode: 4,
|
|
85
|
+
nextStep: `Nothing changed. Restore that folder instead — \`nmts ls --all\` shows which one carries the trash.`,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
acting.push({ entry, path: at });
|
|
89
|
+
}
|
|
90
|
+
// ⛔ NAMING A FOLDER AND SOMETHING INSIDE IT IS NAMING ONE TRASHING TWICE, and only for `rm` is
|
|
91
|
+
// that a problem worth solving here: stamping the child as well would give it a thirty-day
|
|
92
|
+
// clock of its own, and then restoring the folder would leave it behind — the person would
|
|
93
|
+
// have to remember they had also named it to ever find it again. Its bytes are covered
|
|
94
|
+
// either way, because the rows are read from the folder. `restore` is the opposite case: a
|
|
95
|
+
// child with its own instant needs its own clearing, so nothing is dropped there.
|
|
96
|
+
const named = new Set(acting.map((t) => t.entry.id));
|
|
97
|
+
const covered = verb === "rm" ? acting.filter((t) => hasNamedAncestor(entries, t.entry, named)) : [];
|
|
98
|
+
const targets = acting.filter((t) => !covered.includes(t));
|
|
99
|
+
for (const t of covered)
|
|
100
|
+
skipped.push(t.path);
|
|
101
|
+
if (targets.length === 0) {
|
|
102
|
+
// Everything named was already where it was asked to be. A no-op is a success: writing the
|
|
103
|
+
// list would cost every other device a download for nothing.
|
|
104
|
+
return nothingToDo(say, options, verb, skipped, list.seq ?? 0);
|
|
105
|
+
}
|
|
106
|
+
// Every FILE at or under the targets — a folder holds no bytes and has no server row, so the
|
|
107
|
+
// rows to move are its file descendants.
|
|
108
|
+
//
|
|
109
|
+
// ⛔ THE ROWS TO MOVE ARE THE ONES THE EDIT WILL MAKE REACHABLE, so the set is read off a
|
|
110
|
+
// PREVIEW of the list rather than guessed (2026-08-23). `rm` is easy — everything
|
|
111
|
+
// under the target loses its bytes. `restore` is not: a file the person deleted separately
|
|
112
|
+
// last week keeps its own `deletedAt`, stays in the trash after the folder comes back, and
|
|
113
|
+
// its row must stay deleted with it. Restoring that row would cancel its own thirty-day
|
|
114
|
+
// sweep, go on costing storage, and leave the list saying "trashed" while the server says
|
|
115
|
+
// "live" — after which `rm` refuses to put it back and the tool has no way out.
|
|
116
|
+
const at = Date.now();
|
|
117
|
+
const ids = targets.map((t) => t.entry.id);
|
|
118
|
+
const preview = buildIndex(applyIntent(entries, intentFor(verb, ids, at)));
|
|
119
|
+
const under = uniqueById(targets.flatMap((t) => filesUnder(entries, t.entry.id)));
|
|
120
|
+
// ⚠ Judged on the PREVIEW's own row, not on the one in hand: `applyIntent` returns new objects,
|
|
121
|
+
// so asking the preview about the old object reads the old `deletedAt` and answers "still
|
|
122
|
+
// trashed" for the very thing being restored.
|
|
123
|
+
const files = verb === "rm"
|
|
124
|
+
? under
|
|
125
|
+
: under.filter((f) => {
|
|
126
|
+
const after = preview.byId.get(f.id);
|
|
127
|
+
return after !== undefined && isLive(preview, after);
|
|
128
|
+
});
|
|
129
|
+
let done = 0;
|
|
130
|
+
try {
|
|
131
|
+
for (const file of files) {
|
|
132
|
+
await tellServer(session.server, session.apiKey, verb, file.id);
|
|
133
|
+
done += 1;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
catch (error) {
|
|
137
|
+
// ⛔ A HALF-FINISHED RUN MUST NAME ITSELF. Without this the agent sees six words of stderr and
|
|
138
|
+
// the tool's own guidance ("a refusal is not a transient error, do not retry in a loop")
|
|
139
|
+
// steers it away from the one thing that fixes this — running the same command again.
|
|
140
|
+
const because = error instanceof Error ? error.message : "the server refused";
|
|
141
|
+
throw new NmtsError(because, {
|
|
142
|
+
exitCode: 1,
|
|
143
|
+
nextStep: `${done} of ${files.length} file rows were moved before this stopped, and the file list was ` +
|
|
144
|
+
`not written. Running \`nmts ${verb}\` on the same paths again finishes the job — nothing is lost.`,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
// ⛔ THE IDS ARE DECIDED AGAIN ON EVERY ATTEMPT, and this is not ceremony. Between the read
|
|
148
|
+
// above and the write below another device can put one of these targets in the trash — by
|
|
149
|
+
// trashing it, or by moving it under a folder that already is. Re-applying the intent we
|
|
150
|
+
// built earlier would then stamp `deletedAt` on something that is ALREADY in the trash by
|
|
151
|
+
// inheritance, giving it a clock of its own and quietly detaching it from the folder it came
|
|
152
|
+
// with: restoring that folder afterwards would leave it behind. An id that has left the list
|
|
153
|
+
// entirely is dropped for the reason `manifest-ops.ts` gives — the other device removing it
|
|
154
|
+
// is newer information than our edit, and putting it back would undo a deletion somebody
|
|
155
|
+
// made on purpose.
|
|
156
|
+
const result = await applyManyToList(session, (now) => {
|
|
157
|
+
const nowIndex = buildIndex(now);
|
|
158
|
+
const still = ids.filter((id) => {
|
|
159
|
+
const live = nowIndex.byId.get(id);
|
|
160
|
+
if (live === undefined)
|
|
161
|
+
return false;
|
|
162
|
+
return verb === "rm" ? isLive(nowIndex, live) : live.deletedAt !== undefined;
|
|
163
|
+
});
|
|
164
|
+
return still.length === 0 ? [] : [intentFor(verb, still, at)];
|
|
165
|
+
});
|
|
166
|
+
const shown = targets.map((t) => t.path);
|
|
167
|
+
if (options.json) {
|
|
168
|
+
say(JSON.stringify({
|
|
169
|
+
paths: shown,
|
|
170
|
+
ids,
|
|
171
|
+
files: files.length,
|
|
172
|
+
// Named, and nothing was written for them: already out of the trash, or already covered
|
|
173
|
+
// by a folder in the same run.
|
|
174
|
+
skipped,
|
|
175
|
+
changed: result.changed,
|
|
176
|
+
// ⚠ The one signal that another writer intervened. It was printed in prose and left out of
|
|
177
|
+
// the JSON, which is the half an agent is told to read (2026-08-23).
|
|
178
|
+
reappliedAfterConflict: result.reappliedAfterConflict,
|
|
179
|
+
seq: result.seq,
|
|
180
|
+
}));
|
|
181
|
+
return 0;
|
|
182
|
+
}
|
|
183
|
+
const moved = files.length === 1 ? "1 file" : `${files.length} files`;
|
|
184
|
+
const names = shown.map((s) => `"${s}"`).join(", ");
|
|
185
|
+
say(verb === "rm"
|
|
186
|
+
? `Moved ${names} to the trash (${moved}). ${shown.length === 1 ? "It" : "They"} can be restored for 30 days.`
|
|
187
|
+
: `Restored ${names} (${moved}).`);
|
|
188
|
+
if (skipped.length > 0)
|
|
189
|
+
say(` ${skippedLine(verb, skipped)}`);
|
|
190
|
+
if (result.reappliedAfterConflict) {
|
|
191
|
+
say(` Another device wrote the file list first, so this was applied to that version.`);
|
|
192
|
+
}
|
|
193
|
+
return 0;
|
|
194
|
+
}
|
|
195
|
+
/** The one intent either verb writes. Built in two places, so it is spelled in one. */
|
|
196
|
+
function intentFor(verb, ids, at) {
|
|
197
|
+
return verb === "rm" ? { op: "trash", ids, at } : { op: "restore", ids, at };
|
|
198
|
+
}
|
|
199
|
+
/** What was named but not acted on, in the words that say why. */
|
|
200
|
+
function skippedLine(verb, skipped) {
|
|
201
|
+
const names = skipped.map((s) => `"${s}"`).join(", ");
|
|
202
|
+
return verb === "rm"
|
|
203
|
+
? `${names} is inside something else that was named, so it goes with it.`
|
|
204
|
+
: `${names} was not in the trash.`;
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* The run had nothing to do. Says so in whichever form the caller asked for.
|
|
208
|
+
*
|
|
209
|
+
* ⚠ In practice this is the `restore` case — everything named was already out of the trash.
|
|
210
|
+
* `rm` reaches it only if every path it resolved was inside another path it resolved, which
|
|
211
|
+
* cannot happen while the outermost one is always kept.
|
|
212
|
+
*/
|
|
213
|
+
function nothingToDo(say, options, verb, skipped, seq) {
|
|
214
|
+
if (options.json) {
|
|
215
|
+
say(JSON.stringify({ paths: [], ids: [], files: 0, skipped, changed: false, reappliedAfterConflict: false, seq }));
|
|
216
|
+
return 0;
|
|
217
|
+
}
|
|
218
|
+
const names = skipped.map((s) => `"${s}"`).join(", ");
|
|
219
|
+
say(verb === "restore"
|
|
220
|
+
? `${names} ${skipped.length === 1 ? "is" : "are"} not in the trash. Nothing changed.`
|
|
221
|
+
: `Nothing was left to move to the trash. Nothing changed.`);
|
|
222
|
+
return 0;
|
|
223
|
+
}
|
|
224
|
+
/** Is any ancestor of this entry in the set? Used to drop a target a named folder already covers. */
|
|
225
|
+
function hasNamedAncestor(entries, entry, named) {
|
|
226
|
+
const byId = buildIndex(entries).byId;
|
|
227
|
+
const seen = new Set([entry.id]);
|
|
228
|
+
let at = entry.parentId;
|
|
229
|
+
while (at !== null && !seen.has(at)) {
|
|
230
|
+
if (named.has(at))
|
|
231
|
+
return true;
|
|
232
|
+
seen.add(at);
|
|
233
|
+
at = byId.get(at)?.parentId ?? null;
|
|
234
|
+
}
|
|
235
|
+
return false;
|
|
236
|
+
}
|
|
237
|
+
/** One entry per id, keeping the first. Two named folders can hold the same file only once. */
|
|
238
|
+
function uniqueById(files) {
|
|
239
|
+
const byId = new Map();
|
|
240
|
+
for (const file of files)
|
|
241
|
+
if (!byId.has(file.id))
|
|
242
|
+
byId.set(file.id, file);
|
|
243
|
+
return [...byId.values()];
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Every file at or under one entry.
|
|
247
|
+
*
|
|
248
|
+
* ⚠ Trashed descendants are INCLUDED HERE, and the CALLER filters. Somebody who trashed one file
|
|
249
|
+
* last week and then trashes its folder expects the folder to be gone from the server too — so
|
|
250
|
+
* `rm` takes this set whole. `restore` cannot: see the note at the call site.
|
|
251
|
+
*/
|
|
252
|
+
function filesUnder(entries, rootId) {
|
|
253
|
+
const root = entries.find((e) => e.id === rootId);
|
|
254
|
+
if (root === undefined)
|
|
255
|
+
return [];
|
|
256
|
+
if (root.kind === KIND_FILE)
|
|
257
|
+
return [root];
|
|
258
|
+
const childrenOf = new Map();
|
|
259
|
+
for (const e of entries) {
|
|
260
|
+
const list = childrenOf.get(e.parentId);
|
|
261
|
+
if (list === undefined)
|
|
262
|
+
childrenOf.set(e.parentId, [e]);
|
|
263
|
+
else
|
|
264
|
+
list.push(e);
|
|
265
|
+
}
|
|
266
|
+
const out = [];
|
|
267
|
+
const seen = new Set([rootId]);
|
|
268
|
+
const queue = [rootId];
|
|
269
|
+
while (queue.length > 0) {
|
|
270
|
+
const id = queue.pop();
|
|
271
|
+
if (id === undefined)
|
|
272
|
+
break;
|
|
273
|
+
for (const child of childrenOf.get(id) ?? []) {
|
|
274
|
+
if (seen.has(child.id))
|
|
275
|
+
continue;
|
|
276
|
+
seen.add(child.id);
|
|
277
|
+
if (child.kind === KIND_FILE)
|
|
278
|
+
out.push(child);
|
|
279
|
+
else
|
|
280
|
+
queue.push(child.id);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
return out;
|
|
284
|
+
}
|
|
285
|
+
/** ⛔ 404 is "already in the state you asked for", which is what a half-finished run leaves. */
|
|
286
|
+
async function tellServer(base, apiKey, verb, id) {
|
|
287
|
+
await setTrashed(base, apiKey, id, verb === "rm");
|
|
288
|
+
}
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
// `nmts trial` — this week's free trial: what is left of it, and asking for a place.
|
|
2
|
+
//
|
|
3
|
+
// ⛔ THE RULES ARE THE SERVER'S AND THIS COMMAND ONLY REPORTS THEM. One application per account
|
|
4
|
+
// per ISO week, first come first served against a budget the server decides weekly. There is
|
|
5
|
+
// no flag here that asks for more, no retry loop that waits for a place to open, and no way to
|
|
6
|
+
// name an amount — a place is what the week says it is. Every refusal below is passed on as
|
|
7
|
+
// the server's answer with the remedy named, never worked around.
|
|
8
|
+
//
|
|
9
|
+
// ⛔ ASKING IS A SEPARATE WORD FROM LOOKING. `nmts trial` reads and takes nothing; `nmts trial
|
|
10
|
+
// apply` spends this account's one chance for the week. A command that applied because it was
|
|
11
|
+
// run would spend that chance for somebody who wanted to know how many places were left.
|
|
12
|
+
//
|
|
13
|
+
// ⛔ IT NEEDS A PERSON'S CHECK, AND SAYS SO INSTEAD OF REPORTING A BARE REFUSAL. Both routes are
|
|
14
|
+
// open to an API key only while somebody has passed the account's human check inside the last
|
|
15
|
+
// four of the server's weeks. The server decides scope before it looks at that check, so a
|
|
16
|
+
// caller can be told "the key was not given permission" when what is actually missing is a
|
|
17
|
+
// person — which sends an agent to make another key, the one thing that cannot help. So the
|
|
18
|
+
// standing check is asked about first, and the answer names `nmts verify`.
|
|
19
|
+
//
|
|
20
|
+
// ⛔ AND APPLYING ASKS FOR SOMETHING THIS TOOL CANNOT PRODUCE. `POST /v1/trial/apply` runs a
|
|
21
|
+
// fresh browser check of its own on every application — it is the one request in this API that
|
|
22
|
+
// hands out credits, so the check is made per application rather than once per session. A
|
|
23
|
+
// command line has no browser and no token, so on any deployment configured with that check
|
|
24
|
+
// the application is refused however good the credentials are. That is reported as what it is,
|
|
25
|
+
// with the place a person can apply from, rather than as a credential problem. `nmts verify`
|
|
26
|
+
// is a DIFFERENT check and does not stand in for this one.
|
|
27
|
+
import { request, ServerError } from "../api.js";
|
|
28
|
+
import { readCredentialsFile } from "../credentials.js";
|
|
29
|
+
import { NmtsError } from "../errors.js";
|
|
30
|
+
import { isRecord } from "../guards.js";
|
|
31
|
+
import { askAPersonToVerify, humanCheck } from "../human-check.js";
|
|
32
|
+
import { BINARY_NAME, HOME_URL } from "../product.js";
|
|
33
|
+
import { resolveServer } from "../server.js";
|
|
34
|
+
import { requireApiKey } from "../session.js";
|
|
35
|
+
import { humanSize } from "../units.js";
|
|
36
|
+
import { CREDIT_BYTES } from "../upload-price.js";
|
|
37
|
+
/** The one word this command takes, and what it costs. */
|
|
38
|
+
const APPLY = "apply";
|
|
39
|
+
export async function trial(action, options = {}) {
|
|
40
|
+
const say = options.write ?? ((line) => process.stdout.write(`${line}\n`));
|
|
41
|
+
if (action !== undefined && action !== "" && action !== APPLY) {
|
|
42
|
+
throw new NmtsError(`\`${BINARY_NAME} trial\` takes nothing, or \`${APPLY}\`.`, {
|
|
43
|
+
exitCode: 2,
|
|
44
|
+
nextStep: `\`${BINARY_NAME} trial\` says what is left of this week. \`${BINARY_NAME} trial ${APPLY}\` ` +
|
|
45
|
+
`asks for a place, which an account may do once a week.`,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
const apiKey = requireApiKey();
|
|
49
|
+
const stored = readCredentialsFile();
|
|
50
|
+
const server = resolveServer(options.server ?? stored?.server);
|
|
51
|
+
const check = await humanCheck(server, apiKey);
|
|
52
|
+
if (!check.live)
|
|
53
|
+
throw askAPersonToVerify("The free trial cannot be read or applied for");
|
|
54
|
+
const week = readWeek(await request(server, "/v1/trial", { token: apiKey }));
|
|
55
|
+
if (action !== APPLY) {
|
|
56
|
+
if (options.json === true) {
|
|
57
|
+
say(JSON.stringify(asJson(week)));
|
|
58
|
+
return 0;
|
|
59
|
+
}
|
|
60
|
+
sayWeek(say, week);
|
|
61
|
+
sayRules(say);
|
|
62
|
+
if (week.live && !week.already && !week.held && week.slotsLeft > 0) {
|
|
63
|
+
say(``);
|
|
64
|
+
say(` ${BINARY_NAME} trial ${APPLY}`);
|
|
65
|
+
}
|
|
66
|
+
return 0;
|
|
67
|
+
}
|
|
68
|
+
// ⛔ ALREADY IN IS NOT A FAILURE. The account holds the thing the command asks for, so the run
|
|
69
|
+
// ends at 0 — and it does not send the application, because the answer is already known and
|
|
70
|
+
// the server would spend a refusal saying so.
|
|
71
|
+
if (week.already) {
|
|
72
|
+
if (options.json === true) {
|
|
73
|
+
say(JSON.stringify({ event: "already", round: week.round }));
|
|
74
|
+
return 0;
|
|
75
|
+
}
|
|
76
|
+
say(`This account already took its place in week ${week.round}.`);
|
|
77
|
+
say(``);
|
|
78
|
+
say(` One application per account per week, so there is nothing more to ask for until the`);
|
|
79
|
+
say(` next one. \`${BINARY_NAME} balance\` says what the credits bought.`);
|
|
80
|
+
return 0;
|
|
81
|
+
}
|
|
82
|
+
const granted = await apply(server, apiKey, week);
|
|
83
|
+
if (options.json === true) {
|
|
84
|
+
say(JSON.stringify({ event: "granted", ...granted }));
|
|
85
|
+
return 0;
|
|
86
|
+
}
|
|
87
|
+
say(`Granted: ${granted.credits} credits, in week ${granted.round}.`);
|
|
88
|
+
say(``);
|
|
89
|
+
say(` They lapse unused at ${granted.expires_at}. One credit is ${humanSize(CREDIT_BYTES)} held`);
|
|
90
|
+
say(` for one lease period; \`${BINARY_NAME} balance\` says what is left of them.`);
|
|
91
|
+
return 0;
|
|
92
|
+
}
|
|
93
|
+
async function apply(server, apiKey, week) {
|
|
94
|
+
let answer;
|
|
95
|
+
try {
|
|
96
|
+
// ⚠ AN EMPTY BODY, AND NOT A MISSING ONE. The route reads one field — the browser check's
|
|
97
|
+
// token — and treats absent and blank alike; sending `{}` is what a tool that always sends
|
|
98
|
+
// JSON sends, and there is nothing else this end could put in it.
|
|
99
|
+
answer = await request(server, "/v1/trial/apply", { method: "POST", token: apiKey, body: {} });
|
|
100
|
+
}
|
|
101
|
+
catch (error) {
|
|
102
|
+
throw explain(error, week);
|
|
103
|
+
}
|
|
104
|
+
const credits = isRecord(answer) ? answer["credits"] : null;
|
|
105
|
+
const expires = isRecord(answer) ? answer["expires_at"] : null;
|
|
106
|
+
const round = isRecord(answer) ? answer["round"] : null;
|
|
107
|
+
if (typeof credits !== "number" ||
|
|
108
|
+
!Number.isFinite(credits) ||
|
|
109
|
+
typeof expires !== "string" ||
|
|
110
|
+
typeof round !== "string") {
|
|
111
|
+
throw new NmtsError("The server granted a place and described it in a shape this version cannot read.", {
|
|
112
|
+
exitCode: 1,
|
|
113
|
+
nextStep: `The credits are on the account whatever this printed — \`${BINARY_NAME} balance\` reads ` +
|
|
114
|
+
`the ledger. Update this tool.`,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
return { credits, expires_at: expires, round };
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Say what each refusal actually means, and what — if anything — changes it.
|
|
121
|
+
*
|
|
122
|
+
* ⛔ NONE OF THESE IS RETRIED HERE. Two of them (a full week, a lost race) are decided by other
|
|
123
|
+
* people's applications and a loop would only take the next place from whoever asked next; the
|
|
124
|
+
* rest are decided by a person or by the operator. An agent that reads "try again" retries;
|
|
125
|
+
* every message below says what would have to change instead.
|
|
126
|
+
*/
|
|
127
|
+
function explain(error, week) {
|
|
128
|
+
if (!(error instanceof ServerError))
|
|
129
|
+
return error;
|
|
130
|
+
switch (error.code) {
|
|
131
|
+
// ⚠ ONLY THE REFUSAL, NOT THE OUTAGE. `TURNSTILE_UNAVAILABLE` means the check's verifier
|
|
132
|
+
// could not be reached, which is a passing condition on the server's side and keeps
|
|
133
|
+
// `api.ts`'s ordinary handling — telling somebody to go to a browser would send them to a
|
|
134
|
+
// screen that is refusing for the same reason.
|
|
135
|
+
case "TURNSTILE_FAILED":
|
|
136
|
+
// ⛔ THE ADVICE `api.ts` CARRIES FOR THIS CODE IS THE WRONG ONE HERE, and that is why this
|
|
137
|
+
// case exists. It says an API key is what waives the check — true for signing in, and
|
|
138
|
+
// false for this route, which runs its own check on every application no matter what
|
|
139
|
+
// credential arrived. A caller told to go and make a key would make one and be refused
|
|
140
|
+
// again.
|
|
141
|
+
return new NmtsError("This server asks every application for a browser check, and a command line has none.", {
|
|
142
|
+
exitCode: 4,
|
|
143
|
+
nextStep: `Nothing was taken and this account's place for week ${week.round} is untouched. The ` +
|
|
144
|
+
`check is a puzzle solved in a browser, and it is asked for on every application ` +
|
|
145
|
+
`rather than once, because this is the one request that hands out credits. Apply on ` +
|
|
146
|
+
`the account screen at ` +
|
|
147
|
+
`${HOME_URL} instead. \`${BINARY_NAME} verify\` is a different check and does not stand ` +
|
|
148
|
+
`in for this one.`,
|
|
149
|
+
});
|
|
150
|
+
case "TRIAL_FULL":
|
|
151
|
+
return new NmtsError("Every place in this week is taken.", {
|
|
152
|
+
exitCode: 4,
|
|
153
|
+
nextStep: `Places are first come, first served, and the week's budget is decided by the server. ` +
|
|
154
|
+
`Nothing was taken. The next week's places appear when it turns.`,
|
|
155
|
+
});
|
|
156
|
+
case "TRIAL_ALREADY":
|
|
157
|
+
return new NmtsError(`This account already took its place in week ${week.round}.`, {
|
|
158
|
+
exitCode: 4,
|
|
159
|
+
nextStep: `One application per account per week. Nothing was taken.`,
|
|
160
|
+
});
|
|
161
|
+
case "TRIAL_CLOSED":
|
|
162
|
+
return new NmtsError("The free trial is not open on this server.", {
|
|
163
|
+
exitCode: 4,
|
|
164
|
+
nextStep: `Nothing was taken. Whether it runs at all is the operator's decision, not a setting here.`,
|
|
165
|
+
});
|
|
166
|
+
case "TRIAL_HELD":
|
|
167
|
+
return new NmtsError("Applications are paused.", {
|
|
168
|
+
exitCode: 4,
|
|
169
|
+
nextStep: `An unusually large wave of applications is being looked at, and they stay paused until ` +
|
|
170
|
+
`that is done. Nothing was taken, and this account's place for the week is untouched.`,
|
|
171
|
+
});
|
|
172
|
+
case "TRIAL_LINE_CAPPED":
|
|
173
|
+
return new NmtsError("This internet connection has taken all the places it can today.", {
|
|
174
|
+
exitCode: 4,
|
|
175
|
+
nextStep: `Nothing was taken. A day's share per connection is what stops one line sweeping a ` +
|
|
176
|
+
`week. Tomorrow it resets — though this week's places may be gone by then.`,
|
|
177
|
+
});
|
|
178
|
+
default:
|
|
179
|
+
return error;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
function readWeek(answer) {
|
|
183
|
+
if (!isRecord(answer))
|
|
184
|
+
throw brokenAnswer();
|
|
185
|
+
const live = answer["live"];
|
|
186
|
+
const round = answer["round"];
|
|
187
|
+
if (typeof live !== "boolean" || typeof round !== "string")
|
|
188
|
+
throw brokenAnswer();
|
|
189
|
+
return {
|
|
190
|
+
live,
|
|
191
|
+
round,
|
|
192
|
+
computed: answer["computed"] === true,
|
|
193
|
+
winners: whole(answer["winners"]),
|
|
194
|
+
creditsPerWinner: whole(answer["credits_per_winner"]),
|
|
195
|
+
slotsLeft: whole(answer["slots_left"]),
|
|
196
|
+
already: answer["already"] === true,
|
|
197
|
+
held: answer["held"] === true,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
/** A count the server sent, or 0. ⚠ 0 and "not decided" are told apart by `computed`, not by this. */
|
|
201
|
+
function whole(value) {
|
|
202
|
+
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
|
203
|
+
}
|
|
204
|
+
function brokenAnswer() {
|
|
205
|
+
return new NmtsError("The server did not describe this week's trial in a shape this version can read.", {
|
|
206
|
+
exitCode: 1,
|
|
207
|
+
nextStep: `Update this tool, or read the account screen at ${HOME_URL}.`,
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
function asJson(week) {
|
|
211
|
+
// The server's own spelling, so a reader that has seen one and then the other is not converting
|
|
212
|
+
// between two names for one number.
|
|
213
|
+
return {
|
|
214
|
+
live: week.live,
|
|
215
|
+
round: week.round,
|
|
216
|
+
computed: week.computed,
|
|
217
|
+
winners: week.winners,
|
|
218
|
+
credits_per_winner: week.creditsPerWinner,
|
|
219
|
+
slots_left: week.slotsLeft,
|
|
220
|
+
already: week.already,
|
|
221
|
+
held: week.held,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* The week, one fact per line.
|
|
226
|
+
*
|
|
227
|
+
* ⛔ "NOT DECIDED YET" IS NOT "NO PLACES". A week with no computed budget row answers zero for
|
|
228
|
+
* everything, and printing that as "0 places" would be this tool deciding something the server
|
|
229
|
+
* has not.
|
|
230
|
+
*/
|
|
231
|
+
function sayWeek(say, week) {
|
|
232
|
+
say(`Free trial · week ${week.round}`);
|
|
233
|
+
say(``);
|
|
234
|
+
if (!week.live) {
|
|
235
|
+
say(` The trial is not running on this server.`);
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
if (!week.computed) {
|
|
239
|
+
say(` This week's places have not been decided yet. That is not the same as none.`);
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
say(` places left ${week.slotsLeft} of ${week.winners}`);
|
|
243
|
+
say(` one place gives ${week.creditsPerWinner} credits — ` +
|
|
244
|
+
`${humanSize(week.creditsPerWinner * CREDIT_BYTES)} for one lease period`);
|
|
245
|
+
say(` this account ${week.already ? `took its place this week` : `has not applied this week`}`);
|
|
246
|
+
if (week.held) {
|
|
247
|
+
say(``);
|
|
248
|
+
say(` ⛔ Applications are paused while an unusually large wave of them is looked at.`);
|
|
249
|
+
say(` A paused week is not a closed one: nothing here is decided against this account.`);
|
|
250
|
+
}
|
|
251
|
+
say(``);
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* The rules, printed every time and not only when one bites.
|
|
255
|
+
*
|
|
256
|
+
* ⛔ THEY ARE NOT THIS TOOL'S TO CHANGE, so they are stated where somebody reads them before
|
|
257
|
+
* applying rather than quoted back as an error afterwards.
|
|
258
|
+
*/
|
|
259
|
+
function sayRules(say) {
|
|
260
|
+
say(`The rules, which this tool does not set and cannot bend:`);
|
|
261
|
+
say(` · one application per account per week, on the server's own week boundaries`);
|
|
262
|
+
say(` · first come, first served — a place goes to whoever asks while one is open`);
|
|
263
|
+
say(` · the week's budget is decided by the server; there is no way to ask for more`);
|
|
264
|
+
}
|