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 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. `list` reads
69
- those cards straight out of the chat, no downloads:
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.** Don't upload anything you would mind sitting on someone
211
- else's infrastructure โ€” the one thing telstore encrypts is a session token, and that
212
- protects your login rather than your files.
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, { backupId: currentBackupId, done: finished }),
185
+ interruptMessage(currentCommand, {
186
+ backupId: currentBackupId,
187
+ done: finished,
188
+ stream: true,
189
+ }),
28
190
  )
29
- process.exit(SIGINT_EXIT_CODE)
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
- process.exitCode = 1
201
- exitWhenFlushed(1)
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "telstore",
3
- "version": "0.1.9",
3
+ "version": "0.1.11",
4
4
  "description": "Split large files into chunks and store them on Telegram",
5
5
  "keywords": [
6
6
  "telegram",
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 `๐Ÿ“ฆ ${id} ยท ${number}/${total}`
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
  }