telstore 0.1.8 → 0.1.10

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.
@@ -0,0 +1,311 @@
1
+ import { promises as fs } from 'node:fs'
2
+ import os from 'node:os'
3
+ import path from 'node:path'
4
+
5
+ import { describeChat } from '../chat.js'
6
+ import { configFile, defaultConfigDir, loadConfig } from '../config.js'
7
+ import { askConfirm } from '../confirm.js'
8
+ import { deleteCommand, shellArg } from '../shell.js'
9
+ import { listRestores, listStates } from '../state.js'
10
+
11
+ // Nothing here may reach src/client.js, directly or through a command that does: `down` is
12
+ // run on a machine somebody is finished with, often one with no network and sometimes one
13
+ // they no longer trust. It opens no socket, and test/bin.test.js measures that it stays
14
+ // that way — a single import of status.js would put teleproto back in the path.
15
+
16
+ const LABEL_WIDTH = 'Destination'.length + 2
17
+
18
+ function row(label, value) {
19
+ return ` ${label.padEnd(LABEL_WIDTH)}${value}`
20
+ }
21
+
22
+ // status prints this same count about this same directory. It cannot be imported from there
23
+ // without dragging teleproto in, so it is copied — and copied exactly, because two commands
24
+ // disagreeing about what is unfinished on one machine is worse than either wording alone.
25
+ function unfinishedCount(uploads, restores) {
26
+ const parts = []
27
+
28
+ if (uploads > 0) parts.push(`${uploads} upload${uploads === 1 ? '' : 's'}`)
29
+ if (restores > 0) parts.push(`${restores} restore${restores === 1 ? '' : 's'}`)
30
+
31
+ return parts.length === 0 ? 'none' : parts.join(', ')
32
+ }
33
+
34
+ // A recursive remove is the one mistake in this command nobody could apologise for, and the
35
+ // path it walks is derived rather than typed — os.homedir() reading an empty HOME is all it
36
+ // takes. These two are not reachable by any correct call, which is exactly why they are
37
+ // worth refusing by name rather than trusting the caller.
38
+ function refuseDangerousTarget(dir) {
39
+ if (dir === os.homedir() || path.dirname(dir) === dir) {
40
+ throw new Error(
41
+ `telstore refuses to remove ${dir}: that is a home directory or a filesystem root, ` +
42
+ 'not a telstore config directory. Nothing was removed.',
43
+ )
44
+ }
45
+ }
46
+
47
+ // The config is read to describe it, never to trust it. loadConfig throws on a file that is
48
+ // corrupt, that holds a `settings` which is not a group, or that holds both a sealed session
49
+ // and a plain one — and its own advice for all three is to delete the file and log in again.
50
+ // This is the command that does that, so it is the one command a broken config must not stop.
51
+ async function describeConfig(configDir) {
52
+ try {
53
+ return { config: await loadConfig(configDir), error: null }
54
+ } catch (err) {
55
+ return { config: {}, error: err.message }
56
+ }
57
+ }
58
+
59
+ // The error itself is deliberately not quoted here. Every message loadConfig throws names the
60
+ // config file, every path to it contains a dot, and a first-sentence split therefore cuts
61
+ // "/home/sho/.telstore/config.json is not valid JSON" down to "/home/sho/" — a mangled path
62
+ // presented as a diagnosis. The whole message is no better: its advice is "delete the file and
63
+ // log in again", which is what this command is in the middle of doing.
64
+ function sessionLine(config, error) {
65
+ if (error) return 'cannot be read — whatever is in that file, it goes with the rest'
66
+ if (config.sealed) return 'sealed — the api_id and api_hash are inside it, so they go too'
67
+ if (config.session) return 'stored here in plain text — api_id and api_hash go with it'
68
+
69
+ return 'not logged in'
70
+ }
71
+
72
+ // What an unfinished upload was of — either kind of record, which is why this is not the
73
+ // `describeStreamSource` `status` has: that one answers only for a stream record, and one
74
+ // name over two answers is how the two came to disagree. A stream record has no path — its
75
+ // bytes came from a command's stdout — and carries the name the backup was given instead.
76
+ // This listing exists so that nothing goes unnamed before a recursive remove, so a row
77
+ // reading "undefined" is the exact failure it is here to prevent.
78
+ //
79
+ // Blank is the same failure wearing a string's clothes: a record holding `name: " "` would
80
+ // print ` (a command's output)` — a row that names nothing, in the one command whose job
81
+ // is that nothing goes unnamed. Trimmed for that, exactly as `status` trims, and both fields
82
+ // get the rule because a whitespace path is no more a name than a whitespace name is.
83
+ function describeRecordSource(state) {
84
+ if (typeof state.path === 'string' && state.path.trim() !== '') return state.path
85
+ if (typeof state.name === 'string' && state.name.trim() !== '') {
86
+ return `${state.name} (a command's output)`
87
+ }
88
+
89
+ return 'a record that does not say what it was backing up'
90
+ }
91
+
92
+ // The last thing anything anywhere will say about those chunks. down.md prints a resume
93
+ // command for a .partial "because this is the last time anything will mention that file", and
94
+ // a stream record is the sharper case: it cannot be re-run onto — those bytes have gone past —
95
+ // so once this record is gone nothing on this machine lists those message ids and no manifest
96
+ // in the chat names them. Printing the command removes nothing and opens no socket; it is the
97
+ // naming this whole listing exists for, done for something that lives on Telegram.
98
+ //
99
+ // The chat is named for the reason status names it, and the line itself is built by
100
+ // `deleteCommand` in shell.js so that all four places that print it spell `--chat` the same
101
+ // way. The check in front of it is this command's own decision and stays here: a record that
102
+ // cannot say where its chunks went gets no command at all rather than the chatless one that
103
+ // function would otherwise hand back, because here that would be a guess — `--chat` missing is
104
+ // not `--chat` empty, and runDelete would take it as no destination and resolve one from
105
+ // config. The id needs no guard — the listing above measures state.id.length for its own
106
+ // column, so a record without one never reaches this line.
107
+ function removeCommand(state) {
108
+ const chat = state.chat === null || state.chat === undefined ? '' : String(state.chat).trim()
109
+
110
+ if (chat === '') return null
111
+
112
+ return deleteCommand(state.id, chat)
113
+ }
114
+
115
+ // Only the ones actually on disk. A restore record survives a .partial that was deleted by
116
+ // hand, and pointing at a file that is not there sends somebody looking for nothing.
117
+ async function strandedPartials(restores) {
118
+ const found = []
119
+
120
+ for (const { record } of restores) {
121
+ const partial = `${record.target}.partial`
122
+
123
+ try {
124
+ await fs.stat(partial)
125
+ found.push({ partial, record })
126
+ } catch {
127
+ // Nothing there to tell them about.
128
+ }
129
+ }
130
+
131
+ return found
132
+ }
133
+
134
+ // Everything in the directory that telstore did not put there. The whole directory goes
135
+ // either way — it is what was asked for — but delete's rule holds here too: nothing is
136
+ // removed unnamed.
137
+ //
138
+ // `tmp` is telstore's too: a stream upload borrows one chunk of disk at a time under
139
+ // ~/.telstore rather than /tmp, which is tmpfs on many distributions and would turn a chunk
140
+ // size into a memory limit. Naming it here would be down reporting its own working directory
141
+ // as a stranger's file — and if a run died mid-chunk it may hold up to one chunk, which the
142
+ // recursive remove below takes with everything else.
143
+ async function foreignEntries(configDir) {
144
+ const ours = new Set(['config.json', 'config.json.tmp', 'state', 'tmp'])
145
+
146
+ try {
147
+ return (await fs.readdir(configDir)).filter((name) => !ours.has(name))
148
+ } catch {
149
+ return []
150
+ }
151
+ }
152
+
153
+ export async function runDown(args = [], options = {}, deps = {}) {
154
+ const {
155
+ configDir = defaultConfigDir(),
156
+ confirm = askConfirm,
157
+ interactive = () => Boolean(process.stdin.isTTY),
158
+ log = (line) => console.log(line),
159
+ } = deps
160
+
161
+ // `telstore down telstore-20260905-7f3a91` is the plausible typo — somebody reaching for
162
+ // the command that removes one backup. Obeying it would wipe the machine instead.
163
+ if (args.length > 0) {
164
+ throw new Error(
165
+ `down takes no arguments, but got "${args.join(' ')}". It removes everything on this ` +
166
+ 'machine or nothing. To remove one backup from Telegram, use ' +
167
+ '"npx telstore delete <backup-id>".',
168
+ )
169
+ }
170
+
171
+ refuseDangerousTarget(configDir)
172
+
173
+ try {
174
+ await fs.stat(configDir)
175
+ } catch (err) {
176
+ if (err.code !== 'ENOENT') throw err
177
+
178
+ log(`Nothing to remove: ${configDir} is not there.`)
179
+ return { removed: false, dir: configDir }
180
+ }
181
+
182
+ const { config, error } = await describeConfig(configDir)
183
+ const uploads = await listStates(configDir)
184
+ const restores = await listRestores(configDir)
185
+ const stranded = await strandedPartials(restores)
186
+ const foreign = await foreignEntries(configDir)
187
+ const chat = config.settings?.chat ?? null
188
+
189
+ log('This removes everything telstore keeps on this machine.')
190
+ log('')
191
+ log(row('Directory', configDir))
192
+ log(row('Session', sessionLine(config, error)))
193
+ if (chat !== null) log(row('Destination', describeChat(chat)))
194
+ log(row('Unfinished', unfinishedCount(uploads.length, restores.length)))
195
+ if (foreign.length > 0) log(row('Also there', foreign.join(', ')))
196
+
197
+ // The expensive half of what is about to go. An upload record is what lets a second run
198
+ // keep the same backupId and skip the chunks already sent; without it the same file goes
199
+ // up again as a new backup, and the chunks already in the chat stay there under an id
200
+ // nothing on this machine remembers. So the ids are said out loud while someone can read
201
+ // them. A restore record costs nothing to lose — the .partial resumes without it — and is
202
+ // dealt with on the way out instead.
203
+ if (uploads.length > 0) {
204
+ const width = Math.max(...uploads.map(({ state }) => state.id.length))
205
+
206
+ log('')
207
+ log('These uploads have not finished. Their records are the only thing that lets a second')
208
+ log('run carry on: without them the same file goes up again as a new backup, and the chunks')
209
+ log('already sent stay in the chat under these ids and nothing else:')
210
+ log('')
211
+ for (const { state } of uploads) {
212
+ log(` ${state.id.padEnd(width)} ${describeRecordSource(state)}`)
213
+ }
214
+
215
+ const streams = uploads.filter(({ state }) => state.kind === 'stream')
216
+
217
+ if (streams.length > 0) {
218
+ log('')
219
+ log('The ones marked as a command\'s output cannot be carried on at all: those bytes have')
220
+ log('gone past, and a second run cuts them differently. Their records are the only list of')
221
+ log('the chunks those runs left in the chat, and nothing replaces them — so this is the')
222
+ log('last chance to copy the commands that remove those chunks:')
223
+ log('')
224
+
225
+ for (const { state } of streams) {
226
+ const command = removeCommand(state)
227
+
228
+ if (command === null) {
229
+ log(` ${state.id}`)
230
+ log(' this record does not say which chat its chunks went to, so there is no')
231
+ log(' command that could reach them')
232
+ continue
233
+ }
234
+
235
+ log(` ${command}`)
236
+ }
237
+ }
238
+ }
239
+
240
+ log('')
241
+ log('Nothing on Telegram is touched: every backup stays where it is.')
242
+
243
+ if (!options.yes) {
244
+ if (!interactive()) {
245
+ throw new Error(
246
+ `Nothing was removed: there is no terminal to confirm in. Run again with --yes to ` +
247
+ `remove ${configDir} without being asked.`,
248
+ )
249
+ }
250
+
251
+ log('')
252
+
253
+ if (!(await confirm(`This cannot be undone. Remove ${configDir}? [y/N] `))) {
254
+ throw new Error('Cancelled on request.')
255
+ }
256
+ }
257
+
258
+ try {
259
+ await fs.rm(configDir, { recursive: true, force: true })
260
+ } catch (err) {
261
+ // fs.rm walks the tree, so a permission error halfway leaves some of it gone. Reporting
262
+ // that as a success is the one thing this project never does.
263
+ throw new Error(
264
+ `Could not remove ${configDir}: ${err.message}. Some of it may already be gone — ` +
265
+ 'run "npx telstore down" again once the permissions allow it.',
266
+ )
267
+ }
268
+
269
+ log('')
270
+ log(`Done. Removed ${configDir}.`)
271
+
272
+ if (config.session || config.sealed || error) {
273
+ log(
274
+ 'Note: this only deletes the local copy — the session is still alive on Telegram\'s ' +
275
+ 'side. To revoke access for good, open Telegram → Settings → Devices (Active ' +
276
+ 'sessions) and terminate that session.',
277
+ )
278
+ }
279
+
280
+ if (config.apiId || config.apiHash || config.sealed) {
281
+ log(
282
+ 'api_id and api_hash are gone too, which logout would have kept: the next ' +
283
+ '"npx telstore login" asks for them again (my.telegram.org).',
284
+ )
285
+ }
286
+
287
+ log(
288
+ chat === null
289
+ ? 'Your backups are still on Telegram. Log in again and "npx telstore list" finds them.'
290
+ : `Your backups are still in ${describeChat(chat)} — log in again and ` +
291
+ '"npx telstore list" finds them.',
292
+ )
293
+
294
+ // The .partial is the user's data, and down removes what was asked for and nothing else.
295
+ // Unlike after a delete it can still be finished: the chunks and the manifest a resume
296
+ // checks against were never touched. What is gone is the record that listed it, so this
297
+ // is the last time anything will mention the file at all.
298
+ for (const { partial, record } of stranded) {
299
+ const chatFlag = record.chat ? ` --chat ${shellArg(record.chat)}` : ''
300
+
301
+ log('')
302
+ log(`${partial} is a half-finished restore of ${record.id}, and is left where it is.`)
303
+ log('It still resumes — log in again and run it from anywhere:')
304
+ log(
305
+ ` npx telstore restore ${shellArg(record.id)} --out ${shellArg(record.target)}${chatFlag}`,
306
+ )
307
+ log('Nothing else will remind you: the record that listed it went with the rest.')
308
+ }
309
+
310
+ return { removed: true, dir: configDir, uploads: uploads.length, restores: restores.length }
311
+ }
@@ -1,11 +1,14 @@
1
- import { MANIFEST_TAG, parseManifestCaption } from '../caption.js'
1
+ import { parseManifestCaption } from '../caption.js'
2
2
  import { chatName, describeChat } from '../chat.js'
