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 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. `list` reads
68
- those cards straight out of the chat, one search and no downloads:
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 summary
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, { backupId: currentBackupId, done: finished }),
184
+ interruptMessage(currentCommand, {
185
+ backupId: currentBackupId,
186
+ done: finished,
187
+ stream: true,
188
+ }),
28
189
  )
29
- process.exit(SIGINT_EXIT_CODE)
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
- process.exitCode = 1
186
- exitWhenFlushed(1)
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "telstore",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
4
4
  "description": "Split large files into chunks and store them on Telegram",
5
5
  "keywords": [
6
6
  "telegram",
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 `๐Ÿ“ฆ ${id} ยท ${number}/${total}`
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 }) {