telstore 0.1.9 → 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
+ }
@@ -8,6 +8,7 @@ import {
8
8
  } from '../client.js'
9
9
  import { configFile, defaultConfigDir, loadConfig } from '../config.js'
10
10
  import { MANIFEST_SUFFIX } from '../manifest.js'
11
+ import { createWalkNotice } from '../progress.js'
11
12
  import { assertLoggedIn } from '../session.js'
12
13
  import { requireChat, resolveSettings } from '../settings.js'
13
14
 
@@ -148,37 +149,6 @@ function matchesTerm(message, term) {
148
149
  return searchableFields(message).some((field) => field.toLowerCase().includes(term))
149
150
  }
150
151
 
151
- // A walk of one page is over in about the time it takes to notice — 165ms against a real
152
- // chat — and that is the usual case, so nothing is drawn for the first stretch: a line that
153
- // appears and is wiped in the same breath is a flicker, not information. Past that the read
154
- // is long enough that silence reads as the hang this project refuses everywhere else.
155
- //
156
- // \r only moves the cursor home, so every line is padded to the widest one drawn and the last
157
- // write wipes the row: the table that follows must never land on half a progress line.
158
- const NOTICE_QUIET_MS = 400
159
- const NOTICE_INTERVAL_MS = 200
160
-
161
- function createWalkNotice({ write, now, quietMs = NOTICE_QUIET_MS, intervalMs = NOTICE_INTERVAL_MS }) {
162
- const startedAt = now()
163
- let lastDrawnAt = 0
164
- let widest = 0
165
-
166
- return {
167
- tick(text) {
168
- if (now() - startedAt < quietMs) return
169
- if (lastDrawnAt !== 0 && now() - lastDrawnAt < intervalMs) return
170
-
171
- lastDrawnAt = now()
172
- widest = Math.max(widest, text.length)
173
- write(`\r${text.padEnd(widest)}`)
174
- },
175
- clear() {
176
- if (widest === 0) return
177
- write(`\r${' '.repeat(widest)}\r`)
178
- },
179
- }
180
- }
181
-
182
152
  function renderTable(rows) {
183
153
  // Most people never write a note, and a column of dashes tells them nothing they did not
184
154
  // already know while costing every other column the width it takes.