telstore 0.1.6 → 0.1.8

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.
@@ -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
- let stat
82
- try {
83
- stat = await fs.stat(absPath)
84
- } catch (err) {
85
- if (err.code === 'ENOENT') throw new Error(`File does not exist: ${absPath}`)
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
- if (!stat.isFile()) {
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 --to" is no help to
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
- `--to ${state.chat} to carry on sending there, or delete ${file} and run again to ` +
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/downloader.js CHANGED
@@ -43,7 +43,10 @@ async function readExactly(fd, length, position) {
43
43
  // the better check anyway: a slice written at the wrong offset, two slices overlapping, or
44
44
  // one silently skipped all show up here. It does not prove the bytes reached the platter —
45
45
  // this read may well be served from the page cache — it proves the assembly.
46
- async function hashRange(fd, offset, length) {
46
+ //
47
+ // Exported because a resumed restore asks the same question of a .partial left by an earlier
48
+ // run. A second copy of it in restore.js is how two definitions of one check start to differ.
49
+ export async function hashRange(fd, offset, length) {
47
50
  const hash = createHash('sha256')
48
51
 
49
52
  for (let at = 0; at < length; at += HASH_READ_SIZE) {
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({ id, name, size, chunkSize, chunks, createdAt = new Date().toISOString() }) {
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/progress.js CHANGED
@@ -1,6 +1,7 @@
1
1
  const UNITS = ['B', 'KB', 'MB', 'GB', 'TB']
2
2
 
3
3
  export function formatBytes(n) {
4
+ if (!Number.isFinite(n)) return '--'
4
5
  if (n < 1024) return `${n} B`
5
6
 
6
7
  let value = n
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: 'to',
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 `--to` and `--chunk-size` for a week will type them at the
133
- // config command too. Accept the flag spelling as a way in, and canonicalise on the way to
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 --to" advice made.
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 --to to choose one for this run.',
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
+ }