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.
- package/README.md +110 -5
- package/bin/telstore.js +272 -4
- package/package.json +1 -1
- package/src/caption.js +6 -1
- package/src/cli.js +279 -12
- package/src/client.js +168 -9
- package/src/commands/delete.js +335 -43
- package/src/commands/down.js +311 -0
- package/src/commands/list.js +170 -16
- 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 +294 -0
- package/src/manifest.js +59 -2
- 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
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,13 +29,19 @@ 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. |
|
|
38
|
+
| `telstore verify <backup-id>...` | Check that every chunk of a backup is still in the chat. Downloads nothing. |
|
|
31
39
|
| `telstore delete <backup-id>...` | Remove a backup's chunks and manifest from the chat, for good. Several ids are listed and confirmed once. |
|
|
32
40
|
| `telstore status` | Account, destination, and unfinished uploads and restores. |
|
|
33
41
|
| `telstore config` | Show or change settings. |
|
|
34
42
|
| `telstore token` | Print a session token for a machine you do not trust. |
|
|
35
43
|
| `telstore logout` | Remove the locally stored session. |
|
|
44
|
+
| `telstore down` | Remove everything telstore keeps on this machine: session, credentials, settings and resume records. Asks once. Deletes nothing from Telegram. |
|
|
36
45
|
|
|
37
46
|
## Settings
|
|
38
47
|
|
|
@@ -64,8 +73,10 @@ a prompt that does not echo.
|
|
|
64
73
|
## What the chat looks like
|
|
65
74
|
|
|
66
75
|
Every chunk goes up as a document captioned `๐ฆ <backupId> ยท 3/12`, followed by a manifest
|
|
67
|
-
carrying a summary card โ file name, size, id, date and the restore command.
|
|
68
|
-
|
|
76
|
+
carrying a summary card โ file name, size, id, date and the restore command. A backup made
|
|
77
|
+
from a command's output is captioned `๐ฆ <backupId> ยท 3`, with no total: it does not learn how
|
|
78
|
+
many chunks there are until the last one has gone out, and the manifest card carries the final
|
|
79
|
+
count. `list` reads those cards straight out of the chat, no downloads:
|
|
69
80
|
|
|
70
81
|
```
|
|
71
82
|
Destination https://web.telegram.org/k/#@my_backups
|
|
@@ -117,13 +128,96 @@ it, and the run ends with a line per file and a non-zero exit code:
|
|
|
117
128
|
c.tar telstore-20260905-9de447 (1 chunk)
|
|
118
129
|
```
|
|
119
130
|
|
|
131
|
+
## A backup made from a command
|
|
132
|
+
|
|
133
|
+
`--` says the rest of the line is a command for telstore to run, and what that command writes
|
|
134
|
+
to its standard output is the backup. The name in front of `--` is a label for it, not a file
|
|
135
|
+
telstore reads:
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
npx telstore a.tar -- tar cf - ./a
|
|
139
|
+
npx telstore dir.tar.age -- bash -c 'set -o pipefail; tar c ./dir | age -r age1abc...'
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
**`set -o pipefail` is load-bearing, not tidiness.** A shell exits with the status of the
|
|
143
|
+
*last* command in a pipeline, so without it a `tar` that dies at 50% hands `age` a clean end
|
|
144
|
+
of input; `age` encrypts what it got, exits 0, the shell exits 0, and the guarantee below is
|
|
145
|
+
satisfied by a truncated archive that restores cleanly and matches every sha256. `pipefail`
|
|
146
|
+
is what makes the shell report the producer's failure as its own. `bash` rather than `sh`
|
|
147
|
+
because `sh` is `dash` on Debian and Ubuntu and `dash` has no `pipefail` โ it refuses the line
|
|
148
|
+
and exits 2 before writing a byte, which telstore turns into a failed run rather than a bad
|
|
149
|
+
backup, but a shell that refuses is not an example anyone can paste.
|
|
150
|
+
|
|
151
|
+
Nothing has to exist on disk first, which is the point: `tar` of a 200GB directory would
|
|
152
|
+
otherwise need 200GB free before a single byte reached Telegram, and `pg_dump` leaves no file
|
|
153
|
+
to point telstore at in the first place.
|
|
154
|
+
|
|
155
|
+
**The manifest is sent if and only if the command's output reached its end *and* the command
|
|
156
|
+
exited 0.** That biconditional is the whole reason telstore runs the command itself instead of
|
|
157
|
+
reading a pipe. When `tar` dies halfway it closes its end of the pipe, and an EOF after a crash
|
|
158
|
+
is byte-for-byte the same event as an EOF after success โ a reader on the other end cannot tell
|
|
159
|
+
them apart, and would hand you a manifest for a truncated archive that restores cleanly, matches
|
|
160
|
+
every sha256, and is garbage. A parent process sees the exit code.
|
|
161
|
+
|
|
162
|
+
So a run that fails **removes the chunks it already sent.** That is the opposite of what a file
|
|
163
|
+
upload does, and deliberately: a file upload keeps its chunks because a second run resumes onto
|
|
164
|
+
them. A stream has no second run โ the bytes have gone past, and a later run cuts them
|
|
165
|
+
differently โ so a chunk left in the chat by a failed stream is a chunk no manifest will ever
|
|
166
|
+
name. Ctrl-C means the same thing here: telstore asks the run to remove what it sent and waits
|
|
167
|
+
for it, rather than leaving where it stands. If the removal itself cannot finish, the record
|
|
168
|
+
stays on this machine and the run prints the `npx telstore delete <id> --chat <chat>` that
|
|
169
|
+
finishes it by hand. `delete` reads the chat for chunks carrying the backup id rather than
|
|
170
|
+
taking that record's word for what is there, so a chunk that landed after the record stopped
|
|
171
|
+
being written goes too โ and when it cannot read far enough back to be sure, it says so
|
|
172
|
+
instead of reporting the backup gone.
|
|
173
|
+
|
|
174
|
+
There is no shell in between: the command is spawned as an argv, so nothing needs quoting and
|
|
175
|
+
telstore never builds a command string out of your arguments. `-- bash -c 'set -o pipefail;
|
|
176
|
+
...'` is how a pipeline gets in โ with `pipefail`, for the reason above: the biconditional is
|
|
177
|
+
only as good as the exit code the shell reports, and a pipeline without it reports the wrong
|
|
178
|
+
one. That is also how your data is compressed or encrypted **before** it reaches Telegram โ
|
|
179
|
+
`zstd`, `gpg`, `age` โ with telstore holding nobody's passphrase. The command's
|
|
180
|
+
stderr is left as it is, so one that fails explains itself in its own words and telstore adds
|
|
181
|
+
only the exit code and what it did about it.
|
|
182
|
+
|
|
183
|
+
## Checking a backup is still there
|
|
184
|
+
|
|
185
|
+
A backup is a set of messages in a chat, and messages can be deleted by hand. `list` reads
|
|
186
|
+
the manifest's card and would happily show a backup whose chunks are long gone; `verify` asks
|
|
187
|
+
the chat about every chunk the manifest names:
|
|
188
|
+
|
|
189
|
+
```
|
|
190
|
+
$ npx telstore verify telstore-20260905-7f3a91
|
|
191
|
+
Backup telstore-20260905-7f3a91
|
|
192
|
+
File data.tar (21.4 GB, 12 chunks)
|
|
193
|
+
In https://web.telegram.org/k/#@my_backups
|
|
194
|
+
|
|
195
|
+
12 chunks present, at the sizes the manifest records.
|
|
196
|
+
This does not download them, so it cannot prove their contents.
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
It costs one request per hundred chunks and no bandwidth, so it is cheap enough to run on a
|
|
200
|
+
schedule. What it proves is that a restore would find everything it needs โ every chunk still
|
|
201
|
+
there, under the file name telstore wrote, at the length the manifest records. It does not
|
|
202
|
+
read the chunks, so it cannot speak for what is inside them; only a restore does that, and a
|
|
203
|
+
restore checks every sha256 before it renames anything into place.
|
|
204
|
+
|
|
205
|
+
A backup missing chunks is named line by line, and the exit code is 1:
|
|
206
|
+
|
|
207
|
+
```
|
|
208
|
+
Chunk 3/12 is gone: message 1042 is no longer in @my_backups.
|
|
209
|
+
|
|
210
|
+
12 chunks checked, 1 damaged. This backup cannot be restored.
|
|
211
|
+
```
|
|
212
|
+
|
|
120
213
|
## Several backups at once
|
|
121
214
|
|
|
122
|
-
`restore` and `delete` take a list of ids the same way, over one connection, with a
|
|
123
|
-
and a non-zero exit code if any of them failed:
|
|
215
|
+
`restore`, `verify` and `delete` take a list of ids the same way, over one connection, with a
|
|
216
|
+
summary and a non-zero exit code if any of them failed:
|
|
124
217
|
|
|
125
218
|
```bash
|
|
126
219
|
npx telstore restore telstore-20260905-7f3a91 telstore-20260901-9de447
|
|
220
|
+
npx telstore verify telstore-20260905-7f3a91 telstore-20260901-9de447
|
|
127
221
|
npx telstore delete telstore-20260905-7f3a91 telstore-20260901-9de447
|
|
128
222
|
```
|
|
129
223
|
|
|
@@ -179,14 +273,25 @@ There is no expiry and no revocation: to end a session for good, terminate it un
|
|
|
179
273
|
else's infrastructure โ the one thing telstore encrypts is a session token, and that
|
|
180
274
|
protects your login rather than your files.
|
|
181
275
|
- A chunk cannot exceed 1950MB: Telegram accepts at most 4000 parts of 512KB per file.
|
|
276
|
+
- **A backup made from a command cannot be resumed**, so a run that fails or is interrupted
|
|
277
|
+
removes the chunks it had already sent instead of keeping them. Ctrl-C takes a moment longer
|
|
278
|
+
for that reason, and leaves nothing of that run behind in the chat.
|
|
182
279
|
- Deleting a chunk message in the Telegram app destroys the backup, and keeping the
|
|
183
|
-
`backupId` is what saves you hunting for its manifest in the chat by hand.
|
|
280
|
+
`backupId` is what saves you hunting for its manifest in the chat by hand. `verify` is how
|
|
281
|
+
you find that out before you need the file rather than after.
|
|
184
282
|
|
|
185
283
|
Settings and credentials live in `~/.telstore/config.json`, mode 600 โ `apiId`, `apiHash` and
|
|
186
284
|
the session at the top level (or a single `sealed` blob after `login --token`), everything
|
|
187
285
|
`config` manages under `settings`. Editing it by hand is fine: a value that cannot be used is
|
|
188
286
|
named on the next run, with the file and the key.
|
|
189
287
|
|
|
288
|
+
`npx telstore down` removes all of that, and `~/.telstore/state/` with it โ everything on
|
|
289
|
+
this machine, in one question. It opens no connection: your backups stay in the chat, and
|
|
290
|
+
the session stays alive on Telegram's side until you terminate it under Settings โ Devices.
|
|
291
|
+
Unlike `logout` it takes the `api_id` and `api_hash` too, so the next `login` asks for them
|
|
292
|
+
again. A half-finished restore's `.partial` is left where it is and named on the way out โ
|
|
293
|
+
it still resumes, and after this nothing else will remind you it is there.
|
|
294
|
+
|
|
190
295
|
## License
|
|
191
296
|
|
|
192
297
|
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,194 @@ 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 null. The run removes its
|
|
42
|
+
// own on every ending it gets to run code for; this exists for the one ending it does not.
|
|
43
|
+
let tempChunk = null
|
|
44
|
+
|
|
45
|
+
// The command a streaming restore is feeding, if one is running. A stream upload unwinds
|
|
46
|
+
// cooperatively and kills its own child on the way; a restore has nothing in the chat to
|
|
47
|
+
// unwind, so Ctrl-C leaves at once โ and leaving without this would let tar go on writing
|
|
48
|
+
// files into somebody's directory after telstore had said it stopped. Synchronous, like
|
|
49
|
+
// dropTempChunk and for the same reason: anything awaited here would be waiting on the run
|
|
50
|
+
// this exit exists to stop waiting for.
|
|
51
|
+
let killChild = null
|
|
52
|
+
|
|
53
|
+
function stopChild() {
|
|
54
|
+
const kill = killChild
|
|
55
|
+
|
|
56
|
+
if (kill === null) return
|
|
57
|
+
|
|
58
|
+
killChild = null
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
kill()
|
|
62
|
+
} catch {
|
|
63
|
+
// Already gone. There is nothing this could do about it and nothing worth saying.
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// What a Ctrl-C that will not wait can still do about that file, and it has to be exactly this
|
|
68
|
+
// shape: local and unawaited, because anything awaited here would be waiting on the very run
|
|
69
|
+
// this exit exists to stop waiting for. `unlinkSync` holds no handle, opens no socket and
|
|
70
|
+
// cannot hang. Removing a file this process still has open is not a problem where it matters
|
|
71
|
+
// either: on POSIX the name goes now and the space comes back as the process dies. A platform
|
|
72
|
+
// that refuses to unlink an open file leaves the user with a file holding up to a whole chunk,
|
|
73
|
+
// so that ending โ and only that ending โ prints.
|
|
74
|
+
//
|
|
75
|
+
// It is not free, though, and "one syscall" is the wrong number to quote. Three costs, and
|
|
76
|
+
// they are three different numbers: the call removes a name and returns in microseconds; the
|
|
77
|
+
// goodbye line still reaches the terminal in about 1.2ms; and then the extents are freed at
|
|
78
|
+
// the *last close*, which is process teardown. Measured on ext4, SIGINT to exit, file still
|
|
79
|
+
// open: 60-100ms against 29-43ms for a 64MB chunk, 596-658ms against 45-56ms at 512MB, and
|
|
80
|
+
// 1131-1646ms against 45-64ms at the default 1792MB โ so at default settings the prompt comes
|
|
81
|
+
// back about a second after the message does. Nothing avoids that second: whoever runs `rm`
|
|
82
|
+
// on the leftover instead pays the same teardown. docs/design/data-integrity.md has the
|
|
83
|
+
// conditions.
|
|
84
|
+
//
|
|
85
|
+
// Silence on success is the point rather than an omission: the rule is that nothing telstore
|
|
86
|
+
// leaves behind goes unnamed, and this leaves nothing behind.
|
|
87
|
+
function dropTempChunk() {
|
|
88
|
+
const file = tempChunk
|
|
89
|
+
|
|
90
|
+
if (file === null) return ''
|
|
91
|
+
|
|
92
|
+
tempChunk = null
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
unlinkSync(file)
|
|
96
|
+
return ''
|
|
97
|
+
} catch (err) {
|
|
98
|
+
// Already gone โ the run's own discard won the race โ is not something to report.
|
|
99
|
+
if (err.code === 'ENOENT') return ''
|
|
100
|
+
|
|
101
|
+
return (
|
|
102
|
+
`\nThe chunk telstore was buffering is still on this machine: ${file} ` +
|
|
103
|
+
`(${err.message}). It holds up to one chunk โ remove it by hand.\n`
|
|
104
|
+
)
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
17
108
|
// A batch clears each finished item's record as it goes, so by the time Ctrl-C lands these
|
|
18
109
|
// are transfers no second run should touch. Ctrl-C needs their names to say so.
|
|
19
110
|
const finished = []
|
|
20
111
|
|
|
112
|
+
// Every exit Ctrl-C leads to comes through here. Through exitWhenFlushed rather than straight
|
|
113
|
+
// to process.exit, because on a pipe stderr is asynchronous, and the line most at risk of
|
|
114
|
+
// being cut in half is the one below carrying the command that removes the leftovers.
|
|
115
|
+
function leave(message) {
|
|
116
|
+
stopChild()
|
|
117
|
+
|
|
118
|
+
// Called twice means Ctrl-C landed again while the first line was still flushing, or the
|
|
119
|
+
// deadline arrived on top of it. There is nothing more to say, and someone pressing it a
|
|
120
|
+
// second time is asking to be gone rather than read to.
|
|
121
|
+
if (leaving) process.exit(SIGINT_EXIT_CODE)
|
|
122
|
+
|
|
123
|
+
leaving = true
|
|
124
|
+
|
|
125
|
+
// Every exit the handler leads to comes through here, which is why the borrowed disk is
|
|
126
|
+
// given back here rather than in the second-Ctrl-C arm alone: the deadline running out
|
|
127
|
+
// ends the process in exactly the same place, and so does a Ctrl-C on a run that had
|
|
128
|
+
// nothing to unwind. One write, so the two lines cannot be split by a slow pipe.
|
|
129
|
+
process.stderr.write(message + dropTempChunk())
|
|
130
|
+
exitWhenFlushed(SIGINT_EXIT_CODE)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Said when the waiting ends without the rollback having finished โ because someone pressed
|
|
134
|
+
// Ctrl-C again, or because the deadline above ran out. Neither knows how far the removal got,
|
|
135
|
+
// so both point at the command that finishes it by hand.
|
|
136
|
+
function leaveNow() {
|
|
137
|
+
leave(
|
|
138
|
+
interruptMessage(currentCommand, {
|
|
139
|
+
backupId: currentBackupId,
|
|
140
|
+
done: finished,
|
|
141
|
+
stream: true,
|
|
142
|
+
again: true,
|
|
143
|
+
chat: currentChat,
|
|
144
|
+
}),
|
|
145
|
+
)
|
|
146
|
+
}
|
|
147
|
+
|
|
21
148
|
process.on('SIGINT', () => {
|
|
22
149
|
// A passphrase prompt has stdin in raw mode, and process.exit skips readline's own cleanup.
|
|
23
150
|
// Without this, Ctrl-C hands back a shell that no longer echoes what is typed into it.
|
|
24
151
|
if (process.stdin.isTTY) process.stdin.setRawMode(false)
|
|
25
152
|
|
|
153
|
+
// Checked before anything else, because every message below describes a run that is still
|
|
154
|
+
// going. Said about one that has settled they are all false: a removal that is not running,
|
|
155
|
+
// leftovers that were already removed, a resume for a backup that is finished and valid.
|
|
156
|
+
// The window is real โ exitWhenFlushed waits up to two seconds on a pipe nobody is reading.
|
|
157
|
+
if (settled) {
|
|
158
|
+
leave(interruptMessage(null))
|
|
159
|
+
return
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// A second Ctrl-C is someone saying they will not wait.
|
|
163
|
+
if (interrupting) {
|
|
164
|
+
leaveNow()
|
|
165
|
+
return
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Everything else keeps what it has sent on purpose โ a file upload's chunks are what the
|
|
169
|
+
// next run resumes onto โ so there is nothing to unwind and Ctrl-C is the immediate exit it
|
|
170
|
+
// has always been.
|
|
171
|
+
if (!abortRun) {
|
|
172
|
+
leave(
|
|
173
|
+
interruptMessage(currentCommand, {
|
|
174
|
+
backupId: currentBackupId,
|
|
175
|
+
done: finished,
|
|
176
|
+
stream: streaming,
|
|
177
|
+
}),
|
|
178
|
+
)
|
|
179
|
+
return
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
interrupting = true
|
|
26
183
|
process.stderr.write(
|
|
27
|
-
interruptMessage(currentCommand, {
|
|
184
|
+
interruptMessage(currentCommand, {
|
|
185
|
+
backupId: currentBackupId,
|
|
186
|
+
done: finished,
|
|
187
|
+
stream: true,
|
|
188
|
+
}),
|
|
28
189
|
)
|
|
29
|
-
|
|
190
|
+
|
|
191
|
+
// Deliberately not unref'd, and it does two jobs. It is the deadline; it is also the one
|
|
192
|
+
// handle that keeps this process alive while the rollback runs. Node leaves with 0 the
|
|
193
|
+
// moment nothing is pending, and a rollback waiting on something that holds no handle
|
|
194
|
+
// would end the process mid-removal reporting success, with the chunks still in the chat.
|
|
195
|
+
// The upload arm clears it, so a rollback that finishes normally never meets the deadline.
|
|
196
|
+
deadline = setTimeout(leaveNow, CLEANUP_DEADLINE_MS)
|
|
197
|
+
|
|
198
|
+
// The abort itself only asks; the waiting is done by main, which is still awaiting the run.
|
|
199
|
+
// Nothing it could reject with matters here โ the run reports its own end.
|
|
200
|
+
Promise.resolve(abortRun()).catch(() => {})
|
|
30
201
|
})
|
|
31
202
|
|
|
32
203
|
async function main() {
|
|
@@ -65,6 +236,13 @@ async function main() {
|
|
|
65
236
|
return
|
|
66
237
|
}
|
|
67
238
|
|
|
239
|
+
case 'down': {
|
|
240
|
+
const { runDown } = await import('../src/commands/down.js')
|
|
241
|
+
|
|
242
|
+
await runDown(parsed.args, parsed.options)
|
|
243
|
+
return
|
|
244
|
+
}
|
|
245
|
+
|
|
68
246
|
case 'list': {
|
|
69
247
|
const { runList } = await import('../src/commands/list.js')
|
|
70
248
|
|
|
@@ -94,6 +272,46 @@ async function main() {
|
|
|
94
272
|
}
|
|
95
273
|
|
|
96
274
|
case 'upload': {
|
|
275
|
+
// `telstore a.tar -- tar cf - ./a`: one name, and the bytes are what the command writes
|
|
276
|
+
// rather than a file on disk. route has already refused every other shape of that line.
|
|
277
|
+
if (parsed.childArgv) {
|
|
278
|
+
const { runStreamUpload } = await import('../src/commands/upload-stream.js')
|
|
279
|
+
|
|
280
|
+
streaming = true
|
|
281
|
+
|
|
282
|
+
try {
|
|
283
|
+
await runStreamUpload(parsed.args[0], parsed.childArgv, parsed.options, {
|
|
284
|
+
onBackupId: (id) => {
|
|
285
|
+
currentBackupId = id
|
|
286
|
+
},
|
|
287
|
+
onAbortable: (abort, { chat } = {}) => {
|
|
288
|
+
abortRun = abort
|
|
289
|
+
currentChat = chat ?? null
|
|
290
|
+
},
|
|
291
|
+
onTempChunk: (file) => {
|
|
292
|
+
tempChunk = file
|
|
293
|
+
},
|
|
294
|
+
})
|
|
295
|
+
} catch (err) {
|
|
296
|
+
// Only the run that stopped because Ctrl-C asked it to, which has already said so
|
|
297
|
+
// and already put the chat back as it found it. Everything else โ a rollback that
|
|
298
|
+
// could not finish included, since that throws an error of its own โ is a failure
|
|
299
|
+
// the handler below reports.
|
|
300
|
+
if (!err?.interrupted) throw err
|
|
301
|
+
|
|
302
|
+
process.stderr.write('\nStopped. Nothing this run sent was left in the chat.\n')
|
|
303
|
+
process.exitCode = SIGINT_EXIT_CODE
|
|
304
|
+
} finally {
|
|
305
|
+
// Nothing left to unwind, and nothing left to say about it: whichever way the run
|
|
306
|
+
// ended, it ended. The deadline that was holding the process open goes with it.
|
|
307
|
+
settled = true
|
|
308
|
+
abortRun = null
|
|
309
|
+
if (deadline) clearTimeout(deadline)
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
return
|
|
313
|
+
}
|
|
314
|
+
|
|
97
315
|
const { runUploads } = await import('../src/commands/upload.js')
|
|
98
316
|
|
|
99
317
|
const { failed } = await runUploads(parsed.args, parsed.options, {
|
|
@@ -115,6 +333,36 @@ async function main() {
|
|
|
115
333
|
}
|
|
116
334
|
|
|
117
335
|
case 'restore': {
|
|
336
|
+
// The spec's stage 2, and the refusal that stood here is gone: the bytes were asked for
|
|
337
|
+
// on a command's stdin and that is now where they go.
|
|
338
|
+
if (parsed.childArgv) {
|
|
339
|
+
const { runRestoreStream } = await import('../src/commands/restore-stream.js')
|
|
340
|
+
|
|
341
|
+
// So Ctrl-C says the restore sentence rather than the upload one.
|
|
342
|
+
streaming = true
|
|
343
|
+
|
|
344
|
+
await runRestoreStream(parsed.args[0], parsed.childArgv, parsed.options, {
|
|
345
|
+
// Only the alias promises gzip. The general form promises nothing and is asked
|
|
346
|
+
// nothing, which is how `restore <id> -- tar xf -` stays useful.
|
|
347
|
+
requireGzipName: parsed.shortcut === 'tarx',
|
|
348
|
+
onBackupId: (id) => {
|
|
349
|
+
currentBackupId = id
|
|
350
|
+
},
|
|
351
|
+
onTempChunk: (file) => {
|
|
352
|
+
tempChunk = file
|
|
353
|
+
},
|
|
354
|
+
onChild: (kill) => {
|
|
355
|
+
killChild = kill
|
|
356
|
+
},
|
|
357
|
+
})
|
|
358
|
+
|
|
359
|
+
// Mirrors the stream-upload branch above: the run has finished, so a Ctrl-C landing in
|
|
360
|
+
// the output-flush window that follows must not print the "removing what it already
|
|
361
|
+
// sent" sentence about a restore that already has everything it is going to get.
|
|
362
|
+
settled = true
|
|
363
|
+
return
|
|
364
|
+
}
|
|
365
|
+
|
|
118
366
|
if (!parsed.args[0]) {
|
|
119
367
|
throw new Error('Missing backup id. Example: npx telstore restore telstore-20260905-7f3a91')
|
|
120
368
|
}
|
|
@@ -134,6 +382,21 @@ async function main() {
|
|
|
134
382
|
return
|
|
135
383
|
}
|
|
136
384
|
|
|
385
|
+
case 'verify': {
|
|
386
|
+
if (!parsed.args[0]) {
|
|
387
|
+
throw new Error('Missing backup id. Example: npx telstore verify telstore-20260905-7f3a91')
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const { runVerifies } = await import('../src/commands/verify.js')
|
|
391
|
+
|
|
392
|
+
const { failed } = await runVerifies(parsed.args, parsed.options)
|
|
393
|
+
|
|
394
|
+
// A backup that is damaged, and one telstore could not look up at all, both mean the
|
|
395
|
+
// run did not find what it was asked to check. Whatever runs telstore learns that here.
|
|
396
|
+
if (failed > 0) process.exitCode = 1
|
|
397
|
+
return
|
|
398
|
+
}
|
|
399
|
+
|
|
137
400
|
case 'delete': {
|
|
138
401
|
if (!parsed.args[0]) {
|
|
139
402
|
throw new Error('Missing backup id. Example: npx telstore delete telstore-20260905-7f3a91')
|
|
@@ -182,7 +445,12 @@ main().then(
|
|
|
182
445
|
},
|
|
183
446
|
(err) => {
|
|
184
447
|
process.stderr.write(`Error: ${err.message}\n`)
|
|
185
|
-
|
|
186
|
-
|
|
448
|
+
|
|
449
|
+
// A run the handler asked to stop leaves with 130 whatever it then had to say: a rollback
|
|
450
|
+
// that could not finish is still an interrupted run, not a command that failed on its own.
|
|
451
|
+
const code = interrupting ? SIGINT_EXIT_CODE : 1
|
|
452
|
+
|
|
453
|
+
process.exitCode = code
|
|
454
|
+
exitWhenFlushed(code)
|
|
187
455
|
},
|
|
188
456
|
)
|
package/package.json
CHANGED
package/src/caption.js
CHANGED
|
@@ -49,8 +49,13 @@ function utcMinutes(createdAt) {
|
|
|
49
49
|
return `${new Date(createdAt).toISOString().slice(0, 16).replace('T', ' ')} UTC`
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
// A stream knows its chunk number and not its count. "3/?" would be a question mark in the
|
|
53
|
+
// chat forever; the number alone is true at the time it is written, and the manifest card
|
|
54
|
+
// carries the final count.
|
|
52
55
|
export function chunkCaption({ id, number, total }) {
|
|
53
|
-
return
|
|
56
|
+
return total === null || total === undefined
|
|
57
|
+
? `๐ฆ ${id} ยท ${number}`
|
|
58
|
+
: `๐ฆ ${id} ยท ${number}/${total}`
|
|
54
59
|
}
|
|
55
60
|
|
|
56
61
|
export function manifestCaption({ id, name, size, chunks, createdAt, note = null }) {
|