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.
- package/README.md +74 -2
- package/bin/telstore.js +257 -4
- package/package.json +1 -1
- package/src/caption.js +6 -1
- package/src/cli.js +265 -10
- package/src/client.js +7 -1
- package/src/commands/delete.js +333 -41
- package/src/commands/down.js +311 -0
- package/src/commands/list.js +1 -31
- package/src/commands/restore-stream.js +407 -0
- package/src/commands/restore.js +27 -20
- package/src/commands/status.js +190 -19
- package/src/commands/upload-stream.js +459 -0
- package/src/commands/upload.js +37 -25
- package/src/commands/verify.js +1 -5
- package/src/manifest.js +53 -1
- package/src/progress.js +86 -0
- package/src/shell.js +33 -0
- package/src/spawn.js +38 -0
- package/src/state.js +78 -0
- package/src/stream.js +295 -0
- package/src/tar.js +23 -0
|
@@ -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
|
+
}
|
package/src/commands/list.js
CHANGED
|
@@ -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.
|