3
3
  import {
4
4
  closeQuietly,
5
5
  connect as realConnect,
6
- searchDocuments,
6
+ iterDocuments,
7
+ iterManifestSearch,
7
8
  } from '../client.js'
8
9
  import { configFile, defaultConfigDir, loadConfig } from '../config.js'
10
+ import { MANIFEST_SUFFIX } from '../manifest.js'
11
+ import { createWalkNotice } from '../progress.js'
9
12
  import { assertLoggedIn } from '../session.js'
10
13
  import { requireChat, resolveSettings } from '../settings.js'
11
14
 
@@ -30,15 +33,48 @@ function shorten(note) {
30
33
  return note.length > NOTE_WIDTH ? `${note.slice(0, NOTE_WIDTH - 1)}…` : note
31
34
  }
32
35
 
33
- // Telegram indexes the tag the manifest caption carries, so one search returns one hit
34
- // per backup instead of one per chunk. What comes back is still whatever the server
35
- // decided to match, which is why the caller filters on the file name afterwards.
36
- async function realSearchManifests(client, peer, limit) {
37
- return await searchDocuments(client, peer, { search: MANIFEST_TAG, limit })
36
+ // What list has to read through depends on how big the backups are, not how many there are:
37
+ // it walks from the newest message down and stops the moment it has --limit manifests, so the
38
+ // three thousandth backup in a chat costs nothing because it is never reached. What costs is
39
+ // the chunks in between — one message each — which is why the ceiling is a budget per backup
40
+ // asked for rather than one number for every chat.
41
+ //
42
+ // 60 documents per backup covers a backup of about 105GB at the default chunk size. A fixed
43
+ // 1000 was both too tight and too loose at once: twenty backups of 100GB need 1140 documents
44
+ // and got 1000 of them, while `--limit 5` never needed more than 300.
45
+ export const DOCUMENTS_PER_BACKUP = 60
46
+
47
+ // A search result is already a manifest, so it buys far more backups per document read. Its
48
+ // budget only has to cover what matchesTerm throws away — measured 2026-09-08, a term like
49
+ // "2026-09" comes back matching everything and is then cut down to the month asked for.
50
+ export const RESULTS_PER_BACKUP = 20
51
+
52
+ // --limit takes any whole number, so the budget needs an end of its own: without one,
53
+ // `--limit 100000` would ask for six million documents and sixty thousand requests.
54
+ export const MAX_LIST_DOCUMENTS = 10000
55
+
56
+ export function documentBudget(limit, perBackup) {
57
+ return Math.min(limit * perBackup, MAX_LIST_DOCUMENTS)
38
58
  }
39
59
 
60
+ // Stopping at the ceiling used to leave the reader standing there: "there may be older backups
61
+ // further back" is true and offers nothing to do about it. --search reaches them without
62
+ // reading the chunks in between, which is the whole reason it exists.
63
+ const DEEPER_HINT =
64
+ '"npx telstore list --search <text>" reaches older ones without reading every chunk ' +
65
+ 'in between.'
66
+
67
+ // Telegram matches whole words and nothing shorter: measured 2026-09-08, "projex" found
68
+ // projex.zip while "proj", "pro" and "pr" each found nothing at all. That is the one way a
69
+ // search can come back empty over a backup that is plainly there, so the empty answer says
70
+ // it, and points at the listing that never asks the index.
71
+ const SEARCH_MISS_HELP =
72
+ 'Telegram matches whole words: "projex" finds projex.zip, "proj" does not. ' +
73
+ 'Run "npx telstore list" without --search to see every backup without going through ' +
74
+ 'the search index.'
75
+
40
76
  function backupIdFromFileName(fileName) {
41
- return fileName.replace(/\.manifest\.json$/, '')
77
+ return fileName.slice(0, -MANIFEST_SUFFIX.length)
42
78
  }
43
79
 
44
80
  function utcDay(unixSeconds) {
@@ -74,6 +110,45 @@ function toRow(message) {
74
110
  }
75
111
  }
76
112
 
113
+ // A search term is a question about one run, and an empty one is not a question: answering
114
+ // it with every backup would look exactly like a search that matched everything.
115
+ function parseSearchTerm(raw) {
116
+ if (raw === undefined || raw === null) return null
117
+
118
+ const term = String(raw).trim()
119
+
120
+ if (term === '') {
121
+ throw new Error(
122
+ '--search is empty. Write the word to look for, or leave the flag off — "list" ' +
123
+ 'without it shows every backup.',
124
+ )
125
+ }
126
+
127
+ return term
128
+ }
129
+
130
+ // The four fields a person remembers about a backup, and the whole of what --search compares
131
+ // against. The note is matched entire rather than the 40 characters the table has room for:
132
+ // a word that fell off the end of the column is still a word they typed. A card that cannot
133
+ // be read back leaves only what the message itself knows.
134
+ function searchableFields(message) {
135
+ const id = backupIdFromFileName(message.fileName)
136
+ const card = parseManifestCaption(message.caption)
137
+
138
+ if (!card) return [id, utcDay(message.date)]
139
+
140
+ return [id, card.name, card.note ?? '', card.createdAt.slice(0, 10)]
141
+ }
142
+
143
+ // Telegram decides what comes back; this decides what is true. The index answers a term the
144
+ // way it wants to — measured 2026-09-08, "2026-09" returned every document in the chat — so
145
+ // a hit is shown only if the term really is in one of the fields above. Without this pass a
146
+ // search for a month would list backups from every other month, which is the plausible wrong
147
+ // answer this project exists to refuse.
148
+ function matchesTerm(message, term) {
149
+ return searchableFields(message).some((field) => field.toLowerCase().includes(term))
150
+ }
151
+
77
152
  function renderTable(rows) {
78
153
  // Most people never write a note, and a column of dashes tells them nothing they did not
79
154
  // already know while costing every other column the width it takes.
@@ -102,12 +177,21 @@ export async function runList(options = {}, deps = {}) {
102
177
  configDir = defaultConfigDir(),
103
178
  connect = realConnect,
104
179
  disconnect = (client) => client.destroy(),
105
- searchManifests = realSearchManifests,
180
+ readDocuments = iterDocuments,
181
+ searchManifests = iterManifestSearch,
106
182
  log = (line) => console.log(line),
183
+ // The notice is drawn on stderr, and only onto a terminal: unlike an upload's progress
184
+ // bar, `list` is a command people pipe into grep, and a carriage return in a log file is
185
+ // rubbish. Null means draw nothing at all.
186
+ writeProgress = process.stderr.isTTY ? (text) => process.stderr.write(text) : null,
187
+ now = () => Date.now(),
107
188
  } = deps
108
189
 
109
190
  const config = await loadConfig(configDir)
110
191
  const { values: settings } = resolveSettings(options, config, { file: configFile(configDir) })
192
+ // Before the login gate: a bad term is the user's own typing, and telling them to log in
193
+ // first would send them off after the wrong thing.
194
+ const term = parseSearchTerm(options.search)
111
195
  // Ask about the login before the destination: telling someone who has never logged in
112
196
  // to pick a chat sends them off after the wrong thing.
113
197
  assertLoggedIn(config)
@@ -115,29 +199,99 @@ export async function runList(options = {}, deps = {}) {
115
199
 
116
200
  const client = await connect(config, { verbose: settings.verbose })
117
201
 
118
- let found
202
+ // Walked rather than searched, unless a term was given. Telegram's text index can answer
203
+ // nothing at all about a chat that is full of backups — it did for a whole day in a channel
204
+ // that had just been created — and "No backups found" is a sentence someone acts on. The
205
+ // documents themselves were right every time they were asked for.
206
+ //
207
+ // --search is the one place worth paying the index for: a term matches a backup that may be
208
+ // ten thousand messages back, and walking to it would cost a request per hundred documents
209
+ // in between, every time, for as long as the chat keeps growing. So the search narrows and
210
+ // matchesTerm decides — the index is asked where to look, never what is true.
211
+ const found = []
212
+ let read = 0
213
+
214
+ const unit = term ? 'search results' : 'documents'
215
+ const budget = documentBudget(settings.limit, term ? RESULTS_PER_BACKUP : DOCUMENTS_PER_BACKUP)
216
+
217
+ const results = term
218
+ ? searchManifests(client, chat, term, { max: budget })
219
+ : readDocuments(client, chat, { max: budget })
220
+
221
+ const wanted = term === null ? null : term.toLowerCase()
222
+ const notice = writeProgress ? createWalkNotice({ write: writeProgress, now }) : null
223
+
119
224
  try {
120
- found = await searchManifests(client, chat, settings.limit)
225
+ for await (const document of results) {
226
+ read += 1
227
+
228
+ notice?.tick(
229
+ `Reading ${chatName(chat)}… ${read} ${unit}, ${found.length} backup` +
230
+ `${found.length === 1 ? '' : 's'}`,
231
+ )
232
+
233
+ if (!document.fileName?.endsWith(MANIFEST_SUFFIX)) continue
234
+ if (wanted !== null && !matchesTerm(document, wanted)) continue
235
+
236
+ found.push(document)
237
+
238
+ // Everything past here is older than the twentieth newest backup, and nobody asked
239
+ // for it. In a chat of ten thousand chunks this is the difference between one
240
+ // request and ten.
241
+ if (found.length >= settings.limit) break
242
+ }
121
243
  } finally {
244
+ notice?.clear()
122
245
  await closeQuietly(client, disconnect)
123
246
  }
124
247
 
248
+ // The one thing either reader cannot see is what lies past its own ceiling, so anything it
249
+ // says about the whole chat has to stop at the edge of what it read.
250
+ const capped = found.length < settings.limit && read >= budget
251
+
125
252
  log(`Destination ${describeChat(chat)}`)
253
+ if (term) log(`Search ${JSON.stringify(term)}`)
126
254
  log('')
127
255
 
128
- const rows = found
129
- .filter((message) => message.fileName?.endsWith('.manifest.json'))
130
- .map(toRow)
256
+ const rows = found.map(toRow)
131
257
 
132
258
  if (rows.length === 0) {
133
- log(`No backups found in ${chatName(chat)}. Upload one with: npx telstore <file>`)
259
+ if (term) {
260
+ log(
261
+ capped
262
+ ? `No backups matching ${JSON.stringify(term)} in the newest ${budget} ` +
263
+ `${unit} from ${chatName(chat)}. There may be older ones further back.`
264
+ : `No backups matching ${JSON.stringify(term)} in ${chatName(chat)}.`,
265
+ )
266
+ log(SEARCH_MISS_HELP)
267
+ return rows
268
+ }
269
+
270
+ log(
271
+ capped
272
+ ? `No backups in the newest ${budget} ${unit} of ${chatName(chat)}. ` +
273
+ `There may be older ones further back. ${DEEPER_HINT}`
274
+ : `No backups found in ${chatName(chat)}. Upload one with: npx telstore <file>`,
275
+ )
134
276
  return rows
135
277
  }
136
278
 
137
279
  for (const line of renderTable(rows)) log(line)
138
280
 
139
281
  log('')
140
- log(`${rows.length} backup${rows.length === 1 ? '' : 's'}. Restore with: npx telstore restore <backup-id>`)
282
+ log(
283
+ `${rows.length} backup${rows.length === 1 ? '' : 's'}` +
284
+ `${term ? ` matching ${JSON.stringify(term)}` : ''}. ` +
285
+ 'Restore with: npx telstore restore <backup-id>',
286
+ )
287
+
288
+ if (capped) {
289
+ log(
290
+ `Read the newest ${budget} ${unit} in ${chatName(chat)} to find them — ` +
291
+ `there may be older ${term ? 'matches' : 'backups'} further back.` +
292
+ `${term ? '' : ` ${DEEPER_HINT}`}`,
293
+ )
294
+ }
141
295
 
142
296
  return rows
143
297
  }