telstore 0.1.6 → 0.1.7
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 +68 -6
- package/bin/telstore.js +27 -7
- package/package.json +1 -1
- package/src/caption.js +40 -2
- package/src/cli.js +73 -20
- package/src/commands/delete.js +196 -1
- package/src/commands/list.js +29 -5
- package/src/commands/restore.js +118 -1
- package/src/commands/status.js +4 -4
- package/src/commands/upload.js +255 -13
- package/src/manifest.js +21 -1
- package/src/settings.js +6 -6
- package/src/sources.js +163 -0
package/README.md
CHANGED
|
@@ -14,6 +14,8 @@ development tools; `login` asks for those, your phone number and the verificatio
|
|
|
14
14
|
npx telstore login # once only
|
|
15
15
|
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
|
+
npx telstore a.tar b.tar c.tar # or several: one backup each, one after another
|
|
18
|
+
npx telstore ./backups # or a folder: every file one level inside it
|
|
17
19
|
npx telstore list # what is already in the destination
|
|
18
20
|
npx telstore restore telstore-20260905-7f3a91
|
|
19
21
|
```
|
|
@@ -23,10 +25,10 @@ npx telstore restore telstore-20260905-7f3a91
|
|
|
23
25
|
| Command | What it does |
|
|
24
26
|
|---|---|
|
|
25
27
|
| `telstore login` | Log in to Telegram. Add `--token` to log in with a session token instead. |
|
|
26
|
-
| `telstore <file
|
|
28
|
+
| `telstore <file\|folder\|pattern>...` | Split each file and upload it. Prints the `backupId` you restore with. |
|
|
27
29
|
| `telstore list` | The backups stored in the destination, newest first. |
|
|
28
|
-
| `telstore restore <backup-id
|
|
29
|
-
| `telstore delete <backup-id
|
|
30
|
+
| `telstore restore <backup-id>...` | Download every chunk and reassemble the file. Several ids run one after another. |
|
|
31
|
+
| `telstore delete <backup-id>...` | Remove a backup's chunks and manifest from the chat, for good. Several ids are listed and confirmed once. |
|
|
30
32
|
| `telstore status` | Account, destination, and unfinished backups. |
|
|
31
33
|
| `telstore config` | Show or change settings. |
|
|
32
34
|
| `telstore token` | Print a session token for a machine you do not trust. |
|
|
@@ -35,7 +37,7 @@ npx telstore restore telstore-20260905-7f3a91
|
|
|
35
37
|
## Settings
|
|
36
38
|
|
|
37
39
|
`config` writes; flags do not. A flag applies to the run you typed it on and changes nothing
|
|
38
|
-
on disk, so `--
|
|
40
|
+
on disk, so `--chat @elsewhere` sends one backup elsewhere without moving the destination for
|
|
39
41
|
the next one.
|
|
40
42
|
|
|
41
43
|
```bash
|
|
@@ -46,7 +48,7 @@ npx telstore config chunkSize --unset # back to the default
|
|
|
46
48
|
|
|
47
49
|
| Setting | Flag | Default | Meaning |
|
|
48
50
|
|---|---|---|---|
|
|
49
|
-
| `chat` | `--
|
|
51
|
+
| `chat` | `--chat` | none | `@username`, `-100123…`, or `me` |
|
|
50
52
|
| `chunkSize` | `--chunk-size` | `1800MB` | e.g. `1.8GB`, `500MB`. Ceiling 1950MB. |
|
|
51
53
|
| `uploadConcurrency` | `--upload-concurrency` | `32` | 512KB parts in parallel while uploading, 1–64 |
|
|
52
54
|
| `downloadConcurrency` | `--download-concurrency` | `8` | 8MB slices in parallel while restoring, 1–64 |
|
|
@@ -54,7 +56,8 @@ npx telstore config chunkSize --unset # back to the default
|
|
|
54
56
|
| `verbose` | `--verbose` | off | Show the Telegram client's own connection logs |
|
|
55
57
|
|
|
56
58
|
Three flags have no setting behind them: `--out <path>` names where one restore writes,
|
|
57
|
-
`--yes` skips the confirmation `delete`
|
|
59
|
+
`--yes` skips the confirmation an upload batch and `delete` ask for, and `--token` takes no
|
|
60
|
+
value — a token written
|
|
58
61
|
on the command line would sit in `ps` and in that machine's shell history, so it is pasted at
|
|
59
62
|
a prompt that does not echo.
|
|
60
63
|
|
|
@@ -74,6 +77,61 @@ telstore-20260901-9de447 photos.zip 940.3 MB 1 2026-09-01
|
|
|
74
77
|
2 backups. Restore with: npx telstore restore <backup-id>
|
|
75
78
|
```
|
|
76
79
|
|
|
80
|
+
## Several files at once
|
|
81
|
+
|
|
82
|
+
`telstore a.tar b.tar c.tar` uploads them one after another over a single connection. Each
|
|
83
|
+
file becomes its own backup with its own `backupId`, exactly as three separate runs would
|
|
84
|
+
have produced.
|
|
85
|
+
|
|
86
|
+
A **folder** stands for the files one level inside it — subfolders are named on screen and
|
|
87
|
+
left alone, hidden files are skipped. A **pattern** stands for the names it matches:
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
npx telstore ./backups # every file directly inside ./backups
|
|
91
|
+
npx telstore 'logs/*.tar' # quoted, so telstore matches it rather than the shell
|
|
92
|
+
npx telstore logs/abc* # unquoted: your shell expands it first, same result
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
More than one file is listed, added up and confirmed before the first byte goes out:
|
|
96
|
+
|
|
97
|
+
```
|
|
98
|
+
3 files, 4.20 GB, to https://web.telegram.org/k/#@my_backups
|
|
99
|
+
|
|
100
|
+
a.tar 1.20 GB
|
|
101
|
+
b.tar 2.00 GB
|
|
102
|
+
c.tar 1.00 GB
|
|
103
|
+
|
|
104
|
+
Upload these 3 files? [y/N]
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
`--yes` skips the question; without a terminal to ask in, the run stops and says so rather
|
|
108
|
+
than reading an empty line as "no". Names that do not exist, and a file named twice, are
|
|
109
|
+
refused before anything is sent; a file that fails mid-transfer does not stop the ones after
|
|
110
|
+
it, and the run ends with a line per file and a non-zero exit code:
|
|
111
|
+
|
|
112
|
+
```
|
|
113
|
+
3 files: 2 uploaded, 1 failed.
|
|
114
|
+
|
|
115
|
+
a.tar telstore-20260905-7f3a91 (12 chunks)
|
|
116
|
+
b.tar failed: connection dropped mid-transfer
|
|
117
|
+
c.tar telstore-20260905-9de447 (1 chunk)
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
## Several backups at once
|
|
121
|
+
|
|
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:
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
npx telstore restore telstore-20260905-7f3a91 telstore-20260901-9de447
|
|
127
|
+
npx telstore delete telstore-20260905-7f3a91 telstore-20260901-9de447
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
With several ids, `restore` writes each file under the name in its own manifest, so `--out`
|
|
131
|
+
— which names exactly one file — is refused rather than quietly used three times. `delete`
|
|
132
|
+
looks every id up first and shows the whole list before asking once; an id that is nowhere to
|
|
133
|
+
be found stops the run before anything is destroyed.
|
|
134
|
+
|
|
77
135
|
## Resuming an upload
|
|
78
136
|
|
|
79
137
|
Progress lives in `~/.telstore/state/` (the 20 most recent), so running the same command
|
|
@@ -81,6 +139,10 @@ again skips the finished chunks and keeps the same `backupId`. A resumed backup
|
|
|
81
139
|
chunk size it started with; passing a `--chunk-size` or a destination that differs from its
|
|
82
140
|
own makes telstore refuse to run rather than re-cut or redirect it silently.
|
|
83
141
|
|
|
142
|
+
After a batch, run telstore again with **only the files that are left**: the finished ones
|
|
143
|
+
have had their records cleared, so repeating the whole command would upload them a second
|
|
144
|
+
time as new backups. `npx telstore status` lists what is unfinished.
|
|
145
|
+
|
|
84
146
|
**Restore keeps no state** — `Ctrl-C` mid-restore saves nothing, running again starts over.
|
|
85
147
|
And `delete` has **no undo**: Telegram is the only copy.
|
|
86
148
|
|
package/bin/telstore.js
CHANGED
|
@@ -14,12 +14,18 @@ const SIGINT_EXIT_CODE = 130
|
|
|
14
14
|
let currentCommand = null
|
|
15
15
|
let currentBackupId = null
|
|
16
16
|
|
|
17
|
+
// A batch clears each finished file's record as it goes, so by the time Ctrl-C lands these
|
|
18
|
+
// are backups no second run should touch. Ctrl-C needs their names to say so.
|
|
19
|
+
const finishedUploads = []
|
|
20
|
+
|
|
17
21
|
process.on('SIGINT', () => {
|
|
18
22
|
// A passphrase prompt has stdin in raw mode, and process.exit skips readline's own cleanup.
|
|
19
23
|
// Without this, Ctrl-C hands back a shell that no longer echoes what is typed into it.
|
|
20
24
|
if (process.stdin.isTTY) process.stdin.setRawMode(false)
|
|
21
25
|
|
|
22
|
-
process.stderr.write(
|
|
26
|
+
process.stderr.write(
|
|
27
|
+
interruptMessage(currentCommand, { backupId: currentBackupId, done: finishedUploads }),
|
|
28
|
+
)
|
|
23
29
|
process.exit(SIGINT_EXIT_CODE)
|
|
24
30
|
})
|
|
25
31
|
|
|
@@ -88,13 +94,23 @@ async function main() {
|
|
|
88
94
|
}
|
|
89
95
|
|
|
90
96
|
case 'upload': {
|
|
91
|
-
const {
|
|
97
|
+
const { runUploads } = await import('../src/commands/upload.js')
|
|
92
98
|
|
|
93
|
-
await
|
|
99
|
+
const { failed } = await runUploads(parsed.args, parsed.options, {
|
|
100
|
+
// Only route saw the command line, and an unquoted note is told apart from a plain
|
|
101
|
+
// missing file by where the words sat on it.
|
|
102
|
+
filesAfterNote: parsed.filesAfterNote,
|
|
94
103
|
onBackupId: (id) => {
|
|
95
104
|
currentBackupId = id
|
|
96
105
|
},
|
|
106
|
+
onFileDone: (file) => {
|
|
107
|
+
if (file.id) finishedUploads.push(file)
|
|
108
|
+
},
|
|
97
109
|
})
|
|
110
|
+
|
|
111
|
+
// A batch reports its own failures by name and has already said so on stdout; the exit
|
|
112
|
+
// code is what carries that out to whatever ran telstore.
|
|
113
|
+
if (failed > 0) process.exitCode = 1
|
|
98
114
|
return
|
|
99
115
|
}
|
|
100
116
|
|
|
@@ -103,9 +119,11 @@ async function main() {
|
|
|
103
119
|
throw new Error('Missing backup id. Example: npx telstore restore telstore-20260905-7f3a91')
|
|
104
120
|
}
|
|
105
121
|
|
|
106
|
-
const {
|
|
122
|
+
const { runRestores } = await import('../src/commands/restore.js')
|
|
107
123
|
|
|
108
|
-
await
|
|
124
|
+
const { failed } = await runRestores(parsed.args, parsed.options)
|
|
125
|
+
|
|
126
|
+
if (failed > 0) process.exitCode = 1
|
|
109
127
|
return
|
|
110
128
|
}
|
|
111
129
|
|
|
@@ -114,9 +132,11 @@ async function main() {
|
|
|
114
132
|
throw new Error('Missing backup id. Example: npx telstore delete telstore-20260905-7f3a91')
|
|
115
133
|
}
|
|
116
134
|
|
|
117
|
-
const {
|
|
135
|
+
const { runDeletes } = await import('../src/commands/delete.js')
|
|
136
|
+
|
|
137
|
+
const { failed } = await runDeletes(parsed.args, parsed.options)
|
|
118
138
|
|
|
119
|
-
|
|
139
|
+
if (failed > 0) process.exitCode = 1
|
|
120
140
|
return
|
|
121
141
|
}
|
|
122
142
|
|
package/package.json
CHANGED
package/src/caption.js
CHANGED
|
@@ -14,6 +14,37 @@ function oneLine(name) {
|
|
|
14
14
|
return String(name).replace(/\s+/g, ' ').trim()
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
// Telegram takes 1024 characters in a caption, and the card around the note already spends
|
|
18
|
+
// some of them — a file name alone may be 255. 500 leaves both room to spare.
|
|
19
|
+
export const MAX_NOTE_LENGTH = 500
|
|
20
|
+
|
|
21
|
+
// A note is written at a shell prompt and read in two places: the manifest body and the card
|
|
22
|
+
// in the chat. Folding it here, once, is what keeps those two from holding slightly different
|
|
23
|
+
// notes and leaving nobody able to say which one was typed.
|
|
24
|
+
export function parseNote(raw) {
|
|
25
|
+
if (raw === undefined || raw === null) return null
|
|
26
|
+
|
|
27
|
+
const note = oneLine(raw)
|
|
28
|
+
|
|
29
|
+
if (note === '') {
|
|
30
|
+
throw new Error(
|
|
31
|
+
'--note is empty. Write the note itself, or leave the flag off — a backup with a blank ' +
|
|
32
|
+
'note is one telstore had something to say about and did not.',
|
|
33
|
+
)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (note.length > MAX_NOTE_LENGTH) {
|
|
37
|
+
throw new Error(
|
|
38
|
+
`--note is ${note.length} characters, and a caption has only room for ${MAX_NOTE_LENGTH} ` +
|
|
39
|
+
'once the rest of the card has had its share. Shorten it: telstore will not cut it ' +
|
|
40
|
+
'short by itself, because half a note read as a whole one is exactly the kind of ' +
|
|
41
|
+
'plausible wrong answer this tool exists to refuse.',
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return note
|
|
46
|
+
}
|
|
47
|
+
|
|
17
48
|
function utcMinutes(createdAt) {
|
|
18
49
|
return `${new Date(createdAt).toISOString().slice(0, 16).replace('T', ' ')} UTC`
|
|
19
50
|
}
|
|
@@ -22,12 +53,15 @@ export function chunkCaption({ id, number, total }) {
|
|
|
22
53
|
return `📦 ${id} · ${number}/${total}`
|
|
23
54
|
}
|
|
24
55
|
|
|
25
|
-
export function manifestCaption({ id, name, size, chunks, createdAt }) {
|
|
56
|
+
export function manifestCaption({ id, name, size, chunks, createdAt, note = null }) {
|
|
26
57
|
return [
|
|
27
58
|
`📄 ${oneLine(name)}`,
|
|
28
59
|
`💾 ${formatBytes(size)} · ${chunks} chunk${chunks === 1 ? '' : 's'}`,
|
|
29
60
|
`🆔 ${id}`,
|
|
30
61
|
`📅 ${utcMinutes(createdAt)}`,
|
|
62
|
+
// Below the facts telstore knows, above the line that says how to get the file back:
|
|
63
|
+
// the note is the one part of the card a person wrote, so it reads last of the four.
|
|
64
|
+
...(note ? [`📝 ${oneLine(note)}`] : []),
|
|
31
65
|
'',
|
|
32
66
|
`↩ npx telstore restore ${id}`,
|
|
33
67
|
MANIFEST_TAG,
|
|
@@ -50,11 +84,15 @@ export function parseManifestCaption(text) {
|
|
|
50
84
|
const id = marker(lines, '🆔')
|
|
51
85
|
const createdAt = marker(lines, '📅')
|
|
52
86
|
|
|
87
|
+
// Every card telstore wrote before --note existed is a complete card, so the note is the
|
|
88
|
+
// one marker whose absence means "there is no note" rather than "this is not a card".
|
|
89
|
+
const note = marker(lines, '📝')
|
|
90
|
+
|
|
53
91
|
if (!name || !totals || !id || !createdAt) return null
|
|
54
92
|
|
|
55
93
|
const match = /^(.+) · (\d+) chunks?$/.exec(totals)
|
|
56
94
|
|
|
57
95
|
if (!match) return null
|
|
58
96
|
|
|
59
|
-
return { id, name, size: match[1], chunks: Number(match[2]), createdAt }
|
|
97
|
+
return { id, name, size: match[1], chunks: Number(match[2]), createdAt, note }
|
|
60
98
|
}
|
package/src/cli.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { basename } from 'node:path'
|
|
1
2
|
import { parseArgs } from 'node:util'
|
|
2
3
|
|
|
3
4
|
const SUBCOMMANDS = new Set([
|
|
@@ -13,11 +14,12 @@ const SUBCOMMANDS = new Set([
|
|
|
13
14
|
])
|
|
14
15
|
|
|
15
16
|
const OPTIONS = {
|
|
16
|
-
|
|
17
|
+
chat: { type: 'string' },
|
|
17
18
|
'chunk-size': { type: 'string' },
|
|
18
19
|
'upload-concurrency': { type: 'string' },
|
|
19
20
|
'download-concurrency': { type: 'string' },
|
|
20
21
|
out: { type: 'string' },
|
|
22
|
+
note: { type: 'string' },
|
|
21
23
|
limit: { type: 'string' },
|
|
22
24
|
verbose: { type: 'boolean' },
|
|
23
25
|
unset: { type: 'boolean' },
|
|
@@ -30,10 +32,10 @@ export const HELP = `telstore — split large files into chunks and store them o
|
|
|
30
32
|
|
|
31
33
|
Usage:
|
|
32
34
|
npx telstore login Log in to Telegram, only needed once
|
|
33
|
-
npx telstore <file
|
|
35
|
+
npx telstore <file|folder|pattern>... Split files and upload them to Telegram
|
|
34
36
|
npx telstore list List the backups stored in the destination
|
|
35
|
-
npx telstore restore <backup-id
|
|
36
|
-
npx telstore delete <backup-id
|
|
37
|
+
npx telstore restore <backup-id>... Download the chunks and reassemble the files
|
|
38
|
+
npx telstore delete <backup-id>... Remove backups' chunks and manifests from the chat
|
|
37
39
|
npx telstore status Show the account, the destination and unfinished backups
|
|
38
40
|
npx telstore config Show every setting and where its value comes from
|
|
39
41
|
npx telstore logout Remove the saved session
|
|
@@ -42,6 +44,15 @@ Running on a machine you do not trust:
|
|
|
42
44
|
npx telstore token Print a session token for another machine
|
|
43
45
|
npx telstore login --token Log in there by pasting one, session stays sealed
|
|
44
46
|
|
|
47
|
+
Several files in one run go one after another over a single connection, each becoming its own
|
|
48
|
+
backup. A folder means the files one level inside it, and a pattern means the names it matches
|
|
49
|
+
— the shell usually expands those itself, so quote one to hand it to telstore intact. More than
|
|
50
|
+
one file is listed and confirmed before the first byte goes out. Run telstore again with only
|
|
51
|
+
the files that are left to carry on after an interruption.
|
|
52
|
+
|
|
53
|
+
restore and delete take several ids the same way: one connection, one line each, and an exit
|
|
54
|
+
code that reports any that failed. delete shows everything it is about to destroy and asks once.
|
|
55
|
+
|
|
45
56
|
Settings:
|
|
46
57
|
npx telstore config <name> Print one setting's value
|
|
47
58
|
npx telstore config <name> <value> Change it for good
|
|
@@ -55,19 +66,25 @@ Settings:
|
|
|
55
66
|
verbose Show Telegram connection logs, default false.
|
|
56
67
|
|
|
57
68
|
Options apply to one run and are never saved. Use config to change a setting for good.
|
|
58
|
-
--
|
|
69
|
+
--chat <chat> Destination for this run only.
|
|
59
70
|
--chunk-size <n> Chunk size for this run only. An unfinished backup keeps the
|
|
60
71
|
size it started with.
|
|
61
72
|
--upload-concurrency <n> 512KB parts in parallel while uploading, this run only.
|
|
62
73
|
--download-concurrency <n> 8MB slices in parallel while restoring, this run only.
|
|
63
74
|
--out <path> Where to write the restored file. Defaults to the basename in
|
|
64
|
-
the manifest.
|
|
75
|
+
the manifest, and works with one backup id only.
|
|
76
|
+
--note <text> A note to store with the upload. It goes into the manifest and
|
|
77
|
+
onto the manifest message, where Telegram's own search can find
|
|
78
|
+
it, and every file of a batch gets the same one. A note with
|
|
79
|
+
spaces in it has to be quoted — --note "march archive" — or the
|
|
80
|
+
shell hands the words after the first to telstore as more files
|
|
81
|
+
to upload.
|
|
65
82
|
--limit <n> How many backups list shows this run.
|
|
66
83
|
--token Log in by pasting a session token. It takes no value on
|
|
67
84
|
purpose: a token written on the command line would sit in
|
|
68
85
|
"ps" for the whole life of the command, and stay in that
|
|
69
86
|
machine's shell history afterwards.
|
|
70
|
-
--yes
|
|
87
|
+
--yes Upload a batch, or delete, without being asked to confirm.
|
|
71
88
|
--verbose Show Telegram connection logs for this run.
|
|
72
89
|
-h, --help Show this help.
|
|
73
90
|
`
|
|
@@ -76,10 +93,27 @@ Options apply to one run and are never saved. Use config to change a setting for
|
|
|
76
93
|
// finished chunk to a state file, restore has not. Naming the backup matters because the
|
|
77
94
|
// id is what `status` lists and what a later `restore` needs — the chunks are already in
|
|
78
95
|
// the chat under that id, whether or not this run ever finishes.
|
|
79
|
-
export function interruptMessage(command, { backupId } = {}) {
|
|
96
|
+
export function interruptMessage(command, { backupId, done = [] } = {}) {
|
|
80
97
|
if (command === 'upload') {
|
|
81
98
|
const backup = backupId ? `Backup ${backupId} is saved` : 'Progress is saved'
|
|
82
99
|
|
|
100
|
+
// A batch is where "run the same command again" turns into a lie: the files it already
|
|
101
|
+
// finished have had their records cleared, so repeating the whole line would upload them
|
|
102
|
+
// a second time under new ids. Name them, and ask for the ones that are left instead.
|
|
103
|
+
if (done.length > 0) {
|
|
104
|
+
const width = Math.max(...done.map((file) => basename(file.path).length))
|
|
105
|
+
const finished = done
|
|
106
|
+
.map((file) => ` ${basename(file.path).padEnd(width)} ${file.id}`)
|
|
107
|
+
.join('\n')
|
|
108
|
+
|
|
109
|
+
return (
|
|
110
|
+
`\n${backup}. These are finished and need no second run:\n${finished}\n` +
|
|
111
|
+
'Run telstore again with only the files that are left — repeating the whole command ' +
|
|
112
|
+
'would upload the finished ones again as new backups. "npx telstore status" shows ' +
|
|
113
|
+
'what is unfinished.\n'
|
|
114
|
+
)
|
|
115
|
+
}
|
|
116
|
+
|
|
83
117
|
return (
|
|
84
118
|
`\n${backup} — run the same command again to continue, ` +
|
|
85
119
|
'or "npx telstore status" to see what is left.\n'
|
|
@@ -107,8 +141,8 @@ export function interruptMessage(command, { backupId } = {}) {
|
|
|
107
141
|
// parseArgs rejects anything starting with a dash as an option, and reports it as one:
|
|
108
142
|
// `config chat -100123` fails with "Unknown option '-1'", naming a flag nobody typed.
|
|
109
143
|
//
|
|
110
|
-
// Two shapes need rescuing, and they are rescued differently. As a flag value, `--
|
|
111
|
-
// is joined into `--
|
|
144
|
+
// Two shapes need rescuing, and they are rescued differently. As a flag value, `--chat -100123`
|
|
145
|
+
// is joined into `--chat=-100123`; only a bare negative integer qualifies, so `--chat --verbose`
|
|
112
146
|
// still reports the missing value instead of eating the next flag. As a positional —
|
|
113
147
|
// `config chat -100123` — there is nothing to join it to, so `--` goes in front and the rest
|
|
114
148
|
// of the line is handed over verbatim. That is greedy on purpose: a flag written after the
|
|
@@ -125,8 +159,8 @@ function protectNegativeChatIds(argv) {
|
|
|
125
159
|
return safe
|
|
126
160
|
}
|
|
127
161
|
|
|
128
|
-
if (argv[i] === '--
|
|
129
|
-
safe.push(`--
|
|
162
|
+
if (argv[i] === '--chat' && /^-\d+$/.test(argv[i + 1] ?? '')) {
|
|
163
|
+
safe.push(`--chat=${argv[i + 1]}`)
|
|
130
164
|
i += 1
|
|
131
165
|
continue
|
|
132
166
|
}
|
|
@@ -142,33 +176,52 @@ function protectNegativeChatIds(argv) {
|
|
|
142
176
|
return safe
|
|
143
177
|
}
|
|
144
178
|
|
|
179
|
+
// An unquoted note is gone by the time node starts: the shell hands `--note ghi chu` over as
|
|
180
|
+
// three arguments and nothing can put the quotes back. The one trace it leaves is its own
|
|
181
|
+
// tail — every word after the first sits here as a positional, after the flag — and upload
|
|
182
|
+
// needs that to tell the mistake from a plain missing file. The shape of the command line is
|
|
183
|
+
// already this file's business, so the observation is made here rather than guessed at later.
|
|
184
|
+
//
|
|
185
|
+
// The last --note is the one parseArgs kept, so it is the one whose position counts.
|
|
186
|
+
function filesNamedAfterNote(tokens) {
|
|
187
|
+
const note = tokens.findLast((token) => token.kind === 'option' && token.name === 'note')
|
|
188
|
+
|
|
189
|
+
if (!note) return false
|
|
190
|
+
|
|
191
|
+
return tokens.some((token) => token.kind === 'positional' && token.index > note.index)
|
|
192
|
+
}
|
|
193
|
+
|
|
145
194
|
export function route(argv) {
|
|
146
|
-
const { values, positionals } = parseArgs({
|
|
195
|
+
const { values, positionals, tokens } = parseArgs({
|
|
147
196
|
args: protectNegativeChatIds(argv),
|
|
148
197
|
options: OPTIONS,
|
|
149
198
|
allowPositionals: true,
|
|
199
|
+
tokens: true,
|
|
150
200
|
})
|
|
151
201
|
|
|
152
202
|
const [first, ...rest] = positionals
|
|
203
|
+
const filesAfterNote = filesNamedAfterNote(tokens)
|
|
153
204
|
|
|
154
|
-
// `telstore --
|
|
205
|
+
// `telstore --chat @chan` with no file used to mean "remember this destination". Flags no
|
|
155
206
|
// longer write anything, so that line now asks for a run that has nothing to upload —
|
|
156
207
|
// say where the destination actually lives instead of printing help at someone who was
|
|
157
208
|
// perfectly clear about what they wanted.
|
|
158
|
-
if (first === undefined && values.
|
|
209
|
+
if (first === undefined && values.chat && !values.help) {
|
|
159
210
|
throw new Error(
|
|
160
|
-
`Nothing to upload. To change the destination for good, run "npx telstore config chat ${values.
|
|
161
|
-
'To use it for one run, pass --
|
|
211
|
+
`Nothing to upload. To change the destination for good, run "npx telstore config chat ${values.chat}". ` +
|
|
212
|
+
'To use it for one run, pass --chat alongside a file or a command.',
|
|
162
213
|
)
|
|
163
214
|
}
|
|
164
215
|
|
|
165
216
|
if (values.help || first === undefined || first === 'help') {
|
|
166
|
-
return { command: 'help', args: [], options: values }
|
|
217
|
+
return { command: 'help', args: [], options: values, filesAfterNote }
|
|
167
218
|
}
|
|
168
219
|
|
|
169
220
|
if (SUBCOMMANDS.has(first)) {
|
|
170
|
-
return { command: first, args: rest, options: values }
|
|
221
|
+
return { command: first, args: rest, options: values, filesAfterNote }
|
|
171
222
|
}
|
|
172
223
|
|
|
173
|
-
|
|
224
|
+
// Every positional, not just the first: `telstore a b c` used to upload `a` and drop the
|
|
225
|
+
// rest without a word, which is the one thing this project never does.
|
|
226
|
+
return { command: 'upload', args: positionals, options: values, filesAfterNote }
|
|
174
227
|
}
|
package/src/commands/delete.js
CHANGED
|
@@ -120,7 +120,7 @@ export async function runDelete(backupId, options = {}, deps = {}) {
|
|
|
120
120
|
if (!manifestMessage && !record) {
|
|
121
121
|
throw new Error(
|
|
122
122
|
`No backup ${backupId} found in ${chatName(chat)}, and no unfinished record of it on ` +
|
|
123
|
-
'this machine. Check the id with "npx telstore list", or use --
|
|
123
|
+
'this machine. Check the id with "npx telstore list", or use --chat to point at the ' +
|
|
124
124
|
'right chat.',
|
|
125
125
|
)
|
|
126
126
|
}
|
|
@@ -248,3 +248,198 @@ export async function runDelete(backupId, options = {}, deps = {}) {
|
|
|
248
248
|
)
|
|
249
249
|
}
|
|
250
250
|
}
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
// `telstore delete a b c` is three deletes, and the question that guards them is asked once —
|
|
254
|
+
// which means it has to be able to say what all three are. So the batch looks every id up
|
|
255
|
+
// before it asks: the manifest for the finished ones, the local record for the unfinished, and
|
|
256
|
+
// an id that neither knows about refuses the whole run rather than half of it. runDelete then
|
|
257
|
+
// does exactly what it does alone, having already been told the answer.
|
|
258
|
+
export async function runDeletes(backupIds, options = {}, deps = {}) {
|
|
259
|
+
const {
|
|
260
|
+
connect = realConnect,
|
|
261
|
+
disconnect = (client) => client.destroy(),
|
|
262
|
+
configDir = defaultConfigDir(),
|
|
263
|
+
searchManifest = findManifestMessage,
|
|
264
|
+
readMessageBytes = realReadMessageBytes,
|
|
265
|
+
confirm = askConfirm,
|
|
266
|
+
interactive = () => Boolean(process.stdin.isTTY),
|
|
267
|
+
writeErr = (line) => process.stderr.write(line),
|
|
268
|
+
log: writeLog = (line) => console.log(line),
|
|
269
|
+
silent = false,
|
|
270
|
+
} = deps
|
|
271
|
+
|
|
272
|
+
// One id keeps its own wording, its own question and its own thrown error.
|
|
273
|
+
if (backupIds.length === 1) {
|
|
274
|
+
const result = await runDelete(backupIds[0], options, deps)
|
|
275
|
+
return { results: [result], failed: 0 }
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const duplicate = backupIds.find((id, index) => backupIds.indexOf(id) !== index)
|
|
279
|
+
|
|
280
|
+
if (duplicate) {
|
|
281
|
+
throw new Error(
|
|
282
|
+
`${duplicate} is named twice. Deleting one backup twice does nothing the first pass ` +
|
|
283
|
+
'did not already do — name it once.',
|
|
284
|
+
)
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const config = await loadConfig(configDir)
|
|
288
|
+
|
|
289
|
+
assertLoggedIn(config)
|
|
290
|
+
|
|
291
|
+
const { values: settings } = resolveSettings(options, config, { file: configFile(configDir) })
|
|
292
|
+
const chat = requireChat(settings)
|
|
293
|
+
|
|
294
|
+
const log = silent ? () => {} : writeLog
|
|
295
|
+
const warn = silent ? () => {} : writeErr
|
|
296
|
+
|
|
297
|
+
let shared = null
|
|
298
|
+
const perId = {
|
|
299
|
+
...deps,
|
|
300
|
+
connect: async (theirConfig, connectOptions) =>
|
|
301
|
+
(shared ??= await connect(theirConfig, connectOptions)),
|
|
302
|
+
disconnect: async () => {},
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const results = []
|
|
306
|
+
|
|
307
|
+
try {
|
|
308
|
+
const client = await perId.connect(config, { verbose: settings.verbose })
|
|
309
|
+
const rows = []
|
|
310
|
+
const unknown = []
|
|
311
|
+
|
|
312
|
+
for (const backupId of backupIds) {
|
|
313
|
+
const message = await searchManifest(client, chat, backupId)
|
|
314
|
+
const records = await findStates(backupId, configDir)
|
|
315
|
+
|
|
316
|
+
if (!message && records.length === 0) {
|
|
317
|
+
unknown.push(backupId)
|
|
318
|
+
continue
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// A manifest too damaged to read is exactly the backup somebody is here to remove, so
|
|
322
|
+
// it costs the row its name and size, not the run. runDelete refuses the ones that
|
|
323
|
+
// cannot name their message ids, which is the check that actually protects anything.
|
|
324
|
+
let manifest = null
|
|
325
|
+
|
|
326
|
+
if (message) {
|
|
327
|
+
try {
|
|
328
|
+
manifest = parseManifestJson(await readMessageBytes(client, message))
|
|
329
|
+
} catch {
|
|
330
|
+
manifest = null
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
rows.push({ id: backupId, manifest, record: records[0] ?? null })
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
if (unknown.length > 0) {
|
|
338
|
+
throw new Error(
|
|
339
|
+
`Nothing was deleted: ${unknown.join(', ')} — not found in ${chatName(chat)}, and no ` +
|
|
340
|
+
'local record of it on this machine either. Check the ids with "npx telstore list".',
|
|
341
|
+
)
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if (!options.yes) {
|
|
345
|
+
if (!interactive()) {
|
|
346
|
+
throw new Error(
|
|
347
|
+
`${backupIds.length} backups to delete, and no terminal to confirm that in. Run ` +
|
|
348
|
+
'again with --yes to delete them without being asked.',
|
|
349
|
+
)
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
for (const line of listingLines(rows, chat)) log(line)
|
|
353
|
+
|
|
354
|
+
if (
|
|
355
|
+
!(await confirm(
|
|
356
|
+
`The chunks cannot be recovered. Delete all ${backupIds.length}? [y/N] `,
|
|
357
|
+
))
|
|
358
|
+
) {
|
|
359
|
+
throw new Error('Cancelled on request.')
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
for (const [index, backupId] of backupIds.entries()) {
|
|
364
|
+
if (index > 0) log('')
|
|
365
|
+
log(`[${index + 1}/${backupIds.length}] ${backupId}`)
|
|
366
|
+
|
|
367
|
+
try {
|
|
368
|
+
// The question was asked about the whole list a moment ago; asking again per backup
|
|
369
|
+
// would be asking the same thing three times.
|
|
370
|
+
results.push(await runDelete(backupId, { ...options, yes: true }, perId))
|
|
371
|
+
} catch (err) {
|
|
372
|
+
results.push({ id: backupId, error: err.message })
|
|
373
|
+
warn(`\n${backupId} failed: ${err.message}\n`)
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
} finally {
|
|
377
|
+
if (shared) {
|
|
378
|
+
await closeQuietly(shared, disconnect, (err) =>
|
|
379
|
+
warn(`\nWarning: could not close the Telegram connection: ${err.message}\n`),
|
|
380
|
+
)
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const failed = results.filter((result) => result.error).length
|
|
385
|
+
|
|
386
|
+
log('')
|
|
387
|
+
for (const line of summaryLines(results, failed)) log(line)
|
|
388
|
+
|
|
389
|
+
return { results, failed }
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// What is about to be destroyed, spelled out before the one question that authorises it. An
|
|
393
|
+
// unfinished backup has no manifest to describe it, so its own record speaks for it.
|
|
394
|
+
function listingLines(rows, chat) {
|
|
395
|
+
const described = rows.map(({ id, manifest, record }) => ({
|
|
396
|
+
id,
|
|
397
|
+
name: manifest
|
|
398
|
+
? describeName(manifest.name)
|
|
399
|
+
: `${describeName(record?.state?.path)} (unfinished)`,
|
|
400
|
+
size: manifest ? describeSize(manifest.size) : UNKNOWN,
|
|
401
|
+
chunks: plural(countChunks(manifest, record), 'chunk'),
|
|
402
|
+
}))
|
|
403
|
+
|
|
404
|
+
const width = (key) => Math.max(...described.map((row) => row[key].length))
|
|
405
|
+
const [idWidth, nameWidth, sizeWidth] = [width('id'), width('name'), width('size')]
|
|
406
|
+
|
|
407
|
+
return [
|
|
408
|
+
`Deleting ${rows.length} backups from ${describeChat(chat)}`,
|
|
409
|
+
'',
|
|
410
|
+
...described.map(
|
|
411
|
+
(row) =>
|
|
412
|
+
` ${row.id.padEnd(idWidth)} ${row.name.padEnd(nameWidth)} ` +
|
|
413
|
+
`${row.size.padStart(sizeWidth)} ${row.chunks}`,
|
|
414
|
+
),
|
|
415
|
+
'',
|
|
416
|
+
]
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function countChunks(manifest, record) {
|
|
420
|
+
if (Array.isArray(manifest?.chunks)) return manifest.chunks.length
|
|
421
|
+
|
|
422
|
+
const done = record?.state?.done
|
|
423
|
+
|
|
424
|
+
return typeof done === 'object' && done !== null ? Object.keys(done).length : 0
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// Every id gets a line whether it worked or not: one missing from this list would be a backup
|
|
428
|
+
// nobody could tell the fate of.
|
|
429
|
+
function summaryLines(results, failed) {
|
|
430
|
+
const width = Math.max(...results.map((result) => result.id.length))
|
|
431
|
+
const deleted = results.length - failed
|
|
432
|
+
|
|
433
|
+
return [
|
|
434
|
+
`${results.length} backups: ${deleted} deleted, ${failed} failed.`,
|
|
435
|
+
'',
|
|
436
|
+
...results.map((result) => {
|
|
437
|
+
const id = result.id.padEnd(width)
|
|
438
|
+
|
|
439
|
+
return result.error
|
|
440
|
+
? ` ${id} failed: ${result.error}`
|
|
441
|
+
: ` ${id} ${plural(result.chunks, 'chunk message')} removed` +
|
|
442
|
+
(result.manifestDeleted ? ' with its manifest' : '')
|
|
443
|
+
}),
|
|
444
|
+
]
|
|
445
|
+
}
|