@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,236 @@
|
|
|
1
|
+
// `nmts extend <path>` — buy more storage time for one file that is already stored.
|
|
2
|
+
//
|
|
3
|
+
// ⛔ THE ONLY COMMAND IN THIS TOOL THAT SIGNS ANYTHING, and the only one that spends from a
|
|
4
|
+
// WALLET. Every other purchase here is made with credits, which are a promise this service
|
|
5
|
+
// made; this one moves WAL out of the wallet the account code derives, on a public chain, and
|
|
6
|
+
// nobody — NMTS included — can reverse it. That difference is said out loud, in the output,
|
|
7
|
+
// before the agreement is asked for.
|
|
8
|
+
//
|
|
9
|
+
// ⛔ IT PRICES BEFORE IT SPENDS, ALWAYS. The reads and the quote are free and happen first, so
|
|
10
|
+
// `--dry-run` answers with a real number and never reaches the key. Nothing below the quote can
|
|
11
|
+
// run without `requireConsent("wallet")` having passed.
|
|
12
|
+
//
|
|
13
|
+
// ⛔ THE SERVER DOES NOT EXTEND ANYTHING, and this command is shaped by that. `POST
|
|
14
|
+
// /v1/items/{id}/extended` means "record an extension the device already signed": the storage is
|
|
15
|
+
// already bought by the time it is called, so a failure there is a failure to WRITE THE DATE
|
|
16
|
+
// DOWN and is reported as itself. Saying "extension failed" would invite a second run, and a
|
|
17
|
+
// second run pays again.
|
|
18
|
+
//
|
|
19
|
+
// ⛔ AND A FILE THAT IS NOT RUNNING OUT IS NOT EXTENDED BY ACCIDENT. Extending early loses nothing
|
|
20
|
+
// — epochs are added to what is left — so this is not a refusal on principle; it is a refusal
|
|
21
|
+
// to spend money on a deadline nobody is near, unless somebody says so with `--yes`.
|
|
22
|
+
import { requireConsent } from "../consent.js";
|
|
23
|
+
import { request } from "../api.js";
|
|
24
|
+
import { buildIndex, entryAt, fullPathOf, KIND_FILE, normalisePath } from "../drive-paths.js";
|
|
25
|
+
import { NmtsError } from "../errors.js";
|
|
26
|
+
import { daysLeftInWords, daysLeftUntilEpoch, stageOf, } from "../expiry.js";
|
|
27
|
+
import { asExtendPreview, chooseEpochs, headroom, soonestEnd, } from "../extend-plan.js";
|
|
28
|
+
import { isRecord } from "../guards.js";
|
|
29
|
+
import { readFileList } from "../manifest.js";
|
|
30
|
+
import { BINARY_NAME } from "../product.js";
|
|
31
|
+
import { openSession } from "../session.js";
|
|
32
|
+
import { coinAmount } from "../wallet.js";
|
|
33
|
+
export async function extend(target, options = {}) {
|
|
34
|
+
const say = options.write ?? ((line) => process.stdout.write(`${line}\n`));
|
|
35
|
+
const now = options.now ?? Date.now();
|
|
36
|
+
if (target === undefined || target === "") {
|
|
37
|
+
throw new NmtsError("Say which file to extend.", {
|
|
38
|
+
exitCode: 2,
|
|
39
|
+
nextStep: `\`${BINARY_NAME} extend <path>\` — the path as \`${BINARY_NAME} ls\` prints it.`,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
const session = await openSession(options);
|
|
43
|
+
const list = await readFileList(session.server, session.apiKey, session.code, session.accountId);
|
|
44
|
+
if (list.manifest === null) {
|
|
45
|
+
throw new NmtsError("This account has no file list, so there is nothing to extend.", { exitCode: 4 });
|
|
46
|
+
}
|
|
47
|
+
const entries = list.manifest.entries;
|
|
48
|
+
const entry = entryAt(entries, normalisePath(target), {
|
|
49
|
+
nothingHappened: "Nothing was signed and nothing was charged.",
|
|
50
|
+
});
|
|
51
|
+
if (entry.kind !== KIND_FILE) {
|
|
52
|
+
throw new NmtsError(`No file at "${fullPathOf(buildIndex(entries), entry)}".`, {
|
|
53
|
+
exitCode: 4,
|
|
54
|
+
nextStep: "That is a folder. Nothing was signed and nothing was charged — storage is bought per " +
|
|
55
|
+
"file, so this takes one file at a time.",
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
const path = fullPathOf(buildIndex(entries), entry);
|
|
59
|
+
// ⛔ THE SERVER SAYS WHICH BLOBS, AND NOTHING ELSE. Its `expiry_epoch` is client-reported and
|
|
60
|
+
// advisory; a command that spends money reads the chain's own answer below.
|
|
61
|
+
const preview = asExtendPreview(await request(session.server, `/v1/items/${encodeURIComponent(entry.id)}/extend-preview`, {
|
|
62
|
+
token: session.apiKey,
|
|
63
|
+
}));
|
|
64
|
+
if (preview.targets.length === 0) {
|
|
65
|
+
throw new NmtsError(`Nothing on "${path}" can be extended from here.`, {
|
|
66
|
+
exitCode: 4,
|
|
67
|
+
nextStep: nothingToExtend(preview),
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
const reads = await (options.readChain ?? defaultReads)(session.network);
|
|
71
|
+
const window = await reads.readWindow();
|
|
72
|
+
if (window === null) {
|
|
73
|
+
// ⛔ Not "nothing needs extending". The two look identical from outside and mean opposite things.
|
|
74
|
+
throw new NmtsError(`The ${session.network} storage network could not be read.`, {
|
|
75
|
+
exitCode: 1,
|
|
76
|
+
nextStep: `Nothing was signed and nothing was charged. Which epoch the network is in, and how far ` +
|
|
77
|
+
`ahead it will sell, are facts only the chain has — this tool will not spend against a ` +
|
|
78
|
+
`guess. Try again, or name a different Sui node in NMTS_SUI_RPC.`,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
const clock = window.clock;
|
|
82
|
+
const leases = await reads.readLeases(preview.targets.map((t) => t.objectId));
|
|
83
|
+
const endEpoch = soonestEnd(leases);
|
|
84
|
+
if (endEpoch === null) {
|
|
85
|
+
throw new NmtsError(`The chain holds no storage term for "${path}".`, {
|
|
86
|
+
exitCode: 4,
|
|
87
|
+
nextStep: nothingToExtend(preview),
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
const stage = stageOf(clock, endEpoch, now);
|
|
91
|
+
if (stage === "lapsed") {
|
|
92
|
+
throw new NmtsError(`The storage term for "${path}" has already ended.`, {
|
|
93
|
+
exitCode: 4,
|
|
94
|
+
nextStep: `Nothing was signed and nothing was charged. A lease is extended before it ends — once it ` +
|
|
95
|
+
`is over there is no storage object left to extend, and the bytes may already be gone. ` +
|
|
96
|
+
`\`${BINARY_NAME} get\` says whether they can still be read.`,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
const epochs = chooseEpochs(options.epochs, headroom(leases, clock.current, window.maxAhead));
|
|
100
|
+
const newEndEpoch = endEpoch + epochs;
|
|
101
|
+
const before = daysLeftUntilEpoch(clock, endEpoch, now);
|
|
102
|
+
const after = daysLeftUntilEpoch(clock, newEndEpoch, now);
|
|
103
|
+
// ⛔ A COST THAT COULD NOT BE COMPUTED MUST NOT BECOME A COST OF ZERO. `quote` rejects rather
|
|
104
|
+
// than defaulting, and that rejection stops this run before the agreement is asked for.
|
|
105
|
+
const frost = await reads.quote(leases, epochs);
|
|
106
|
+
const cohort = Math.max(0, ...preview.targets.map((t) => t.sharedItems));
|
|
107
|
+
const unreachable = preview.treasuryParts + preview.untrackedParts;
|
|
108
|
+
const facts = {
|
|
109
|
+
file: path,
|
|
110
|
+
itemId: entry.id,
|
|
111
|
+
network: session.network,
|
|
112
|
+
epoch: clock.current,
|
|
113
|
+
endEpoch,
|
|
114
|
+
epochs,
|
|
115
|
+
newEndEpoch,
|
|
116
|
+
daysLeft: before,
|
|
117
|
+
daysLeftAfter: after,
|
|
118
|
+
blobs: leases.length,
|
|
119
|
+
priceFrost: frost.toString(),
|
|
120
|
+
priceWal: coinAmount(frost),
|
|
121
|
+
paidFrom: "wallet",
|
|
122
|
+
filesOnTheSameBlobs: cohort,
|
|
123
|
+
partsThatCannotBeExtended: unreachable,
|
|
124
|
+
};
|
|
125
|
+
if (options.dryRun === true) {
|
|
126
|
+
// ⛔ NOTHING BELOW THIS BRANCH RUNS. No key is derived, no agreement is asked for, and the
|
|
127
|
+
// signing module is not even loaded — `--dry-run` is a price and nothing else.
|
|
128
|
+
if (options.json) {
|
|
129
|
+
say(JSON.stringify({ ...facts, dryRun: true, signed: false }));
|
|
130
|
+
return 0;
|
|
131
|
+
}
|
|
132
|
+
describe(say, facts, cohort, unreachable);
|
|
133
|
+
say(``);
|
|
134
|
+
say(` Nothing was signed and nothing was charged. Run the same command without --dry-run to`);
|
|
135
|
+
say(` buy it.`);
|
|
136
|
+
if (stage === "later")
|
|
137
|
+
say(` It is not near its deadline, so buying it also needs --yes.`);
|
|
138
|
+
return 0;
|
|
139
|
+
}
|
|
140
|
+
if (!options.json)
|
|
141
|
+
describe(say, facts, cohort, unreachable);
|
|
142
|
+
// ⛔ ASKED AFTER THE PRICE IS KNOWN AND BEFORE ANYTHING IS SIGNED. Extending early loses nothing,
|
|
143
|
+
// so this is not a refusal on principle — it is a refusal to spend on a deadline that is not
|
|
144
|
+
// close, unless somebody says otherwise out loud.
|
|
145
|
+
if (stage === "later" && options.yes !== true) {
|
|
146
|
+
throw new NmtsError(`"${path}" is not near the end of its storage term.`, {
|
|
147
|
+
exitCode: 4,
|
|
148
|
+
nextStep: `Nothing was signed and nothing was charged. Extending early loses nothing — the epochs ` +
|
|
149
|
+
`are added to what is left — but it spends now for time this file does not need yet. ` +
|
|
150
|
+
`Add --yes to buy it anyway. \`${BINARY_NAME} expiring\` lists what is actually running out.`,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
// ⛔ THE ONE GATE THAT STANDS BETWEEN A PROGRAM AND SOMEBODY'S WALLET. Everything above this line
|
|
154
|
+
// is a read; nothing below it can be undone.
|
|
155
|
+
requireConsent("wallet");
|
|
156
|
+
const sign = options.sign ?? (await import("../extend-sign.js")).signExtension;
|
|
157
|
+
const digest = await sign({
|
|
158
|
+
network: session.network,
|
|
159
|
+
code: session.code,
|
|
160
|
+
objectIds: preview.targets.map((t) => t.objectId),
|
|
161
|
+
epochs,
|
|
162
|
+
});
|
|
163
|
+
// From here the storage IS extended. Recording it is bookkeeping, and a failure to record must
|
|
164
|
+
// never be reported as a failure to extend — that reading invites a second run, which pays again.
|
|
165
|
+
let replay = false;
|
|
166
|
+
try {
|
|
167
|
+
const recorded = await request(session.server, `/v1/items/${encodeURIComponent(entry.id)}/extended`, { method: "POST", token: session.apiKey, body: { epochs, tx_digest: digest } });
|
|
168
|
+
replay = isRecord(recorded) && recorded["replay"] === true;
|
|
169
|
+
}
|
|
170
|
+
catch (error) {
|
|
171
|
+
if (options.json) {
|
|
172
|
+
say(JSON.stringify({ ...facts, dryRun: false, signed: true, digest, recorded: false }));
|
|
173
|
+
}
|
|
174
|
+
else {
|
|
175
|
+
say(``);
|
|
176
|
+
say(` The storage IS extended and the payment has been made — transaction ${digest}.`);
|
|
177
|
+
say(` What failed is telling the NMTS server about it, so the drive will go on showing the`);
|
|
178
|
+
say(` old date until something tells it. ⛔ Do not run this command again for this file:`);
|
|
179
|
+
say(` that would buy the same epochs a second time. Opening the account in a browser reads`);
|
|
180
|
+
say(` the chain directly.`);
|
|
181
|
+
say(` Cause: ${error instanceof Error ? error.message : String(error)}`);
|
|
182
|
+
}
|
|
183
|
+
return 1;
|
|
184
|
+
}
|
|
185
|
+
if (options.json) {
|
|
186
|
+
say(JSON.stringify({ ...facts, dryRun: false, signed: true, digest, recorded: true, replay }));
|
|
187
|
+
return 0;
|
|
188
|
+
}
|
|
189
|
+
say(``);
|
|
190
|
+
say(` Extended. The storage now ends at epoch ${newEndEpoch} — ${daysLeftInWords(after)}.`);
|
|
191
|
+
say(` Transaction ${digest}`);
|
|
192
|
+
if (replay) {
|
|
193
|
+
say(` The server had already recorded this transaction, so nothing was written twice.`);
|
|
194
|
+
}
|
|
195
|
+
return 0;
|
|
196
|
+
}
|
|
197
|
+
/** What the numbers say, for a person, in the order somebody deciding needs them. */
|
|
198
|
+
function describe(say, facts, cohort, unreachable) {
|
|
199
|
+
const when = (epoch, left) => `epoch ${epoch} — ${daysLeftInWords(left)}`;
|
|
200
|
+
say(`${facts.file}`);
|
|
201
|
+
say(` Storage ends at ${when(facts.endEpoch, facts.daysLeft)}.`);
|
|
202
|
+
say(` Extending by ${facts.epochs} epoch${facts.epochs === 1 ? "" : "s"} moves that to ` +
|
|
203
|
+
`${when(facts.newEndEpoch, facts.daysLeftAfter)}.`);
|
|
204
|
+
say(` Price ${facts.priceWal} WAL for ${facts.blobs} stored blob${facts.blobs === 1 ? "" : "s"} — ` +
|
|
205
|
+
`storage only, because the writing was paid for when the file was uploaded.`);
|
|
206
|
+
if (cohort > 1) {
|
|
207
|
+
say(` ${cohort} of this account's files sit on the storage this pays for, so the same payment ` +
|
|
208
|
+
`extends all of them.`);
|
|
209
|
+
}
|
|
210
|
+
if (unreachable > 0) {
|
|
211
|
+
say(` ${unreachable} part${unreachable === 1 ? "" : "s"} of this file cannot be extended from here.`);
|
|
212
|
+
}
|
|
213
|
+
say(``);
|
|
214
|
+
// ⛔ REQUIRED, AND IT COMES BEFORE THE AGREEMENT. Every other purchase in this tool spends
|
|
215
|
+
// credits; this one spends assets out of a wallet, and nobody can put them back.
|
|
216
|
+
say(` This is paid in WAL from the wallet this account code derives — not from credits, which`);
|
|
217
|
+
say(` is what every other command in this tool spends. \`${BINARY_NAME} wallet\` shows what is in it.`);
|
|
218
|
+
}
|
|
219
|
+
/** Why a file has nothing to extend, said as the two different things it can be. */
|
|
220
|
+
function nothingToExtend(preview) {
|
|
221
|
+
const parts = [];
|
|
222
|
+
if (preview.treasuryParts > 0) {
|
|
223
|
+
parts.push(`${preview.treasuryParts} part${preview.treasuryParts === 1 ? " is" : "s are"} on storage NMTS ` +
|
|
224
|
+
`paid for, which this account cannot extend`);
|
|
225
|
+
}
|
|
226
|
+
if (preview.untrackedParts > 0) {
|
|
227
|
+
parts.push(`${preview.untrackedParts} part${preview.untrackedParts === 1 ? " has" : "s have"} no ` +
|
|
228
|
+
`recorded storage object, so there is nothing to name on the chain`);
|
|
229
|
+
}
|
|
230
|
+
const why = parts.length === 0 ? "The server lists no storage object for it." : `${parts.join(", and ")}.`;
|
|
231
|
+
return `Nothing was signed and nothing was charged. ${why} Opening the account in a browser shows what it is stored on.`;
|
|
232
|
+
}
|
|
233
|
+
/** The real chain reads. Imported only when no seam was supplied — it loads the storage SDK. */
|
|
234
|
+
async function defaultReads(network) {
|
|
235
|
+
return (await import("../extend-chain.js")).extendReads(network);
|
|
236
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// `nmts get <path>` — one file back out, decrypted and checked.
|
|
2
|
+
//
|
|
3
|
+
// ⚠ IT REFUSES RATHER THAN WRITES A HALF-RIGHT FILE. A wrong key, a part that will not decrypt,
|
|
4
|
+
// parts that do not add up to the file the list describes, or a whole-file hash that does not
|
|
5
|
+
// match — none of them leave a file behind. A file on disk is a claim that it is the file, and a
|
|
6
|
+
// partial one makes that claim silently.
|
|
7
|
+
//
|
|
8
|
+
// ⛔ WHICH IS NOW KEPT BY A RENAME, NOT BY BUFFERING. The file is written as it is decrypted, so it
|
|
9
|
+
// is no longer bounded by memory — a file that can be uploaded can be brought back. The bytes go
|
|
10
|
+
// to a temporary name in the destination's own directory and are renamed into place only after
|
|
11
|
+
// the whole-file digest matches; the temporary file is deleted when anything fails. `--out -`
|
|
12
|
+
// has no rename available and keeps the older bargain instead: see `download-sink.ts`.
|
|
13
|
+
//
|
|
14
|
+
// ⛔ IT WILL NOT OVERWRITE. An agent that re-runs a command should not destroy what the previous
|
|
15
|
+
// run produced, and neither should a person who forgot the file was there. `--force` is the way
|
|
16
|
+
// to say otherwise, out loud.
|
|
17
|
+
//
|
|
18
|
+
// ⛔ `--out -` HANDS THE FILE TO WHATEVER IS READING stdout AND WRITES NOTHING. Reading one stored
|
|
19
|
+
// note should not mean leaving a plaintext copy of it on the disk, which is what this command
|
|
20
|
+
// made an agent do until now. The refusals above still happen first, and all of them happen
|
|
21
|
+
// before a byte moves — the file is whole and checked in memory before it is handed over, so a
|
|
22
|
+
// pipe gets a whole file or gets nothing (`stdout.ts`). That is also why this mode, and only
|
|
23
|
+
// this mode, still has a size ceiling and says so out loud when a file is over it.
|
|
24
|
+
//
|
|
25
|
+
// ⛔ AND IN THAT MODE EVERYTHING A PERSON READS GOES TO stderr — the summary, the missing-hash
|
|
26
|
+
// warning and the `--json` line alike, because stdout is now carrying the file and a second
|
|
27
|
+
// thing on it would be part of the file as far as the reader is concerned.
|
|
28
|
+
import { basename, resolve } from "node:path";
|
|
29
|
+
import { CODE_ENV_VAR } from "../credentials.js";
|
|
30
|
+
import { fetchFile } from "../download.js";
|
|
31
|
+
import { fileSink, stdoutSink } from "../download-sink.js";
|
|
32
|
+
import { buildIndex, entryAt, fullPathOf, KIND_FILE, normalisePath } from "../drive-paths.js";
|
|
33
|
+
import { NmtsError } from "../errors.js";
|
|
34
|
+
import { readFileList } from "../manifest.js";
|
|
35
|
+
import { resolveNetwork } from "../network.js";
|
|
36
|
+
import { refuseUnwritableName } from "../safe-path.js";
|
|
37
|
+
import { BINARY_NAME } from "../product.js";
|
|
38
|
+
import { openSession } from "../session.js";
|
|
39
|
+
import { processStdout, STDOUT_TARGET } from "../stdout.js";
|
|
40
|
+
export async function get(target, options = {}) {
|
|
41
|
+
const toStdout = options.out === STDOUT_TARGET;
|
|
42
|
+
// ⛔ THE HUMAN LINES MOVE OUT OF THE WAY OF THE FILE. When stdout carries the file, a summary
|
|
43
|
+
// line printed there is not a summary line — it is bytes appended to what the reader thinks
|
|
44
|
+
// is the file.
|
|
45
|
+
const say = options.write ?? ((line) => (toStdout ? process.stderr : process.stdout).write(`${line}\n`));
|
|
46
|
+
if (target === undefined || target === "") {
|
|
47
|
+
throw new NmtsError("Say which file to get.", {
|
|
48
|
+
exitCode: 2,
|
|
49
|
+
nextStep: `\`${BINARY_NAME} get <path>\` — the path as \`${BINARY_NAME} ls\` prints it.`,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
// ⛔ ONE PLACE RESOLVES THE CREDENTIALS, so the "no API key" refusal — the sentence a new user is
|
|
53
|
+
// most likely to see — is one text. This command carried its own copy and had already dropped
|
|
54
|
+
// a sentence the other three keep (2026-08-23).
|
|
55
|
+
const session = await openSession({ server: options.server, network: options.network });
|
|
56
|
+
const chain = resolveNetwork(session.server, session.network);
|
|
57
|
+
const list = await readFileList(session.server, session.apiKey, session.code, session.accountId);
|
|
58
|
+
if (list.manifest === null) {
|
|
59
|
+
throw new NmtsError("This account has no file list, so there is nothing to get.", { exitCode: 4 });
|
|
60
|
+
}
|
|
61
|
+
// ⛔ ONE LOOKUP, SHARED WITH EVERY OTHER COMMAND. This used to walk the parent chain itself and
|
|
62
|
+
// read only `e.deletedAt`, which made it disagree with `ls` about a broken chain and, worse,
|
|
63
|
+
// offer a file whose folder had been trashed — the server refuses those bytes
|
|
64
|
+
// (2026-08-23).
|
|
65
|
+
const index = buildIndex(list.manifest.entries);
|
|
66
|
+
const wanted = normalisePath(target);
|
|
67
|
+
// ⚠ Looked up WITHOUT a kind filter on purpose: a path that names a folder must be told apart
|
|
68
|
+
// from a path that names nothing, and a filtered lookup can only say "nothing is there".
|
|
69
|
+
const entry = entryAt(list.manifest.entries, wanted, { nothingHappened: "Nothing was written." });
|
|
70
|
+
if (entry.kind !== KIND_FILE) {
|
|
71
|
+
throw new NmtsError(`No file at "${fullPathOf(index, entry)}".`, {
|
|
72
|
+
exitCode: 4,
|
|
73
|
+
nextStep: "That is a folder. Nothing was written — this version gets one file at a time.",
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
if (entry.dekWrapped === undefined) {
|
|
77
|
+
throw new NmtsError(`The file list holds no key for "${wanted}".`, {
|
|
78
|
+
exitCode: 4,
|
|
79
|
+
nextStep: "Without it nothing can open the stored bytes. Open the account in a browser.",
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
// ⛔ Only the name that came FROM THE DRIVE is judged. `--out` is the person naming a path on
|
|
83
|
+
// their own machine, and they are allowed to mean it.
|
|
84
|
+
if (!toStdout && options.out === undefined)
|
|
85
|
+
refuseUnwritableName(basename(entry.name));
|
|
86
|
+
const destination = toStdout ? null : resolve(options.out ?? basename(entry.name));
|
|
87
|
+
// ⛔ `--force` has nothing to overwrite on the stdout branch and is ignored there on purpose:
|
|
88
|
+
// there is no file to protect, and refusing the combination would only make a wrapper script
|
|
89
|
+
// harder to write. On the disk branch the sink refuses an occupied name BEFORE the download,
|
|
90
|
+
// which the old code could only do after fetching the whole file.
|
|
91
|
+
const sink = destination === null
|
|
92
|
+
? stdoutSink(options.stdout ?? processStdout())
|
|
93
|
+
: fileSink(destination, { force: options.force === true });
|
|
94
|
+
const fetched = await fetchFile({
|
|
95
|
+
base: session.server,
|
|
96
|
+
apiKey: session.apiKey,
|
|
97
|
+
accountCode: session.code,
|
|
98
|
+
itemId: entry.id,
|
|
99
|
+
size: entry.size,
|
|
100
|
+
dekWrapped: entry.dekWrapped,
|
|
101
|
+
contentHashCt: entry.contentHashCt,
|
|
102
|
+
chain,
|
|
103
|
+
sink,
|
|
104
|
+
});
|
|
105
|
+
// The reader closed the pipe first. It got what it asked for and stopped; saying anything now
|
|
106
|
+
// would be writing to a pipe nobody is holding — see `handOver`.
|
|
107
|
+
if (!fetched.delivered)
|
|
108
|
+
return 0;
|
|
109
|
+
if (options.json) {
|
|
110
|
+
say(JSON.stringify({
|
|
111
|
+
path: wanted,
|
|
112
|
+
// ⛔ `-` and not a path, because no path was written. A caller that treats this field as a
|
|
113
|
+
// file name would otherwise create one — the exact copy this mode exists to avoid.
|
|
114
|
+
writtenTo: destination ?? STDOUT_TARGET,
|
|
115
|
+
bytes: fetched.byteCount,
|
|
116
|
+
parts: fetched.partCount,
|
|
117
|
+
contentHashChecked: fetched.contentHashChecked,
|
|
118
|
+
}));
|
|
119
|
+
return 0;
|
|
120
|
+
}
|
|
121
|
+
say(`${destination ?? "stdout"} ${fetched.byteCount} bytes from ${fetched.partCount} stored part${fetched.partCount === 1 ? "" : "s"}`);
|
|
122
|
+
// ⛔ Said out loud when it is absent. "Verified" and "nothing to verify against" are different
|
|
123
|
+
// facts, and only one of them means the bytes were checked.
|
|
124
|
+
if (!fetched.contentHashChecked) {
|
|
125
|
+
say(``);
|
|
126
|
+
say(` This file has no recorded hash in the file list, so nothing here could check the whole`);
|
|
127
|
+
say(` file against one. Every part still decrypted under this account's key.`);
|
|
128
|
+
}
|
|
129
|
+
return 0;
|
|
130
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// `nmts kit` — the recovery kit: one file that is enough to get everything back.
|
|
2
|
+
//
|
|
3
|
+
// ⛔⛔ IT WRITES THE ACCOUNT CODE IN THE CLEAR. That is the format, decided deliberately: the point
|
|
4
|
+
// of the kit is that a person needs ONE thing, not two. So whoever holds this file holds the
|
|
5
|
+
// account and the wallet, and this command says exactly that at the moment it writes — not in a
|
|
6
|
+
// manual, not on a website. The moment somebody makes this file is the moment that fact
|
|
7
|
+
// matters, and it is not the moment anybody goes looking for a document.
|
|
8
|
+
//
|
|
9
|
+
// ⛔ IT WRITES NOWHERE BUT THE PATH THE CALLER NAMED. Nothing is copied into this tool's own
|
|
10
|
+
// directory, nothing is left in a temporary file, nothing is printed to the terminal. A command
|
|
11
|
+
// that quietly kept a second copy of an account code would be the worst defect in this program.
|
|
12
|
+
//
|
|
13
|
+
// ⛔ AND IT NEVER REPLACES A FILE NOBODY ASKED IT TO REPLACE. A taken name is a refusal unless
|
|
14
|
+
// `--force` says otherwise — the same rule every other writing command here follows.
|
|
15
|
+
//
|
|
16
|
+
// ⛔ NO PARTIAL KIT. If the recovery list cannot be built, nothing is written and the reason is
|
|
17
|
+
// printed. The browser has a third state for this — a kit whose text says "the list could not
|
|
18
|
+
// be made this time" — because it is a screen and can carry that sentence to the person who
|
|
19
|
+
// just pressed the button. This file's MACHINE block has no field for it: `recovery_list: null`
|
|
20
|
+
// means "this account had no files", and writing that for a build that failed would state the
|
|
21
|
+
// opposite of the truth in the one document somebody keeps for the worst day. So it refuses.
|
|
22
|
+
import { existsSync, statSync, writeFileSync } from "node:fs";
|
|
23
|
+
import { isAbsolute, join, resolve } from "node:path";
|
|
24
|
+
import { identityOf } from "../account.js";
|
|
25
|
+
import { NmtsError } from "../errors.js";
|
|
26
|
+
import { isRecord } from "../guards.js";
|
|
27
|
+
import { buildRecoveryKit } from "../kit-file.js";
|
|
28
|
+
import { assembleRecoveryList, recordRecoveryList } from "../recovery-assemble.js";
|
|
29
|
+
export async function kit(options = {}) {
|
|
30
|
+
const say = options.write ?? ((line) => process.stdout.write(`${line}\n`));
|
|
31
|
+
const assembled = await assembleRecoveryList({
|
|
32
|
+
server: options.server,
|
|
33
|
+
network: options.network,
|
|
34
|
+
});
|
|
35
|
+
const { built, session } = assembled;
|
|
36
|
+
// The sealed list is embedded as the SAME document `nmts recovery-list` writes out, so the
|
|
37
|
+
// standalone program reads one shape whichever file it is handed.
|
|
38
|
+
const parsed = JSON.parse(assembled.file.content);
|
|
39
|
+
if (!isRecord(parsed)) {
|
|
40
|
+
throw new NmtsError("The recovery list did not come back as a document.", {
|
|
41
|
+
exitCode: 1,
|
|
42
|
+
nextStep: "Nothing was written. This is a fault in the tool rather than in the account.",
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
const identity = await identityOf(session.code);
|
|
46
|
+
const file = buildRecoveryKit({
|
|
47
|
+
// ⛔ THE GROUPED FORM, which is what a person reads and types back. The kit is the artefact a
|
|
48
|
+
// person is told to print or to put in a drawer.
|
|
49
|
+
code: identity.displayCode,
|
|
50
|
+
accountId: session.accountId,
|
|
51
|
+
generatedAt: nowFrom(parsed),
|
|
52
|
+
// ⚠ AN EMPTY ACCOUNT GETS `null`, WHICH MEANS "there were no files", and that is true here:
|
|
53
|
+
// the list was built and it covers nothing. It is NOT the spelling for a build that failed —
|
|
54
|
+
// this command refuses instead of reaching this line with an unknown.
|
|
55
|
+
recoveryList: built.fileCount === 0 ? null : parsed,
|
|
56
|
+
listFileCount: built.fileCount === 0 ? null : built.fileCount,
|
|
57
|
+
});
|
|
58
|
+
const destination = destinationFor(options.out, file.filename);
|
|
59
|
+
if (options.force !== true && existsSync(destination))
|
|
60
|
+
throw alreadyThere(destination);
|
|
61
|
+
try {
|
|
62
|
+
// ⛔ 0600, AND IT IS NOT A FORMALITY HERE. This file is the account code. On a filesystem that
|
|
63
|
+
// cannot keep the mode, this is still the only protection there is to ask for.
|
|
64
|
+
writeFileSync(destination, file.content, {
|
|
65
|
+
flag: options.force === true ? "w" : "wx",
|
|
66
|
+
mode: 0o600,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
if (error instanceof Error && Reflect.get(error, "code") === "EEXIST") {
|
|
71
|
+
throw alreadyThere(destination);
|
|
72
|
+
}
|
|
73
|
+
throw error;
|
|
74
|
+
}
|
|
75
|
+
// The kit embeds a real recovery list, so the account's record moves with it — `kind: "local"`,
|
|
76
|
+
// the same fact `nmts recovery-list` reports: a list exists and the person is keeping it.
|
|
77
|
+
await recordRecoveryList(assembled, destination);
|
|
78
|
+
if (options.json === true) {
|
|
79
|
+
say(JSON.stringify({
|
|
80
|
+
writtenTo: destination,
|
|
81
|
+
mode: "0600",
|
|
82
|
+
seq: assembled.seq,
|
|
83
|
+
files: built.fileCount,
|
|
84
|
+
// ⛔ THE SENTENCE REACHES A READER THAT ONLY PARSES JSON. An agent that hands this file to
|
|
85
|
+
// somebody has to be able to say what it is, in the same words.
|
|
86
|
+
carries: ["account-code", "recovery-list"],
|
|
87
|
+
warning: "This file contains the account code in the clear. Anyone who holds it holds the " +
|
|
88
|
+
"account and the wallet.",
|
|
89
|
+
missingFromSource: built.missingFromSource,
|
|
90
|
+
}));
|
|
91
|
+
return 0;
|
|
92
|
+
}
|
|
93
|
+
say(`Wrote ${destination}`);
|
|
94
|
+
say(``);
|
|
95
|
+
say(` ⛔ This file contains your account code in the clear, together with the recovery list`);
|
|
96
|
+
say(` for ${built.fileCount} file(s). Anyone who holds it holds this account: every file in`);
|
|
97
|
+
say(` it, and the wallet that pays for storage. One account code opens both.`);
|
|
98
|
+
say(``);
|
|
99
|
+
say(` It was written only where you see it, with permissions 0600, and nowhere else.`);
|
|
100
|
+
say(` Do not keep it in a folder that syncs or backs up on its own, and do not send it to`);
|
|
101
|
+
say(` anyone. A drawer is often safer than a cloud folder.`);
|
|
102
|
+
if (built.missingFromSource.length > 0) {
|
|
103
|
+
say(``);
|
|
104
|
+
say(` ⚠ ${built.missingFromSource.length} file(s) in your file list are not in the account's`);
|
|
105
|
+
say(` stored files, so they are not in this kit.`);
|
|
106
|
+
}
|
|
107
|
+
return 0;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* The instant the embedded list was taken, read back out of the document rather than re-stamped.
|
|
111
|
+
*
|
|
112
|
+
* ⛔ THE TWO HALVES OF THIS FILE MUST NOT CLAIM DIFFERENT MOMENTS. A person comparing "Created" at
|
|
113
|
+
* the top with `generated_at` inside would have no way to tell which one to believe.
|
|
114
|
+
*/
|
|
115
|
+
function nowFrom(mapFile) {
|
|
116
|
+
const at = mapFile["generated_at"];
|
|
117
|
+
return typeof at === "string" && at.length > 0 ? at : new Date().toISOString();
|
|
118
|
+
}
|
|
119
|
+
/** Where the file goes: the given file name, inside the given directory, or this directory. */
|
|
120
|
+
function destinationFor(out, filename) {
|
|
121
|
+
if (out === undefined)
|
|
122
|
+
return join(process.cwd(), filename);
|
|
123
|
+
const target = isAbsolute(out) ? out : resolve(process.cwd(), out);
|
|
124
|
+
// A directory that exists means "put it in here under its own name"; anything else is the name
|
|
125
|
+
// to write.
|
|
126
|
+
if (existsSync(target) && statSync(target).isDirectory())
|
|
127
|
+
return join(target, filename);
|
|
128
|
+
return target;
|
|
129
|
+
}
|
|
130
|
+
function alreadyThere(destination) {
|
|
131
|
+
return new NmtsError(`${destination} is already there.`, {
|
|
132
|
+
exitCode: 4,
|
|
133
|
+
nextStep: `Nothing was written, and no account code was put anywhere. Pass --out to choose another ` +
|
|
134
|
+
`name, or --force to replace it.`,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// `nmts listfile` — writing this machine's copy of the sealed file list out as a file.
|
|
2
|
+
//
|
|
3
|
+
// ⛔ WHY A COPY IS WORTH ANYTHING. The list is where every file's NAME, folder and KEY live. The
|
|
4
|
+
// server holds it sealed and cannot read it — and an account whose list the server loses is an
|
|
5
|
+
// account of nameless, unopenable files, even though every byte is still stored. A copy on
|
|
6
|
+
// somebody's own disk is the answer to that, and it is the artefact an account used only from a
|
|
7
|
+
// terminal never had: the browser keeps one and can hand it over, this tool kept two numbers.
|
|
8
|
+
//
|
|
9
|
+
// ⛔ IT ANSWERS FROM THIS MACHINE, AND ASKS NOTHING OF ANYBODY. No API key, no server, no network.
|
|
10
|
+
// What it writes is the copy kept beside the rollback record, refreshed every time this tool
|
|
11
|
+
// read a newer list — so the version it hands over is the newest version this machine has seen,
|
|
12
|
+
// which is a fact it can state exactly rather than a promise about the account.
|
|
13
|
+
//
|
|
14
|
+
// ⛔ WHAT THE FILE IS NOT. It is not a recovery on its own. The storage network's addresses are not
|
|
15
|
+
// in it — those live in the recovery list a browser writes — and neither is the account code,
|
|
16
|
+
// deliberately: this file plus the code IS the account, and keeping both together turns one
|
|
17
|
+
// theft into a total loss. Both facts are printed, and both are inside the file for whoever
|
|
18
|
+
// finds it later without this text.
|
|
19
|
+
import { existsSync, statSync, writeFileSync } from "node:fs";
|
|
20
|
+
import { isAbsolute, join, resolve } from "node:path";
|
|
21
|
+
import { identityOf } from "../account.js";
|
|
22
|
+
import { requireAccountCode } from "../code-access.js";
|
|
23
|
+
import { NmtsError } from "../errors.js";
|
|
24
|
+
import { buildFileListFile } from "../list-file.js";
|
|
25
|
+
import { readKeptList } from "../manifest.js";
|
|
26
|
+
import { BINARY_NAME } from "../product.js";
|
|
27
|
+
export async function listfile(options = {}) {
|
|
28
|
+
const toStdout = options.out === "-";
|
|
29
|
+
// ⛔ WITH `--out -` EVERY LINE FOR A PERSON GOES TO STDERR. The document is then the only thing
|
|
30
|
+
// on standard output, so `… --out - > copy.nmtslist` produces a file and not a file with an
|
|
31
|
+
// explanation glued to the front of it.
|
|
32
|
+
const say = options.write ??
|
|
33
|
+
((line) => process[toStdout ? "stderr" : "stdout"].write(`${line}\n`));
|
|
34
|
+
const emit = options.writeDocument ?? ((text) => process.stdout.write(text));
|
|
35
|
+
const resolved = await requireAccountCode();
|
|
36
|
+
const identity = await identityOf(resolved.code);
|
|
37
|
+
const kept = readKeptList(identity.accountId);
|
|
38
|
+
if (kept === null) {
|
|
39
|
+
throw new NmtsError(`This machine has no copy of this account's file list.`, {
|
|
40
|
+
exitCode: 4,
|
|
41
|
+
nextStep: `Nothing was written. Run \`${BINARY_NAME} ls\` once while this machine can reach the ` +
|
|
42
|
+
`server: every read of the list keeps its sealed bytes here, and this command writes that ` +
|
|
43
|
+
`copy out. A copy cannot be made from the account code alone — the list itself is what is ` +
|
|
44
|
+
`being copied.`,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
const file = buildFileListFile({
|
|
48
|
+
accountId: identity.accountId,
|
|
49
|
+
seq: kept.seq,
|
|
50
|
+
savedAt: kept.savedAt,
|
|
51
|
+
sealed: kept.ct,
|
|
52
|
+
});
|
|
53
|
+
if (toStdout) {
|
|
54
|
+
emit(file.content);
|
|
55
|
+
saidWhatItIs(say, kept.seq, kept.savedAt, file.filename);
|
|
56
|
+
return 0;
|
|
57
|
+
}
|
|
58
|
+
const destination = destinationFor(options.out, file.filename);
|
|
59
|
+
try {
|
|
60
|
+
// ⛔ `wx` UNLESS TOLD OTHERWISE, and 0600 either way. Replacing a file nobody asked to replace
|
|
61
|
+
// is not this command's decision, and the bytes are one account's whole file list.
|
|
62
|
+
writeFileSync(destination, file.content, { flag: options.force === true ? "w" : "wx", mode: 0o600 });
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
if (error instanceof Error && Reflect.get(error, "code") === "EEXIST") {
|
|
66
|
+
throw new NmtsError(`${destination} is already there.`, {
|
|
67
|
+
exitCode: 4,
|
|
68
|
+
nextStep: `Nothing was written. Pass --out to choose another name, or --force to replace it.`,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
throw error;
|
|
72
|
+
}
|
|
73
|
+
say(`Wrote ${destination}`);
|
|
74
|
+
saidWhatItIs(say, kept.seq, kept.savedAt, file.filename);
|
|
75
|
+
return 0;
|
|
76
|
+
}
|
|
77
|
+
/** Where the file goes: the given file name, inside the given directory, or this directory. */
|
|
78
|
+
function destinationFor(out, filename) {
|
|
79
|
+
if (out === undefined)
|
|
80
|
+
return join(process.cwd(), filename);
|
|
81
|
+
const target = isAbsolute(out) ? out : resolve(process.cwd(), out);
|
|
82
|
+
// A directory that exists means "put it in here under its own name"; anything else is the name
|
|
83
|
+
// to write. Guessing the other way round would silently rename the artefact whose filename is
|
|
84
|
+
// how a person tells two copies apart.
|
|
85
|
+
if (existsSync(target) && statSync(target).isDirectory())
|
|
86
|
+
return join(target, filename);
|
|
87
|
+
return target;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* The two sentences that stop this file from being mistaken for a recovery.
|
|
91
|
+
*
|
|
92
|
+
* ⛔ SAID ON EVERY RUN, not once in a manual. The moment somebody reaches for this file is the
|
|
93
|
+
* moment those two facts matter, and it is not the moment anybody goes looking for a document.
|
|
94
|
+
*/
|
|
95
|
+
function saidWhatItIs(say, seq, savedAt, filename) {
|
|
96
|
+
say(``);
|
|
97
|
+
say(` File list version ${seq}, as this machine last read it (${savedAt}).`);
|
|
98
|
+
say(` It holds the names, folders and file keys of this account, sealed with the account code.`);
|
|
99
|
+
say(``);
|
|
100
|
+
say(` ⛔ It is not a recovery on its own. It carries no storage-network addresses — those are in`);
|
|
101
|
+
say(` the recovery list saved from the account screen — and it does not contain the account`);
|
|
102
|
+
say(` code. Keep it somewhere other than the code: together they are the whole account.`);
|
|
103
|
+
say(` ⚠ A copy goes stale. Run this again after uploading: of two copies, the one whose`);
|
|
104
|
+
say(` filename carries the higher number supersedes the other (this one is ${filename}).`);
|
|
105
|
+
}
|