telstore 0.1.5 → 0.1.7
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/README.md +122 -161
- package/bin/telstore.js +27 -7
- package/package.json +1 -1
- package/src/caption.js +42 -4
- package/src/cli.js +73 -20
- package/src/commands/delete.js +196 -1
- package/src/commands/list.js +29 -5
- package/src/commands/restore.js +118 -1
- package/src/commands/status.js +4 -4
- package/src/commands/upload.js +255 -13
- package/src/manifest.js +21 -1
- package/src/settings.js +6 -6
- package/src/sources.js +163 -0
package/src/commands/upload.js
CHANGED
|
@@ -5,10 +5,13 @@ import { Api } from 'teleproto'
|
|
|
5
5
|
import { CustomFile } from 'teleproto/client/uploads.js'
|
|
6
6
|
|
|
7
7
|
import { PART_SIZE, planChunks } from '../chunking.js'
|
|
8
|
-
import { chunkCaption, manifestCaption } from '../caption.js'
|
|
8
|
+
import { chunkCaption, manifestCaption, parseNote } from '../caption.js'
|
|
9
9
|
import { describeChat } from '../chat.js'
|
|
10
10
|
import { closeQuietly, connect as realConnect } from '../client.js'
|
|
11
|
+
import { askConfirm } from '../confirm.js'
|
|
11
12
|
import { configFile, defaultConfigDir, loadConfig } from '../config.js'
|
|
13
|
+
import { expandSources } from '../sources.js'
|
|
14
|
+
import { assertLoggedIn } from '../session.js'
|
|
12
15
|
import { requireChat, resolveSettings } from '../settings.js'
|
|
13
16
|
import {
|
|
14
17
|
buildManifest,
|
|
@@ -61,6 +64,49 @@ async function realSendManifest(client, peer, { bytes, fileName, caption }) {
|
|
|
61
64
|
})
|
|
62
65
|
}
|
|
63
66
|
|
|
67
|
+
// `--note quarterly accounts` is two arguments by the time node sees it: the note is
|
|
68
|
+
// "quarterly", and "accounts" is a file telstore has been asked to upload. Nothing here can
|
|
69
|
+
// put them back together — the shell dropped the quotes before the process started — so the
|
|
70
|
+
// one useful thing left is to name the mistake this probably was.
|
|
71
|
+
//
|
|
72
|
+
// Two things have to be true before it is said, and neither is enough alone. The note must
|
|
73
|
+
// still be one word — one that kept its spaces is one the shell was told to keep whole, so
|
|
74
|
+
// this cannot have happened to it — and a file must have been named *after* the flag, which
|
|
75
|
+
// is where an unquoted note's remaining words land. Without both, the file name is simply
|
|
76
|
+
// wrong, and sending someone off after a quoting bug that is not there costs them the typo
|
|
77
|
+
// waiting at the front of the same sentence.
|
|
78
|
+
function splitNoteHint(note, filesAfterNote) {
|
|
79
|
+
if (!note || !filesAfterNote || /\s/.test(note)) return ''
|
|
80
|
+
|
|
81
|
+
return (
|
|
82
|
+
` This run also carries --note ${JSON.stringify(note)}: a note with spaces in it has to be ` +
|
|
83
|
+
'quoted, or the shell hands every word after the first to telstore as another file. ' +
|
|
84
|
+
'Write it as --note "the whole note".'
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// What telstore will read the bytes from. A batch checks every path through this before it
|
|
89
|
+
// sends anything, so "File does not exist" reads the same whether it came from the one file
|
|
90
|
+
// asked for or from the fourth of six — one definition, one wording.
|
|
91
|
+
async function statSource(absPath, { note = null, filesAfterNote = false } = {}) {
|
|
92
|
+
let stat
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
stat = await fs.stat(absPath)
|
|
96
|
+
} catch (err) {
|
|
97
|
+
if (err.code === 'ENOENT') {
|
|
98
|
+
throw new Error(`File does not exist: ${absPath}.${splitNoteHint(note, filesAfterNote)}`)
|
|
99
|
+
}
|
|
100
|
+
throw err
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (!stat.isFile()) {
|
|
104
|
+
throw new Error(`${absPath} is not a file.`)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return stat
|
|
108
|
+
}
|
|
109
|
+
|
|
64
110
|
export async function runUpload(filePath, options = {}, deps = {}) {
|
|
65
111
|
const {
|
|
66
112
|
connect = realConnect,
|
|
@@ -74,21 +120,20 @@ export async function runUpload(filePath, options = {}, deps = {}) {
|
|
|
74
120
|
log: writeLog = (line) => console.log(line),
|
|
75
121
|
silent = false,
|
|
76
122
|
onBackupId = () => {},
|
|
123
|
+
// Where on the command line the note sat, as `route` saw it. Nothing else can know, and
|
|
124
|
+
// a run that never says leaves the advice unsaid rather than guessed at.
|
|
125
|
+
filesAfterNote = false,
|
|
77
126
|
} = deps
|
|
78
127
|
|
|
79
128
|
const absPath = path.resolve(filePath)
|
|
80
129
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
throw err
|
|
87
|
-
}
|
|
130
|
+
// Before the file is even looked at: the note is the only thing this command sends that a
|
|
131
|
+
// person typed by hand, and it is written into the manifest, which goes out last. A note
|
|
132
|
+
// Telegram would refuse has to stop the run here, not after an hour of chunks whose only
|
|
133
|
+
// list can no longer be sent.
|
|
134
|
+
const note = parseNote(options.note)
|
|
88
135
|
|
|
89
|
-
|
|
90
|
-
throw new Error(`${absPath} is not a file.`)
|
|
91
|
-
}
|
|
136
|
+
const stat = await statSource(absPath, { note, filesAfterNote })
|
|
92
137
|
|
|
93
138
|
const config = await loadConfig(configDir)
|
|
94
139
|
const { values: settings, source } = resolveSettings(options, config, {
|
|
@@ -138,14 +183,14 @@ export async function runUpload(filePath, options = {}, deps = {}) {
|
|
|
138
183
|
const resuming = Boolean(state)
|
|
139
184
|
|
|
140
185
|
// Naming the way back rather than a flag to drop: the destination may have come from the
|
|
141
|
-
// command line or from the stored setting, and "run again without --
|
|
186
|
+
// command line or from the stored setting, and "run again without --chat" is no help to
|
|
142
187
|
// someone who never typed one. Pointing at the chat itself is right either way.
|
|
143
188
|
if (resuming && state.chat !== String(chat)) {
|
|
144
189
|
const file = stateFile(key, configDir)
|
|
145
190
|
throw new Error(
|
|
146
191
|
`This unfinished backup is going to ${state.chat}, but the current command targets ${chat} — ` +
|
|
147
192
|
`a single backup cannot be split across two destinations. Run again with ` +
|
|
148
|
-
`--
|
|
193
|
+
`--chat ${state.chat} to carry on sending there, or delete ${file} and run again to ` +
|
|
149
194
|
`start a new backup in ${chat}.`,
|
|
150
195
|
)
|
|
151
196
|
}
|
|
@@ -299,6 +344,7 @@ export async function runUpload(filePath, options = {}, deps = {}) {
|
|
|
299
344
|
name: path.basename(absPath),
|
|
300
345
|
size: stat.size,
|
|
301
346
|
chunkSize,
|
|
347
|
+
note,
|
|
302
348
|
chunks: chunks.map((chunk) => ({ i: chunk.i, ...state.done[String(chunk.i)] })),
|
|
303
349
|
})
|
|
304
350
|
|
|
@@ -311,6 +357,7 @@ export async function runUpload(filePath, options = {}, deps = {}) {
|
|
|
311
357
|
size: manifest.size,
|
|
312
358
|
chunks: manifest.chunks.length,
|
|
313
359
|
createdAt: manifest.createdAt,
|
|
360
|
+
note: manifest.note ?? null,
|
|
314
361
|
}),
|
|
315
362
|
})
|
|
316
363
|
|
|
@@ -325,3 +372,198 @@ export async function runUpload(filePath, options = {}, deps = {}) {
|
|
|
325
372
|
)
|
|
326
373
|
}
|
|
327
374
|
}
|
|
375
|
+
|
|
376
|
+
// `telstore a b c` is three backups, not one: each file keeps its own id, its own manifest and
|
|
377
|
+
// its own resumable record, so a batch is exactly what running the command three times would
|
|
378
|
+
// have produced — minus two logins. The connection is the one thing worth sharing, and it is
|
|
379
|
+
// shared through the same deps seam the tests drive, so runUpload stays the only caller of
|
|
380
|
+
// connect and nothing here has to know what a client is.
|
|
381
|
+
export async function runUploads(filePaths, options = {}, deps = {}) {
|
|
382
|
+
const {
|
|
383
|
+
connect = realConnect,
|
|
384
|
+
disconnect = (client) => client.destroy(),
|
|
385
|
+
configDir = defaultConfigDir(),
|
|
386
|
+
writeErr = (line) => process.stderr.write(line),
|
|
387
|
+
log: writeLog = (line) => console.log(line),
|
|
388
|
+
silent = false,
|
|
389
|
+
onFileDone = () => {},
|
|
390
|
+
confirm = askConfirm,
|
|
391
|
+
interactive = () => Boolean(process.stdin.isTTY),
|
|
392
|
+
filesAfterNote = false,
|
|
393
|
+
} = deps
|
|
394
|
+
|
|
395
|
+
const log = silent ? () => {} : writeLog
|
|
396
|
+
const warn = silent ? () => {} : writeErr
|
|
397
|
+
|
|
398
|
+
// One note covers the whole run, so a note telstore cannot send is one mistake, not one per
|
|
399
|
+
// file. Left to runUpload it would be caught per file and swallowed into the summary as a
|
|
400
|
+
// failed row, after the files ahead of it had already gone out labelled with nothing.
|
|
401
|
+
const note = parseNote(options.note)
|
|
402
|
+
|
|
403
|
+
// What was typed and what will be sent are two different lists once a folder or a pattern is
|
|
404
|
+
// allowed: resolve them here, before anything else has an opinion about them.
|
|
405
|
+
const { paths: found, skipped } = await expandSources(filePaths)
|
|
406
|
+
const paths = found.map((filePath) => path.resolve(filePath))
|
|
407
|
+
|
|
408
|
+
// Something inside a named folder that is not going to be uploaded is still something the
|
|
409
|
+
// user pointed at, so it is said out loud rather than quietly missing from the list.
|
|
410
|
+
const folders = skipped.filter((entry) => entry.reason === 'directory')
|
|
411
|
+
|
|
412
|
+
if (folders.length > 0) {
|
|
413
|
+
warn(
|
|
414
|
+
`\ntelstore reads one level down, so these folders were left alone: ` +
|
|
415
|
+
`${folders.map((entry) => path.basename(entry.path)).join(', ')}. ` +
|
|
416
|
+
'Name one of them to upload what is inside it.\n',
|
|
417
|
+
)
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
for (const entry of skipped) {
|
|
421
|
+
if (entry.reason !== 'directory') warn(`\n${entry.path} was skipped: ${entry.reason}.\n`)
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// A single file is the common case and must read exactly as it did before this existed:
|
|
425
|
+
// no batch heading, no question, no summary, and an error that reaches the caller rather
|
|
426
|
+
// than a report.
|
|
427
|
+
if (paths.length === 1) {
|
|
428
|
+
const { id, chunks } = await runUpload(paths[0], options, deps)
|
|
429
|
+
return { results: [{ path: paths[0], id, chunks }], failed: 0 }
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// Everything that can be known before the first byte goes out is settled here. A typo in the
|
|
433
|
+
// fourth name must not surface an hour into the third file, and a destination nobody set is
|
|
434
|
+
// one problem, not one per file — the report at the end is for what only the transfer can
|
|
435
|
+
// discover.
|
|
436
|
+
const duplicate = paths.find((absPath, index) => paths.indexOf(absPath) !== index)
|
|
437
|
+
|
|
438
|
+
if (duplicate) {
|
|
439
|
+
throw new Error(
|
|
440
|
+
`${duplicate} is named twice — a folder or a pattern can pick up a file that was ` +
|
|
441
|
+
'named on its own as well. Uploading one file twice would make two backups of the ' +
|
|
442
|
+
'same bytes, each with its own id: name it once, or run telstore again afterwards if ' +
|
|
443
|
+
'a second copy is really what you want.',
|
|
444
|
+
)
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
const config = await loadConfig(configDir)
|
|
448
|
+
|
|
449
|
+
// The login is checked here rather than left to the first connect: reported from inside the
|
|
450
|
+
// loop it would arrive once per file, each one having already written a state record for a
|
|
451
|
+
// backup that never sent a byte.
|
|
452
|
+
assertLoggedIn(config)
|
|
453
|
+
|
|
454
|
+
const { values: settings } = resolveSettings(options, config, { file: configFile(configDir) })
|
|
455
|
+
const chat = requireChat(settings)
|
|
456
|
+
|
|
457
|
+
const sizes = []
|
|
458
|
+
|
|
459
|
+
for (const absPath of paths) sizes.push((await statSource(absPath, { note, filesAfterNote })).size)
|
|
460
|
+
|
|
461
|
+
// The last thing before the first byte. A folder or a pattern hands telstore a list nobody
|
|
462
|
+
// has read yet, and even a hand-typed one is worth seeing added up: this is the moment where
|
|
463
|
+
// "23 files, 180 GB, to @family_photos" is still a question rather than an afternoon.
|
|
464
|
+
if (!options.yes) {
|
|
465
|
+
if (!interactive()) {
|
|
466
|
+
throw new Error(
|
|
467
|
+
`${paths.length} files to upload, and no terminal to confirm that in. Run again with ` +
|
|
468
|
+
'--yes to upload them without being asked.',
|
|
469
|
+
)
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
for (const line of listingLines(paths, sizes, chat)) log(line)
|
|
473
|
+
|
|
474
|
+
if (!(await confirm(`Upload these ${paths.length} files? [y/N] `))) {
|
|
475
|
+
throw new Error('Cancelled on request.')
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
let shared = null
|
|
480
|
+
const perFile = {
|
|
481
|
+
...deps,
|
|
482
|
+
connect: async (theirConfig, connectOptions) =>
|
|
483
|
+
(shared ??= await connect(theirConfig, connectOptions)),
|
|
484
|
+
disconnect: async () => {},
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
const results = []
|
|
488
|
+
|
|
489
|
+
try {
|
|
490
|
+
for (const [index, absPath] of paths.entries()) {
|
|
491
|
+
if (index > 0) log('')
|
|
492
|
+
log(`[${index + 1}/${paths.length}] ${path.basename(absPath)}`)
|
|
493
|
+
|
|
494
|
+
let result
|
|
495
|
+
try {
|
|
496
|
+
const { id, chunks } = await runUpload(absPath, options, perFile)
|
|
497
|
+
result = { path: absPath, id, chunks }
|
|
498
|
+
} catch (err) {
|
|
499
|
+
// One file's trouble is that file's trouble. Stopping here would leave the files
|
|
500
|
+
// named after it untouched and unmentioned, which is the batch equivalent of the
|
|
501
|
+
// silent drop this command exists to end — so it is recorded and named in the
|
|
502
|
+
// summary, and the exit code carries it out to the shell.
|
|
503
|
+
//
|
|
504
|
+
// It is also said out loud here and now. Waiting for the summary would leave the bar
|
|
505
|
+
// of the next file scrolling for an hour over a failure nobody had been told about.
|
|
506
|
+
result = { path: absPath, error: err.message }
|
|
507
|
+
warn(`\n${path.basename(absPath)} failed: ${err.message}\n`)
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
results.push(result)
|
|
511
|
+
onFileDone(result)
|
|
512
|
+
}
|
|
513
|
+
} finally {
|
|
514
|
+
if (shared) {
|
|
515
|
+
await closeQuietly(shared, disconnect, (err) =>
|
|
516
|
+
warn(`\nWarning: could not close the Telegram connection: ${err.message}\n`),
|
|
517
|
+
)
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
const failed = results.filter((result) => result.error).length
|
|
522
|
+
|
|
523
|
+
log('')
|
|
524
|
+
for (const line of summaryLines(results, failed)) log(line)
|
|
525
|
+
|
|
526
|
+
return { results, failed }
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// What the batch is about to do, in the shape the summary will report it afterwards: the same
|
|
530
|
+
// names, the same order, so the two lists can be read against each other.
|
|
531
|
+
function listingLines(paths, sizes, chat) {
|
|
532
|
+
const names = paths.map((absPath) => path.basename(absPath))
|
|
533
|
+
const amounts = sizes.map(formatBytes)
|
|
534
|
+
const width = Math.max(...names.map((name) => name.length))
|
|
535
|
+
const amountWidth = Math.max(...amounts.map((amount) => amount.length))
|
|
536
|
+
const total = sizes.reduce((sum, size) => sum + size, 0)
|
|
537
|
+
|
|
538
|
+
return [
|
|
539
|
+
`${paths.length} files, ${formatBytes(total)}, to ${describeChat(chat)}`,
|
|
540
|
+
'',
|
|
541
|
+
...names.map((name, i) => ` ${name.padEnd(width)} ${amounts[i].padStart(amountWidth)}`),
|
|
542
|
+
'',
|
|
543
|
+
]
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// The one place a batch says how it went. Every file gets a line whether it worked or not:
|
|
547
|
+
// a name missing from this list would be a file nobody could tell the fate of.
|
|
548
|
+
function summaryLines(results, failed) {
|
|
549
|
+
const width = Math.max(...results.map((result) => path.basename(result.path).length))
|
|
550
|
+
const uploaded = results.length - failed
|
|
551
|
+
const lines = [`${results.length} files: ${uploaded} uploaded, ${failed} failed.`, '']
|
|
552
|
+
|
|
553
|
+
for (const result of results) {
|
|
554
|
+
const name = path.basename(result.path).padEnd(width)
|
|
555
|
+
|
|
556
|
+
if (result.error) {
|
|
557
|
+
lines.push(` ${name} failed: ${result.error}`)
|
|
558
|
+
continue
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
lines.push(` ${name} ${result.id} (${result.chunks} chunk${result.chunks === 1 ? '' : 's'})`)
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
if (uploaded > 0) {
|
|
565
|
+
lines.push('', 'Restore with: npx telstore restore <backup-id>')
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
return lines
|
|
569
|
+
}
|
package/src/manifest.js
CHANGED
|
@@ -19,7 +19,15 @@ export function manifestFileName(id) {
|
|
|
19
19
|
return `${id}.manifest.json`
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
export function buildManifest({
|
|
22
|
+
export function buildManifest({
|
|
23
|
+
id,
|
|
24
|
+
name,
|
|
25
|
+
size,
|
|
26
|
+
chunkSize,
|
|
27
|
+
chunks,
|
|
28
|
+
createdAt = new Date().toISOString(),
|
|
29
|
+
note = null,
|
|
30
|
+
}) {
|
|
23
31
|
return {
|
|
24
32
|
v: MANIFEST_VERSION,
|
|
25
33
|
id,
|
|
@@ -27,6 +35,9 @@ export function buildManifest({ id, name, size, chunkSize, chunks, createdAt = n
|
|
|
27
35
|
size,
|
|
28
36
|
chunkSize,
|
|
29
37
|
createdAt,
|
|
38
|
+
// Absent rather than null when there is none: a manifest without a note has to be the
|
|
39
|
+
// same file telstore wrote before the flag existed, down to the bytes.
|
|
40
|
+
...(note ? { note } : {}),
|
|
30
41
|
chunks: [...chunks]
|
|
31
42
|
.sort((a, b) => a.i - b.i)
|
|
32
43
|
.map(({ i, msgId, size: chunkBytes, sha256 }) => ({ i, msgId, size: chunkBytes, sha256 })),
|
|
@@ -105,6 +116,15 @@ export function parseManifest(input) {
|
|
|
105
116
|
)
|
|
106
117
|
}
|
|
107
118
|
|
|
119
|
+
// The note is decoration — nothing restores differently because of it — but a manifest is
|
|
120
|
+
// a file a person can edit and send back, and a field holding something other than what it
|
|
121
|
+
// claims to be is the point where telstore stops reading rather than guesses.
|
|
122
|
+
if (manifest.note !== undefined && typeof manifest.note !== 'string') {
|
|
123
|
+
throw new Error(
|
|
124
|
+
`Manifest records a note of ${JSON.stringify(manifest.note)}, which is not text.`,
|
|
125
|
+
)
|
|
126
|
+
}
|
|
127
|
+
|
|
108
128
|
if (!Number.isSafeInteger(manifest.chunkSize) || manifest.chunkSize < 1) {
|
|
109
129
|
throw new Error(
|
|
110
130
|
`Manifest records a chunk size of ${JSON.stringify(manifest.chunkSize)}, ` +
|
package/src/settings.js
CHANGED
|
@@ -35,7 +35,7 @@ function wholeNumber(raw, where, minimum, maximum, explanation) {
|
|
|
35
35
|
// which is why chunkSize prints bytes and leaves "1.8 GB" to `describe`.
|
|
36
36
|
export const SETTINGS = {
|
|
37
37
|
chat: {
|
|
38
|
-
flag: '
|
|
38
|
+
flag: 'chat',
|
|
39
39
|
default: null,
|
|
40
40
|
// A number here is a chat id, and a chat id is a whole number. 42.5 would otherwise
|
|
41
41
|
// slip through as the string "42.5" and only fail much later, at Telegram, as a chat
|
|
@@ -129,9 +129,9 @@ export const SETTINGS = {
|
|
|
129
129
|
|
|
130
130
|
export const SETTING_KEYS = Object.keys(SETTINGS)
|
|
131
131
|
|
|
132
|
-
// Someone who has been typing `--
|
|
133
|
-
// config command too. Accept the flag spelling as a way in, and canonicalise on
|
|
134
|
-
// disk so the file only ever holds one name per setting.
|
|
132
|
+
// Someone who has been typing `--chunk-size` and `--upload-concurrency` for a week will type
|
|
133
|
+
// them at the config command too. Accept the flag spelling as a way in, and canonicalise on
|
|
134
|
+
// the way to disk so the file only ever holds one name per setting.
|
|
135
135
|
const ALIASES = new Map()
|
|
136
136
|
|
|
137
137
|
for (const [key, spec] of Object.entries(SETTINGS)) {
|
|
@@ -150,7 +150,7 @@ export function isManagedByLogin(input) {
|
|
|
150
150
|
// Where a value came from, spelled the way the user would recognise it. A stored value that
|
|
151
151
|
// fails to parse must not be reported as a bad flag: nobody typed a flag, and telling them
|
|
152
152
|
// to fix one sends them off after the wrong thing — the same mistake the old
|
|
153
|
-
// "run again without --
|
|
153
|
+
// "run again without --chat" advice made.
|
|
154
154
|
function origin(key, from, file) {
|
|
155
155
|
return from === 'flag' ? `--${SETTINGS[key].flag}` : `${key} in ${file}`
|
|
156
156
|
}
|
|
@@ -197,7 +197,7 @@ export function requireChat(values) {
|
|
|
197
197
|
if (values.chat === null || values.chat === undefined) {
|
|
198
198
|
throw new Error(
|
|
199
199
|
'No destination set — run "npx telstore config chat @my_backups" to set one ' +
|
|
200
|
-
'("config chat me" for Saved Messages), or pass --
|
|
200
|
+
'("config chat me" for Saved Messages), or pass --chat to choose one for this run.',
|
|
201
201
|
)
|
|
202
202
|
}
|
|
203
203
|
|
package/src/sources.js
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
|
|
4
|
+
// What a command line names and what telstore uploads are not the same list: a folder stands
|
|
5
|
+
// for the files inside it, and a pattern for the names that match. Both are resolved here,
|
|
6
|
+
// before anything connects, so the run is decided against the disk rather than discovered one
|
|
7
|
+
// file at a time.
|
|
8
|
+
//
|
|
9
|
+
// The shell expands `*` long before node sees it, and that is the expansion telstore prefers —
|
|
10
|
+
// this one only runs on a pattern that arrived intact: quoted, or left alone by a shell that
|
|
11
|
+
// found nothing to expand it to. Whichever did the work, the rule is the same one glob has
|
|
12
|
+
// always had, so a quoted pattern is not a different feature with different results.
|
|
13
|
+
|
|
14
|
+
// One level, because a folder is a place someone put files, not a tree telstore may walk on
|
|
15
|
+
// its own: a recursive sweep of a home directory is thousands of backups nobody asked for.
|
|
16
|
+
const HIDDEN = /^\./
|
|
17
|
+
|
|
18
|
+
function isPattern(text) {
|
|
19
|
+
return /[*?]/.test(text)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// `*` and `?` mean what they mean in a shell; everything else is a literal, including the
|
|
23
|
+
// regex metacharacters a file name is perfectly allowed to contain.
|
|
24
|
+
function patternToRegExp(pattern) {
|
|
25
|
+
const body = pattern
|
|
26
|
+
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
27
|
+
.replace(/[*?]/g, (char) => (char === '*' ? '[^/]*' : '[^/]'))
|
|
28
|
+
|
|
29
|
+
return new RegExp(`^${body}$`)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// A directory entry is only a source if it is a file — following symlinks, since a link to a
|
|
33
|
+
// file is a perfectly ordinary thing to keep in a folder of backups. Anything that cannot be
|
|
34
|
+
// read at all is named rather than dropped.
|
|
35
|
+
async function classify(full) {
|
|
36
|
+
try {
|
|
37
|
+
const stat = await fs.stat(full)
|
|
38
|
+
|
|
39
|
+
if (stat.isFile()) return { kind: 'file' }
|
|
40
|
+
if (stat.isDirectory()) return { kind: 'skip', reason: 'directory' }
|
|
41
|
+
|
|
42
|
+
return { kind: 'skip', reason: 'not a file' }
|
|
43
|
+
} catch (err) {
|
|
44
|
+
return { kind: 'skip', reason: `cannot be read (${err.code ?? err.message})` }
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function readEntries(dir) {
|
|
49
|
+
return (await fs.readdir(dir)).sort()
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function expandDirectory(dir, skipped) {
|
|
53
|
+
const paths = []
|
|
54
|
+
const folders = []
|
|
55
|
+
|
|
56
|
+
for (const name of await readEntries(dir)) {
|
|
57
|
+
if (HIDDEN.test(name)) continue
|
|
58
|
+
|
|
59
|
+
const full = path.join(dir, name)
|
|
60
|
+
const { kind, reason } = await classify(full)
|
|
61
|
+
|
|
62
|
+
if (kind === 'file') {
|
|
63
|
+
paths.push(full)
|
|
64
|
+
continue
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (reason === 'directory') folders.push(name)
|
|
68
|
+
skipped.push({ path: full, reason })
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (paths.length === 0) {
|
|
72
|
+
throw new Error(
|
|
73
|
+
`${dir} has no files to upload — telstore reads one level down and leaves hidden files ` +
|
|
74
|
+
'alone.' +
|
|
75
|
+
(folders.length > 0
|
|
76
|
+
? ` telstore does not walk into the folders inside it (${folders.join(', ')}) — ` +
|
|
77
|
+
'name one of those instead.'
|
|
78
|
+
: ''),
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return paths
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function expandPattern(pattern) {
|
|
86
|
+
const dir = path.dirname(pattern)
|
|
87
|
+
const base = path.basename(pattern)
|
|
88
|
+
|
|
89
|
+
if (isPattern(dir)) {
|
|
90
|
+
throw new Error(
|
|
91
|
+
`Wildcards only work in the last part of a path, and ${pattern} has one earlier: ` +
|
|
92
|
+
'telstore does not walk folders looking for matches.',
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
let entries
|
|
97
|
+
|
|
98
|
+
try {
|
|
99
|
+
entries = await readEntries(dir)
|
|
100
|
+
} catch (err) {
|
|
101
|
+
if (err.code === 'ENOENT') {
|
|
102
|
+
throw new Error(`No file matches ${pattern}: ${dir} does not exist.`)
|
|
103
|
+
}
|
|
104
|
+
throw err
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const matches = patternToRegExp(base)
|
|
108
|
+
const paths = []
|
|
109
|
+
|
|
110
|
+
for (const name of entries) {
|
|
111
|
+
// A pattern that does not start with a dot does not go looking for hidden files, the same
|
|
112
|
+
// rule every shell uses — otherwise `telstore '*'` in a home directory sends .ssh upwards.
|
|
113
|
+
if (HIDDEN.test(name) && !HIDDEN.test(base)) continue
|
|
114
|
+
if (!matches.test(name)) continue
|
|
115
|
+
|
|
116
|
+
const full = path.join(dir, name)
|
|
117
|
+
if ((await classify(full)).kind === 'file') paths.push(full)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (paths.length === 0) {
|
|
121
|
+
throw new Error(
|
|
122
|
+
`No file matches ${pattern} in ${dir}. Note that the shell usually expands patterns ` +
|
|
123
|
+
'itself, so a quoted one reaches telstore exactly as typed.',
|
|
124
|
+
)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return paths
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Turns the positionals of an upload into the list of files it will actually send.
|
|
132
|
+
* Returns `{ paths, skipped }`; a name that is neither a folder nor a pattern is handed on
|
|
133
|
+
* untouched, missing ones included, so "File does not exist" keeps coming from one place.
|
|
134
|
+
*/
|
|
135
|
+
export async function expandSources(args) {
|
|
136
|
+
const paths = []
|
|
137
|
+
const skipped = []
|
|
138
|
+
|
|
139
|
+
for (const arg of args) {
|
|
140
|
+
if (isPattern(arg)) {
|
|
141
|
+
paths.push(...(await expandPattern(arg)))
|
|
142
|
+
continue
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
let stat = null
|
|
146
|
+
|
|
147
|
+
try {
|
|
148
|
+
stat = await fs.stat(arg)
|
|
149
|
+
} catch {
|
|
150
|
+
paths.push(arg)
|
|
151
|
+
continue
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (stat.isDirectory()) {
|
|
155
|
+
paths.push(...(await expandDirectory(arg, skipped)))
|
|
156
|
+
continue
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
paths.push(arg)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return { paths, skipped }
|
|
163
|
+
}
|