telstore 0.1.9 โ 0.1.11
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 +117 -5
- package/bin/telstore.js +271 -4
- package/package.json +1 -1
- package/src/caption.js +70 -3
- package/src/cipher.js +211 -0
- package/src/cli.js +321 -13
- package/src/client.js +7 -1
- package/src/commands/delete.js +333 -41
- package/src/commands/down.js +311 -0
- package/src/commands/join.js +273 -0
- package/src/commands/list.js +12 -35
- package/src/commands/restore-stream.js +434 -0
- package/src/commands/restore.js +73 -41
- package/src/commands/status.js +196 -20
- package/src/commands/upload-stream.js +507 -0
- package/src/commands/upload.js +212 -29
- package/src/commands/verify.js +9 -6
- package/src/manifest.js +161 -5
- package/src/password.js +101 -0
- package/src/progress.js +86 -0
- package/src/shell.js +33 -0
- package/src/spawn.js +38 -0
- package/src/state.js +102 -2
- package/src/stream.js +295 -0
- package/src/tar.js +23 -0
- package/src/uploader.js +6 -1
package/README.md
CHANGED
|
@@ -16,6 +16,9 @@ npx telstore config chat @my_backups # where backups go, from now on
|
|
|
16
16
|
npx telstore data.tar # split it and send it there
|
|
17
17
|
npx telstore a.tar b.tar c.tar # or several: one backup each, one after another
|
|
18
18
|
npx telstore ./backups # or a folder: every file one level inside it
|
|
19
|
+
npx telstore a.tar -- tar cf - ./a # or from a command, with no file on disk first
|
|
20
|
+
npx telstore tarc a.tar.gz ./dir # or the common case of that: archive and store
|
|
21
|
+
npx telstore tarx telstore-20260905-7f3a91 # restore a backup and extract it with tar
|
|
19
22
|
npx telstore list # what is already in the destination
|
|
20
23
|
npx telstore restore telstore-20260905-7f3a91
|
|
21
24
|
```
|
|
@@ -26,14 +29,20 @@ npx telstore restore telstore-20260905-7f3a91
|
|
|
26
29
|
|---|---|
|
|
27
30
|
| `telstore login` | Log in to Telegram. Add `--token` to log in with a session token instead. |
|
|
28
31
|
| `telstore <file\|folder\|pattern>...` | Split each file and upload it. Prints the `backupId` you restore with. |
|
|
32
|
+
| `telstore <name> -- <command>...` | Run the command and store what it writes, under `<name>`. The manifest goes out only if the command exits 0. |
|
|
33
|
+
| `telstore tarc <name> <path>...` | Archive the paths with `tar` and store the result. Always compresses, so the name ends in `.tar.gz`. |
|
|
34
|
+
| `telstore tarx <backup-id>` | Restore a backup and extract it with `tar`. |
|
|
29
35
|
| `telstore list` | The backups stored in the destination, newest first. |
|
|
30
36
|
| `telstore restore <backup-id>...` | Download every chunk and reassemble the file. Several ids run one after another. |
|
|
37
|
+
| `telstore restore <backup-id> -- <command>...` | Restore onto the command's stdin instead of a file. |
|
|
31
38
|
| `telstore verify <backup-id>...` | Check that every chunk of a backup is still in the chat. Downloads nothing. |
|
|
32
39
|
| `telstore delete <backup-id>...` | Remove a backup's chunks and manifest from the chat, for good. Several ids are listed and confirmed once. |
|
|
40
|
+
| `telstore join <manifest.json>` | Reassemble chunks you downloaded by hand. Offline: no login, no connection. |
|
|
33
41
|
| `telstore status` | Account, destination, and unfinished uploads and restores. |
|
|
34
42
|
| `telstore config` | Show or change settings. |
|
|
35
43
|
| `telstore token` | Print a session token for a machine you do not trust. |
|
|
36
44
|
| `telstore logout` | Remove the locally stored session. |
|
|
45
|
+
| `telstore down` | Remove everything telstore keeps on this machine: session, credentials, settings and resume records. Asks once. Deletes nothing from Telegram. |
|
|
37
46
|
|
|
38
47
|
## Settings
|
|
39
48
|
|
|
@@ -65,8 +74,10 @@ a prompt that does not echo.
|
|
|
65
74
|
## What the chat looks like
|
|
66
75
|
|
|
67
76
|
Every chunk goes up as a document captioned `๐ฆ <backupId> ยท 3/12`, followed by a manifest
|
|
68
|
-
carrying a summary card โ file name, size, id, date and the restore command.
|
|
69
|
-
|
|
77
|
+
carrying a summary card โ file name, size, id, date and the restore command. A backup made
|
|
78
|
+
from a command's output is captioned `๐ฆ <backupId> ยท 3`, with no total: it does not learn how
|
|
79
|
+
many chunks there are until the last one has gone out, and the manifest card carries the final
|
|
80
|
+
count. `list` reads those cards straight out of the chat, no downloads:
|
|
70
81
|
|
|
71
82
|
```
|
|
72
83
|
Destination https://web.telegram.org/k/#@my_backups
|
|
@@ -118,6 +129,77 @@ it, and the run ends with a line per file and a non-zero exit code:
|
|
|
118
129
|
c.tar telstore-20260905-9de447 (1 chunk)
|
|
119
130
|
```
|
|
120
131
|
|
|
132
|
+
## A backup made from a command
|
|
133
|
+
|
|
134
|
+
`--` says the rest of the line is a command for telstore to run, and what that command writes
|
|
135
|
+
to its standard output is the backup. The name in front of `--` is a label for it, not a file
|
|
136
|
+
telstore reads:
|
|
137
|
+
|
|
138
|
+
```bash
|
|
139
|
+
npx telstore a.tar -- tar cf - ./a
|
|
140
|
+
npx telstore dir.tar.age -- bash -c 'set -o pipefail; tar c ./dir | age -r age1abc...'
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
**`set -o pipefail` is load-bearing, not tidiness.** A shell exits with the status of the
|
|
144
|
+
*last* command in a pipeline, so without it a `tar` that dies at 50% hands `age` a clean end
|
|
145
|
+
of input; `age` encrypts what it got, exits 0, the shell exits 0, and the guarantee below is
|
|
146
|
+
satisfied by a truncated archive that restores cleanly and matches every sha256. `pipefail`
|
|
147
|
+
is what makes the shell report the producer's failure as its own. `bash` rather than `sh`
|
|
148
|
+
because `sh` is `dash` on Debian and Ubuntu and `dash` has no `pipefail` โ it refuses the line
|
|
149
|
+
and exits 2 before writing a byte, which telstore turns into a failed run rather than a bad
|
|
150
|
+
backup, but a shell that refuses is not an example anyone can paste.
|
|
151
|
+
|
|
152
|
+
Nothing has to exist on disk first, which is the point: `tar` of a 200GB directory would
|
|
153
|
+
otherwise need 200GB free before a single byte reached Telegram, and `pg_dump` leaves no file
|
|
154
|
+
to point telstore at in the first place.
|
|
155
|
+
|
|
156
|
+
**The manifest is sent if and only if the command's output reached its end *and* the command
|
|
157
|
+
exited 0.** That biconditional is the whole reason telstore runs the command itself instead of
|
|
158
|
+
reading a pipe. When `tar` dies halfway it closes its end of the pipe, and an EOF after a crash
|
|
159
|
+
is byte-for-byte the same event as an EOF after success โ a reader on the other end cannot tell
|
|
160
|
+
them apart, and would hand you a manifest for a truncated archive that restores cleanly, matches
|
|
161
|
+
every sha256, and is garbage. A parent process sees the exit code.
|
|
162
|
+
|
|
163
|
+
So a run that fails **removes the chunks it already sent.** That is the opposite of what a file
|
|
164
|
+
upload does, and deliberately: a file upload keeps its chunks because a second run resumes onto
|
|
165
|
+
them. A stream has no second run โ the bytes have gone past, and a later run cuts them
|
|
166
|
+
differently โ so a chunk left in the chat by a failed stream is a chunk no manifest will ever
|
|
167
|
+
name. Ctrl-C means the same thing here: telstore asks the run to remove what it sent and waits
|
|
168
|
+
for it, rather than leaving where it stands. If the removal itself cannot finish, the record
|
|
169
|
+
stays on this machine and the run prints the `npx telstore delete <id> --chat <chat>` that
|
|
170
|
+
finishes it by hand. `delete` reads the chat for chunks carrying the backup id rather than
|
|
171
|
+
taking that record's word for what is there, so a chunk that landed after the record stopped
|
|
172
|
+
being written goes too โ and when it cannot read far enough back to be sure, it says so
|
|
173
|
+
instead of reporting the backup gone.
|
|
174
|
+
|
|
175
|
+
There is no shell in between: the command is spawned as an argv, so nothing needs quoting and
|
|
176
|
+
telstore never builds a command string out of your arguments. `-- bash -c 'set -o pipefail;
|
|
177
|
+
...'` is how a pipeline gets in โ with `pipefail`, for the reason above: the biconditional is
|
|
178
|
+
only as good as the exit code the shell reports, and a pipeline without it reports the wrong
|
|
179
|
+
one. That is also how your data is compressed or encrypted **before** it reaches Telegram โ
|
|
180
|
+
`zstd`, `gpg`, `age` โ with telstore holding nobody's passphrase. The command's
|
|
181
|
+
stderr is left as it is, so one that fails explains itself in its own words and telstore adds
|
|
182
|
+
only the exit code and what it did about it.
|
|
183
|
+
|
|
184
|
+
## Encrypting a backup
|
|
185
|
+
|
|
186
|
+
```bash
|
|
187
|
+
npx telstore photos.tar --encrypt
|
|
188
|
+
npx telstore tarc photos.tar.gz ./photos --encrypt
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
telstore asks for a password twice and for an optional hint, then encrypts every chunk before
|
|
192
|
+
it leaves the machine (AES-256-CTR, the key derived with scrypt, the manifest sealed with
|
|
193
|
+
AES-256-GCM). `restore`, `tarx` and `join` see that a backup is encrypted, print its hint, and
|
|
194
|
+
ask for the password. The hint is shown in the chat as plain text โ telstore refuses one that
|
|
195
|
+
contains the password. If telstore ever restores a backup you encrypted without asking for its
|
|
196
|
+
password, the manifest in the chat has been replaced: do not trust the result.
|
|
197
|
+
|
|
198
|
+
What stays readable to anyone who can read the chat: the file name, the note, the size, the
|
|
199
|
+
number of chunks, the dates and the hint. **A forgotten password is a lost backup** โ nothing
|
|
200
|
+
can recover it. The password is only ever typed at a terminal; for unattended encrypted
|
|
201
|
+
backups, use `--` with a key-based tool such as `age`.
|
|
202
|
+
|
|
121
203
|
## Checking a backup is still there
|
|
122
204
|
|
|
123
205
|
A backup is a set of messages in a chat, and messages can be deleted by hand. `list` reads
|
|
@@ -148,6 +230,26 @@ Chunk 3/12 is gone: message 1042 is no longer in @my_backups.
|
|
|
148
230
|
12 chunks checked, 1 damaged. This backup cannot be restored.
|
|
149
231
|
```
|
|
150
232
|
|
|
233
|
+
## Joining chunks downloaded by hand
|
|
234
|
+
|
|
235
|
+
A backup is ordinary files in a chat, so it can be fetched without telstore โ from Telegram
|
|
236
|
+
web, on a machine where you cannot or would rather not log in. Download the manifest
|
|
237
|
+
(`<backupId>.manifest.json`) and every chunk (`<backupId>.part0001`, `.part0002`, โฆ) into one
|
|
238
|
+
folder, keeping the names they have in the chat, then:
|
|
239
|
+
|
|
240
|
+
```bash
|
|
241
|
+
npx telstore join ~/Downloads/telstore-20260905-7f3a91.manifest.json
|
|
242
|
+
npx telstore join ~/Downloads/telstore-20260905-7f3a91.manifest.json --out data.tar
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
`join` makes the same promise `restore` does, from the manifest rather than the chat: every
|
|
246
|
+
chunk is checked for its length before anything is written, every missing or short one is
|
|
247
|
+
named in one go, each chunk's sha256 is checked as it is copied, and the file takes its real
|
|
248
|
+
name only after all of it passes. Until then it is `<target>.joining`, which a failure or a
|
|
249
|
+
Ctrl-C removes โ the chunks are still in the folder, so there is nothing worth keeping. A
|
|
250
|
+
browser that saves a name twice tends to add ` (1)` to it; `join` does not guess past that,
|
|
251
|
+
so rename the file back.
|
|
252
|
+
|
|
151
253
|
## Several backups at once
|
|
152
254
|
|
|
153
255
|
`restore`, `verify` and `delete` take a list of ids the same way, over one connection, with a
|
|
@@ -207,10 +309,13 @@ There is no expiry and no revocation: to end a session for good, terminate it un
|
|
|
207
309
|
|
|
208
310
|
## Limits worth knowing
|
|
209
311
|
|
|
210
|
-
- **Your data is not encrypted
|
|
211
|
-
|
|
212
|
-
|
|
312
|
+
- **Your data is not encrypted unless you pass `--encrypt`.** Without it, don't upload anything
|
|
313
|
+
you would mind sitting on someone else's infrastructure. With it, the contents are encrypted
|
|
314
|
+
but the file name, note, size and hint are not, and a forgotten password cannot be recovered.
|
|
213
315
|
- A chunk cannot exceed 1950MB: Telegram accepts at most 4000 parts of 512KB per file.
|
|
316
|
+
- **A backup made from a command cannot be resumed**, so a run that fails or is interrupted
|
|
317
|
+
removes the chunks it had already sent instead of keeping them. Ctrl-C takes a moment longer
|
|
318
|
+
for that reason, and leaves nothing of that run behind in the chat.
|
|
214
319
|
- Deleting a chunk message in the Telegram app destroys the backup, and keeping the
|
|
215
320
|
`backupId` is what saves you hunting for its manifest in the chat by hand. `verify` is how
|
|
216
321
|
you find that out before you need the file rather than after.
|
|
@@ -220,6 +325,13 @@ the session at the top level (or a single `sealed` blob after `login --token`),
|
|
|
220
325
|
`config` manages under `settings`. Editing it by hand is fine: a value that cannot be used is
|
|
221
326
|
named on the next run, with the file and the key.
|
|
222
327
|
|
|
328
|
+
`npx telstore down` removes all of that, and `~/.telstore/state/` with it โ everything on
|
|
329
|
+
this machine, in one question. It opens no connection: your backups stay in the chat, and
|
|
330
|
+
the session stays alive on Telegram's side until you terminate it under Settings โ Devices.
|
|
331
|
+
Unlike `logout` it takes the `api_id` and `api_hash` too, so the next `login` asks for them
|
|
332
|
+
again. A half-finished restore's `.partial` is left where it is and named on the way out โ
|
|
333
|
+
it still resumes, and after this nothing else will remind you it is there.
|
|
334
|
+
|
|
223
335
|
## License
|
|
224
336
|
|
|
225
337
|
MIT
|
package/bin/telstore.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { unlinkSync } from 'node:fs'
|
|
3
|
+
|
|
2
4
|
import { route, HELP, interruptMessage } from '../src/cli.js'
|
|
3
5
|
|
|
4
6
|
// Each command is imported where it runs, not here. Importing all nine up front pulled
|
|
@@ -8,25 +10,195 @@ import { route, HELP, interruptMessage } from '../src/cli.js'
|
|
|
8
10
|
|
|
9
11
|
const SIGINT_EXIT_CODE = 130
|
|
10
12
|
|
|
13
|
+
// How long the process waits for a run to unwind itself before it stops waiting. Only a
|
|
14
|
+
// rollback that cannot reach Telegram ever gets this far, and the alternative to a deadline
|
|
15
|
+
// is a terminal held open by a network that is never coming back, with no way out but a
|
|
16
|
+
// second Ctrl-C nobody has been told to press.
|
|
17
|
+
const CLEANUP_DEADLINE_MS = 60_000
|
|
18
|
+
|
|
11
19
|
// Which command is running when Ctrl-C arrives โ each one tells a different truth
|
|
12
20
|
// about whether progress was saved, so we need to know which to pick the right line.
|
|
13
21
|
// The backup id arrives a moment later, once upload knows which backup this run is.
|
|
14
22
|
let currentCommand = null
|
|
15
23
|
let currentBackupId = null
|
|
16
24
|
|
|
25
|
+
// Only a stream upload sets these. `streaming` is known from the command line, before the run
|
|
26
|
+
// has said anything, because the wrong message is the file one that promises a resume; the
|
|
27
|
+
// other two arrive once the run has something in the chat and a way to unwind it.
|
|
28
|
+
let streaming = false
|
|
29
|
+
let currentChat = null
|
|
30
|
+
let abortRun = null
|
|
31
|
+
let interrupting = false
|
|
32
|
+
let deadline = null
|
|
33
|
+
|
|
34
|
+
// The run is over โ it finished, or it unwound itself and threw. Nothing after this point is
|
|
35
|
+
// something Ctrl-C can be about: the process is only waiting for its own output to flush.
|
|
36
|
+
let settled = false
|
|
37
|
+
|
|
38
|
+
// This process is already on its way out, and has already said why.
|
|
39
|
+
let leaving = false
|
|
40
|
+
|
|
41
|
+
// The chunk file a stream upload is buffering into right now, or the half-joined file a join
|
|
42
|
+
// is writing, or null. The run removes its own on every ending it gets to run code for; this
|
|
43
|
+
// exists for the one ending it does not.
|
|
44
|
+
let tempChunk = null
|
|
45
|
+
|
|
46
|
+
// The command a streaming restore is feeding, if one is running. A stream upload unwinds
|
|
47
|
+
// cooperatively and kills its own child on the way; a restore has nothing in the chat to
|
|
48
|
+
// unwind, so Ctrl-C leaves at once โ and leaving without this would let tar go on writing
|
|
49
|
+
// files into somebody's directory after telstore had said it stopped. Synchronous, like
|
|
50
|
+
// dropTempChunk and for the same reason: anything awaited here would be waiting on the run
|
|
51
|
+
// this exit exists to stop waiting for.
|
|
52
|
+
let killChild = null
|
|
53
|
+
|
|
54
|
+
function stopChild() {
|
|
55
|
+
const kill = killChild
|
|
56
|
+
|
|
57
|
+
if (kill === null) return
|
|
58
|
+
|
|
59
|
+
killChild = null
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
kill()
|
|
63
|
+
} catch {
|
|
64
|
+
// Already gone. There is nothing this could do about it and nothing worth saying.
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// What a Ctrl-C that will not wait can still do about that file, and it has to be exactly this
|
|
69
|
+
// shape: local and unawaited, because anything awaited here would be waiting on the very run
|
|
70
|
+
// this exit exists to stop waiting for. `unlinkSync` holds no handle, opens no socket and
|
|
71
|
+
// cannot hang. Removing a file this process still has open is not a problem where it matters
|
|
72
|
+
// either: on POSIX the name goes now and the space comes back as the process dies. A platform
|
|
73
|
+
// that refuses to unlink an open file leaves the user with a file holding up to a whole chunk,
|
|
74
|
+
// so that ending โ and only that ending โ prints.
|
|
75
|
+
//
|
|
76
|
+
// It is not free, though, and "one syscall" is the wrong number to quote. Three costs, and
|
|
77
|
+
// they are three different numbers: the call removes a name and returns in microseconds; the
|
|
78
|
+
// goodbye line still reaches the terminal in about 1.2ms; and then the extents are freed at
|
|
79
|
+
// the *last close*, which is process teardown. Measured on ext4, SIGINT to exit, file still
|
|
80
|
+
// open: 60-100ms against 29-43ms for a 64MB chunk, 596-658ms against 45-56ms at 512MB, and
|
|
81
|
+
// 1131-1646ms against 45-64ms at the default 1792MB โ so at default settings the prompt comes
|
|
82
|
+
// back about a second after the message does. Nothing avoids that second: whoever runs `rm`
|
|
83
|
+
// on the leftover instead pays the same teardown. docs/design/data-integrity.md has the
|
|
84
|
+
// conditions.
|
|
85
|
+
//
|
|
86
|
+
// Silence on success is the point rather than an omission: the rule is that nothing telstore
|
|
87
|
+
// leaves behind goes unnamed, and this leaves nothing behind.
|
|
88
|
+
function dropTempChunk() {
|
|
89
|
+
const file = tempChunk
|
|
90
|
+
|
|
91
|
+
if (file === null) return ''
|
|
92
|
+
|
|
93
|
+
tempChunk = null
|
|
94
|
+
|
|
95
|
+
try {
|
|
96
|
+
unlinkSync(file)
|
|
97
|
+
return ''
|
|
98
|
+
} catch (err) {
|
|
99
|
+
// Already gone โ the run's own discard won the race โ is not something to report.
|
|
100
|
+
if (err.code === 'ENOENT') return ''
|
|
101
|
+
|
|
102
|
+
return (
|
|
103
|
+
`\nThe temporary file telstore was writing is still on this machine: ${file} ` +
|
|
104
|
+
`(${err.message}). Nothing needs it โ remove it by hand.\n`
|
|
105
|
+
)
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
17
109
|
// A batch clears each finished item's record as it goes, so by the time Ctrl-C lands these
|
|
18
110
|
// are transfers no second run should touch. Ctrl-C needs their names to say so.
|
|
19
111
|
const finished = []
|
|
20
112
|
|
|
113
|
+
// Every exit Ctrl-C leads to comes through here. Through exitWhenFlushed rather than straight
|
|
114
|
+
// to process.exit, because on a pipe stderr is asynchronous, and the line most at risk of
|
|
115
|
+
// being cut in half is the one below carrying the command that removes the leftovers.
|
|
116
|
+
function leave(message) {
|
|
117
|
+
stopChild()
|
|
118
|
+
|
|
119
|
+
// Called twice means Ctrl-C landed again while the first line was still flushing, or the
|
|
120
|
+
// deadline arrived on top of it. There is nothing more to say, and someone pressing it a
|
|
121
|
+
// second time is asking to be gone rather than read to.
|
|
122
|
+
if (leaving) process.exit(SIGINT_EXIT_CODE)
|
|
123
|
+
|
|
124
|
+
leaving = true
|
|
125
|
+
|
|
126
|
+
// Every exit the handler leads to comes through here, which is why the borrowed disk is
|
|
127
|
+
// given back here rather than in the second-Ctrl-C arm alone: the deadline running out
|
|
128
|
+
// ends the process in exactly the same place, and so does a Ctrl-C on a run that had
|
|
129
|
+
// nothing to unwind. One write, so the two lines cannot be split by a slow pipe.
|
|
130
|
+
process.stderr.write(message + dropTempChunk())
|
|
131
|
+
exitWhenFlushed(SIGINT_EXIT_CODE)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Said when the waiting ends without the rollback having finished โ because someone pressed
|
|
135
|
+
// Ctrl-C again, or because the deadline above ran out. Neither knows how far the removal got,
|
|
136
|
+
// so both point at the command that finishes it by hand.
|
|
137
|
+
function leaveNow() {
|
|
138
|
+
leave(
|
|
139
|
+
interruptMessage(currentCommand, {
|
|
140
|
+
backupId: currentBackupId,
|
|
141
|
+
done: finished,
|
|
142
|
+
stream: true,
|
|
143
|
+
again: true,
|
|
144
|
+
chat: currentChat,
|
|
145
|
+
}),
|
|
146
|
+
)
|
|
147
|
+
}
|
|
148
|
+
|
|
21
149
|
process.on('SIGINT', () => {
|
|
22
150
|
// A passphrase prompt has stdin in raw mode, and process.exit skips readline's own cleanup.
|
|
23
151
|
// Without this, Ctrl-C hands back a shell that no longer echoes what is typed into it.
|
|
24
152
|
if (process.stdin.isTTY) process.stdin.setRawMode(false)
|
|
25
153
|
|
|
154
|
+
// Checked before anything else, because every message below describes a run that is still
|
|
155
|
+
// going. Said about one that has settled they are all false: a removal that is not running,
|
|
156
|
+
// leftovers that were already removed, a resume for a backup that is finished and valid.
|
|
157
|
+
// The window is real โ exitWhenFlushed waits up to two seconds on a pipe nobody is reading.
|
|
158
|
+
if (settled) {
|
|
159
|
+
leave(interruptMessage(null))
|
|
160
|
+
return
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// A second Ctrl-C is someone saying they will not wait.
|
|
164
|
+
if (interrupting) {
|
|
165
|
+
leaveNow()
|
|
166
|
+
return
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Everything else keeps what it has sent on purpose โ a file upload's chunks are what the
|
|
170
|
+
// next run resumes onto โ so there is nothing to unwind and Ctrl-C is the immediate exit it
|
|
171
|
+
// has always been.
|
|
172
|
+
if (!abortRun) {
|
|
173
|
+
leave(
|
|
174
|
+
interruptMessage(currentCommand, {
|
|
175
|
+
backupId: currentBackupId,
|
|
176
|
+
done: finished,
|
|
177
|
+
stream: streaming,
|
|
178
|
+
}),
|
|
179
|
+
)
|
|
180
|
+
return
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
interrupting = true
|
|
26
184
|
process.stderr.write(
|
|
27
|
-
interruptMessage(currentCommand, {
|
|
185
|
+
interruptMessage(currentCommand, {
|
|
186
|
+
backupId: currentBackupId,
|
|
187
|
+
done: finished,
|
|
188
|
+
stream: true,
|
|
189
|
+
}),
|
|
28
190
|
)
|
|
29
|
-
|
|
191
|
+
|
|
192
|
+
// Deliberately not unref'd, and it does two jobs. It is the deadline; it is also the one
|
|
193
|
+
// handle that keeps this process alive while the rollback runs. Node leaves with 0 the
|
|
194
|
+
// moment nothing is pending, and a rollback waiting on something that holds no handle
|
|
195
|
+
// would end the process mid-removal reporting success, with the chunks still in the chat.
|
|
196
|
+
// The upload arm clears it, so a rollback that finishes normally never meets the deadline.
|
|
197
|
+
deadline = setTimeout(leaveNow, CLEANUP_DEADLINE_MS)
|
|
198
|
+
|
|
199
|
+
// The abort itself only asks; the waiting is done by main, which is still awaiting the run.
|
|
200
|
+
// Nothing it could reject with matters here โ the run reports its own end.
|
|
201
|
+
Promise.resolve(abortRun()).catch(() => {})
|
|
30
202
|
})
|
|
31
203
|
|
|
32
204
|
async function main() {
|
|
@@ -65,6 +237,13 @@ async function main() {
|
|
|
65
237
|
return
|
|
66
238
|
}
|
|
67
239
|
|
|
240
|
+
case 'down': {
|
|
241
|
+
const { runDown } = await import('../src/commands/down.js')
|
|
242
|
+
|
|
243
|
+
await runDown(parsed.args, parsed.options)
|
|
244
|
+
return
|
|
245
|
+
}
|
|
246
|
+
|
|
68
247
|
case 'list': {
|
|
69
248
|
const { runList } = await import('../src/commands/list.js')
|
|
70
249
|
|
|
@@ -94,6 +273,46 @@ async function main() {
|
|
|
94
273
|
}
|
|
95
274
|
|
|
96
275
|
case 'upload': {
|
|
276
|
+
// `telstore a.tar -- tar cf - ./a`: one name, and the bytes are what the command writes
|
|
277
|
+
// rather than a file on disk. route has already refused every other shape of that line.
|
|
278
|
+
if (parsed.childArgv) {
|
|
279
|
+
const { runStreamUpload } = await import('../src/commands/upload-stream.js')
|
|
280
|
+
|
|
281
|
+
streaming = true
|
|
282
|
+
|
|
283
|
+
try {
|
|
284
|
+
await runStreamUpload(parsed.args[0], parsed.childArgv, parsed.options, {
|
|
285
|
+
onBackupId: (id) => {
|
|
286
|
+
currentBackupId = id
|
|
287
|
+
},
|
|
288
|
+
onAbortable: (abort, { chat } = {}) => {
|
|
289
|
+
abortRun = abort
|
|
290
|
+
currentChat = chat ?? null
|
|
291
|
+
},
|
|
292
|
+
onTempChunk: (file) => {
|
|
293
|
+
tempChunk = file
|
|
294
|
+
},
|
|
295
|
+
})
|
|
296
|
+
} catch (err) {
|
|
297
|
+
// Only the run that stopped because Ctrl-C asked it to, which has already said so
|
|
298
|
+
// and already put the chat back as it found it. Everything else โ a rollback that
|
|
299
|
+
// could not finish included, since that throws an error of its own โ is a failure
|
|
300
|
+
// the handler below reports.
|
|
301
|
+
if (!err?.interrupted) throw err
|
|
302
|
+
|
|
303
|
+
process.stderr.write('\nStopped. Nothing this run sent was left in the chat.\n')
|
|
304
|
+
process.exitCode = SIGINT_EXIT_CODE
|
|
305
|
+
} finally {
|
|
306
|
+
// Nothing left to unwind, and nothing left to say about it: whichever way the run
|
|
307
|
+
// ended, it ended. The deadline that was holding the process open goes with it.
|
|
308
|
+
settled = true
|
|
309
|
+
abortRun = null
|
|
310
|
+
if (deadline) clearTimeout(deadline)
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
return
|
|
314
|
+
}
|
|
315
|
+
|
|
97
316
|
const { runUploads } = await import('../src/commands/upload.js')
|
|
98
317
|
|
|
99
318
|
const { failed } = await runUploads(parsed.args, parsed.options, {
|
|
@@ -115,6 +334,36 @@ async function main() {
|
|
|
115
334
|
}
|
|
116
335
|
|
|
117
336
|
case 'restore': {
|
|
337
|
+
// The spec's stage 2, and the refusal that stood here is gone: the bytes were asked for
|
|
338
|
+
// on a command's stdin and that is now where they go.
|
|
339
|
+
if (parsed.childArgv) {
|
|
340
|
+
const { runRestoreStream } = await import('../src/commands/restore-stream.js')
|
|
341
|
+
|
|
342
|
+
// So Ctrl-C says the restore sentence rather than the upload one.
|
|
343
|
+
streaming = true
|
|
344
|
+
|
|
345
|
+
await runRestoreStream(parsed.args[0], parsed.childArgv, parsed.options, {
|
|
346
|
+
// Only the alias promises gzip. The general form promises nothing and is asked
|
|
347
|
+
// nothing, which is how `restore <id> -- tar xf -` stays useful.
|
|
348
|
+
requireGzipName: parsed.shortcut === 'tarx',
|
|
349
|
+
onBackupId: (id) => {
|
|
350
|
+
currentBackupId = id
|
|
351
|
+
},
|
|
352
|
+
onTempChunk: (file) => {
|
|
353
|
+
tempChunk = file
|
|
354
|
+
},
|
|
355
|
+
onChild: (kill) => {
|
|
356
|
+
killChild = kill
|
|
357
|
+
},
|
|
358
|
+
})
|
|
359
|
+
|
|
360
|
+
// Mirrors the stream-upload branch above: the run has finished, so a Ctrl-C landing in
|
|
361
|
+
// the output-flush window that follows must not print the "removing what it already
|
|
362
|
+
// sent" sentence about a restore that already has everything it is going to get.
|
|
363
|
+
settled = true
|
|
364
|
+
return
|
|
365
|
+
}
|
|
366
|
+
|
|
118
367
|
if (!parsed.args[0]) {
|
|
119
368
|
throw new Error('Missing backup id. Example: npx telstore restore telstore-20260905-7f3a91')
|
|
120
369
|
}
|
|
@@ -162,6 +411,19 @@ async function main() {
|
|
|
162
411
|
return
|
|
163
412
|
}
|
|
164
413
|
|
|
414
|
+
case 'join': {
|
|
415
|
+
const { runJoin } = await import('../src/commands/join.js')
|
|
416
|
+
|
|
417
|
+
// The half-joined file rides the same seam as a stream upload's buffered chunk, so the
|
|
418
|
+
// SIGINT handler removes it on the way out rather than leaving it for someone to find.
|
|
419
|
+
await runJoin(parsed.args[0], parsed.options, {
|
|
420
|
+
onTempChunk: (file) => {
|
|
421
|
+
tempChunk = file
|
|
422
|
+
},
|
|
423
|
+
})
|
|
424
|
+
return
|
|
425
|
+
}
|
|
426
|
+
|
|
165
427
|
default:
|
|
166
428
|
throw new Error(`Unknown command: ${parsed.command}`)
|
|
167
429
|
}
|
|
@@ -197,7 +459,12 @@ main().then(
|
|
|
197
459
|
},
|
|
198
460
|
(err) => {
|
|
199
461
|
process.stderr.write(`Error: ${err.message}\n`)
|
|
200
|
-
|
|
201
|
-
|
|
462
|
+
|
|
463
|
+
// A run the handler asked to stop leaves with 130 whatever it then had to say: a rollback
|
|
464
|
+
// that could not finish is still an interrupted run, not a command that failed on its own.
|
|
465
|
+
const code = interrupting ? SIGINT_EXIT_CODE : 1
|
|
466
|
+
|
|
467
|
+
process.exitCode = code
|
|
468
|
+
exitWhenFlushed(code)
|
|
202
469
|
},
|
|
203
470
|
)
|
package/package.json
CHANGED
package/src/caption.js
CHANGED
|
@@ -14,6 +14,23 @@ function oneLine(name) {
|
|
|
14
14
|
return String(name).replace(/\s+/g, ' ').trim()
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
// C0 and C1, DEL included. What makes a string an instruction to the terminal rather than text
|
|
18
|
+
// on it: an escape sequence can clear the screen, move the cursor, or rewrite the line above.
|
|
19
|
+
const CONTROL_CHARACTERS = /[\x00-\x1f\x7f-\x9f]/g
|
|
20
|
+
|
|
21
|
+
export function hasControlCharacter(text) {
|
|
22
|
+
return /[\x00-\x1f\x7f-\x9f]/.test(String(text))
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// A hint is printed before anything can prove it genuine: the seal covers it, but only a password
|
|
26
|
+
// that opens the seal says so, and the hint is what someone reads while deciding what to type. So
|
|
27
|
+
// everywhere it reaches a terminal it goes through here first โ whitespace folded the way oneLine
|
|
28
|
+
// folds it, every other control character dropped โ and the most a hint someone else wrote can do
|
|
29
|
+
// is say something unhelpful, never repaint the screen around the password prompt.
|
|
30
|
+
export function terminalSafe(text) {
|
|
31
|
+
return oneLine(String(text).replace(/\s/g, ' ').replace(CONTROL_CHARACTERS, ''))
|
|
32
|
+
}
|
|
33
|
+
|
|
17
34
|
// Telegram takes 1024 characters in a caption, and the card around the note already spends
|
|
18
35
|
// some of them โ a file name alone may be 255. 500 leaves both room to spare.
|
|
19
36
|
export const MAX_NOTE_LENGTH = 500
|
|
@@ -45,15 +62,56 @@ export function parseNote(raw) {
|
|
|
45
62
|
return note
|
|
46
63
|
}
|
|
47
64
|
|
|
65
|
+
// What the card has left once every other line has had its share. Measured 2026-09-14: the
|
|
66
|
+
// worst card telstore writes without encryption โ a 255-character name, a 500-character note,
|
|
67
|
+
// 10000 chunks โ is 899 of Telegram's 1024 characters, and the lock and hint lines leave 108.
|
|
68
|
+
export const MAX_HINT_LENGTH = 100
|
|
69
|
+
|
|
70
|
+
const LOCK_LINE = '๐ encrypted'
|
|
71
|
+
|
|
72
|
+
// Written at the password prompt and shown in the open: on the card, in `list`, and above the
|
|
73
|
+
// password prompt at restore time. So a hint holding the password is a password in the chat.
|
|
74
|
+
//
|
|
75
|
+
// Stripped of control characters here, once, rather than only when printed: parseManifest refuses
|
|
76
|
+
// a hint carrying one, so a hint that kept it would be an honest backup restore turns away.
|
|
77
|
+
export function parseHint(raw, password = null) {
|
|
78
|
+
if (raw === undefined || raw === null) return null
|
|
79
|
+
|
|
80
|
+
const hint = terminalSafe(raw)
|
|
81
|
+
|
|
82
|
+
if (hint === '') return null
|
|
83
|
+
|
|
84
|
+
if (hint.length > MAX_HINT_LENGTH) {
|
|
85
|
+
throw new Error(
|
|
86
|
+
`The hint is ${hint.length} characters, and the card in the chat has room for ` +
|
|
87
|
+
`${MAX_HINT_LENGTH}. Shorten it: telstore will not cut it short by itself.`,
|
|
88
|
+
)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (password && hint.toLowerCase().includes(String(password).toLowerCase())) {
|
|
92
|
+
throw new Error(
|
|
93
|
+
'The hint contains the password itself, and the hint is shown in the chat as plain ' +
|
|
94
|
+
'text. Write something only you would connect with it.',
|
|
95
|
+
)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return hint
|
|
99
|
+
}
|
|
100
|
+
|
|
48
101
|
function utcMinutes(createdAt) {
|
|
49
102
|
return `${new Date(createdAt).toISOString().slice(0, 16).replace('T', ' ')} UTC`
|
|
50
103
|
}
|
|
51
104
|
|
|
105
|
+
// A stream knows its chunk number and not its count. "3/?" would be a question mark in the
|
|
106
|
+
// chat forever; the number alone is true at the time it is written, and the manifest card
|
|
107
|
+
// carries the final count.
|
|
52
108
|
export function chunkCaption({ id, number, total }) {
|
|
53
|
-
return
|
|
109
|
+
return total === null || total === undefined
|
|
110
|
+
? `๐ฆ ${id} ยท ${number}`
|
|
111
|
+
: `๐ฆ ${id} ยท ${number}/${total}`
|
|
54
112
|
}
|
|
55
113
|
|
|
56
|
-
export function manifestCaption({ id, name, size, chunks, createdAt, note = null }) {
|
|
114
|
+
export function manifestCaption({ id, name, size, chunks, createdAt, note = null, encrypted = false, hint = null }) {
|
|
57
115
|
return [
|
|
58
116
|
`๐ ${oneLine(name)}`,
|
|
59
117
|
`๐พ ${formatBytes(size)} ยท ${chunks} chunk${chunks === 1 ? '' : 's'}`,
|
|
@@ -62,6 +120,10 @@ export function manifestCaption({ id, name, size, chunks, createdAt, note = null
|
|
|
62
120
|
// Below the facts telstore knows, above the line that says how to get the file back:
|
|
63
121
|
// the note is the one part of the card a person wrote, so it reads last of the four.
|
|
64
122
|
...(note ? [`๐ ${oneLine(note)}`] : []),
|
|
123
|
+
// Below the note and above the restore line: the lock is a fact about the backup a person
|
|
124
|
+
// needs before they try to restore it, and the hint is what they will need at the prompt.
|
|
125
|
+
...(encrypted ? [LOCK_LINE] : []),
|
|
126
|
+
...(encrypted && hint ? [`๐ก ${oneLine(hint)}`] : []),
|
|
65
127
|
'',
|
|
66
128
|
`โฉ npx telstore restore ${id}`,
|
|
67
129
|
MANIFEST_TAG,
|
|
@@ -88,11 +150,16 @@ export function parseManifestCaption(text) {
|
|
|
88
150
|
// one marker whose absence means "there is no note" rather than "this is not a card".
|
|
89
151
|
const note = marker(lines, '๐')
|
|
90
152
|
|
|
153
|
+
// Both optional, like the note: every card telstore wrote before encryption existed is a
|
|
154
|
+
// complete card with neither.
|
|
155
|
+
const encrypted = lines.includes(LOCK_LINE)
|
|
156
|
+
const hint = marker(lines, '๐ก')
|
|
157
|
+
|
|
91
158
|
if (!name || !totals || !id || !createdAt) return null
|
|
92
159
|
|
|
93
160
|
const match = /^(.+) ยท (\d+) chunks?$/.exec(totals)
|
|
94
161
|
|
|
95
162
|
if (!match) return null
|
|
96
163
|
|
|
97
|
-
return { id, name, size: match[1], chunks: Number(match[2]), createdAt, note }
|
|
164
|
+
return { id, name, size: match[1], chunks: Number(match[2]), createdAt, note, encrypted, hint }
|
|
98
165
|
}
|