telstore 0.1.6 → 0.1.8
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 +77 -8
- package/bin/telstore.js +34 -7
- package/package.json +1 -1
- package/src/caption.js +40 -2
- package/src/cli.js +105 -22
- package/src/commands/delete.js +230 -2
- package/src/commands/list.js +29 -5
- package/src/commands/restore.js +297 -50
- package/src/commands/status.js +80 -13
- package/src/commands/upload.js +255 -13
- package/src/downloader.js +4 -1
- package/src/manifest.js +21 -1
- package/src/progress.js +1 -0
- package/src/settings.js +6 -6
- package/src/sources.js +163 -0
- package/src/state.js +161 -45
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,11 +25,11 @@ 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 status` | Account, destination, and unfinished
|
|
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. |
|
|
32
|
+
| `telstore status` | Account, destination, and unfinished uploads and restores. |
|
|
31
33
|
| `telstore config` | Show or change settings. |
|
|
32
34
|
| `telstore token` | Print a session token for a machine you do not trust. |
|
|
33
35
|
| `telstore logout` | Remove the locally stored session. |
|
|
@@ -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,7 +139,18 @@ 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
|
|
|
84
|
-
|
|
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
|
+
|
|
146
|
+
## Resuming a restore
|
|
147
|
+
|
|
148
|
+
`Ctrl-C` mid-restore keeps the `<target>.partial` file rather than throwing it away. Running
|
|
149
|
+
the same command again hashes each chunk-sized region of it against the manifest, in order,
|
|
150
|
+
and carries on from the first one that does not match — nothing already on disk is trusted
|
|
151
|
+
just because it is there. `npx telstore status` lists unfinished restores alongside
|
|
152
|
+
unfinished uploads, with a resume command for each.
|
|
153
|
+
|
|
85
154
|
And `delete` has **no undo**: Telegram is the only copy.
|
|
86
155
|
|
|
87
156
|
## Running on a machine you do not trust
|
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 item's record as it goes, so by the time Ctrl-C lands these
|
|
18
|
+
// are transfers no second run should touch. Ctrl-C needs their names to say so.
|
|
19
|
+
const finished = []
|
|
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: finished }),
|
|
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) finished.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,18 @@ 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')
|
|
123
|
+
|
|
124
|
+
const { failed } = await runRestores(parsed.args, parsed.options, {
|
|
125
|
+
onBackupId: (id) => {
|
|
126
|
+
currentBackupId = id
|
|
127
|
+
},
|
|
128
|
+
onRestoreDone: (item) => {
|
|
129
|
+
finished.push(item)
|
|
130
|
+
},
|
|
131
|
+
})
|
|
107
132
|
|
|
108
|
-
|
|
133
|
+
if (failed > 0) process.exitCode = 1
|
|
109
134
|
return
|
|
110
135
|
}
|
|
111
136
|
|
|
@@ -114,9 +139,11 @@ async function main() {
|
|
|
114
139
|
throw new Error('Missing backup id. Example: npx telstore delete telstore-20260905-7f3a91')
|
|
115
140
|
}
|
|
116
141
|
|
|
117
|
-
const {
|
|
142
|
+
const { runDeletes } = await import('../src/commands/delete.js')
|
|
143
|
+
|
|
144
|
+
const { failed } = await runDeletes(parsed.args, parsed.options)
|
|
118
145
|
|
|
119
|
-
|
|
146
|
+
if (failed > 0) process.exitCode = 1
|
|
120
147
|
return
|
|
121
148
|
}
|
|
122
149
|
|
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,11 +32,11 @@ 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 status Show the account, the destination and unfinished
|
|
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
|
|
39
|
+
npx telstore status Show the account, the destination and unfinished uploads and restores
|
|
38
40
|
npx telstore config Show every setting and where its value comes from
|
|
39
41
|
npx telstore logout Remove the saved session
|
|
40
42
|
|
|
@@ -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,18 +93,65 @@ 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'
|
|
86
120
|
)
|
|
87
121
|
}
|
|
88
122
|
|
|
123
|
+
// A restore keeps its .partial now, and the next run proves each chunk in it against the
|
|
124
|
+
// manifest before trusting a byte — so "running again starts over", which this said while
|
|
125
|
+
// there was nothing to resume from, would now be false.
|
|
89
126
|
if (command === 'restore') {
|
|
90
|
-
|
|
127
|
+
// Finished ids have been renamed to their real names and their records removed, so
|
|
128
|
+
// repeating the whole command line would meet an overwrite prompt and then download
|
|
129
|
+
// them again from nothing. Name them and ask for the rest, exactly as a batch upload does.
|
|
130
|
+
if (done.length > 0) {
|
|
131
|
+
const width = Math.max(...done.map((item) => basename(item.path).length))
|
|
132
|
+
const finished = done
|
|
133
|
+
.map((item) => ` ${basename(item.path).padEnd(width)} ${item.id}`)
|
|
134
|
+
.join('\n')
|
|
135
|
+
|
|
136
|
+
return (
|
|
137
|
+
`\nStopped. These are finished and need no second run:\n${finished}\n` +
|
|
138
|
+
'Run telstore again with only the ids that are left — their .partial files are kept, ' +
|
|
139
|
+
'so those carry on where they stopped. "npx telstore status" shows what is unfinished.\n'
|
|
140
|
+
)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// With onBackupId firing only once the .partial is open, an id here means there is a
|
|
144
|
+
// file to carry on from. Without one, this run stopped before it wrote anything, and
|
|
145
|
+
// saying a .partial was kept would be the same lie this message was rewritten to stop
|
|
146
|
+
// telling — just from the other side.
|
|
147
|
+
if (!backupId) {
|
|
148
|
+
return '\nStopped before anything was written. Run the same command again to start.\n'
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return (
|
|
152
|
+
`\nBackup ${backupId} kept its .partial file — run the same command again from this ` +
|
|
153
|
+
'directory to carry on, or "npx telstore status" to see what is left.\n'
|
|
154
|
+
)
|
|
91
155
|
}
|
|
92
156
|
|
|
93
157
|
// A delete has already destroyed messages for good by the time Ctrl-C lands, and the
|
|
@@ -107,8 +171,8 @@ export function interruptMessage(command, { backupId } = {}) {
|
|
|
107
171
|
// parseArgs rejects anything starting with a dash as an option, and reports it as one:
|
|
108
172
|
// `config chat -100123` fails with "Unknown option '-1'", naming a flag nobody typed.
|
|
109
173
|
//
|
|
110
|
-
// Two shapes need rescuing, and they are rescued differently. As a flag value, `--
|
|
111
|
-
// is joined into `--
|
|
174
|
+
// Two shapes need rescuing, and they are rescued differently. As a flag value, `--chat -100123`
|
|
175
|
+
// is joined into `--chat=-100123`; only a bare negative integer qualifies, so `--chat --verbose`
|
|
112
176
|
// still reports the missing value instead of eating the next flag. As a positional —
|
|
113
177
|
// `config chat -100123` — there is nothing to join it to, so `--` goes in front and the rest
|
|
114
178
|
// of the line is handed over verbatim. That is greedy on purpose: a flag written after the
|
|
@@ -125,8 +189,8 @@ function protectNegativeChatIds(argv) {
|
|
|
125
189
|
return safe
|
|
126
190
|
}
|
|
127
191
|
|
|
128
|
-
if (argv[i] === '--
|
|
129
|
-
safe.push(`--
|
|
192
|
+
if (argv[i] === '--chat' && /^-\d+$/.test(argv[i + 1] ?? '')) {
|
|
193
|
+
safe.push(`--chat=${argv[i + 1]}`)
|
|
130
194
|
i += 1
|
|
131
195
|
continue
|
|
132
196
|
}
|
|
@@ -142,33 +206,52 @@ function protectNegativeChatIds(argv) {
|
|
|
142
206
|
return safe
|
|
143
207
|
}
|
|
144
208
|
|
|
209
|
+
// An unquoted note is gone by the time node starts: the shell hands `--note ghi chu` over as
|
|
210
|
+
// three arguments and nothing can put the quotes back. The one trace it leaves is its own
|
|
211
|
+
// tail — every word after the first sits here as a positional, after the flag — and upload
|
|
212
|
+
// needs that to tell the mistake from a plain missing file. The shape of the command line is
|
|
213
|
+
// already this file's business, so the observation is made here rather than guessed at later.
|
|
214
|
+
//
|
|
215
|
+
// The last --note is the one parseArgs kept, so it is the one whose position counts.
|
|
216
|
+
function filesNamedAfterNote(tokens) {
|
|
217
|
+
const note = tokens.findLast((token) => token.kind === 'option' && token.name === 'note')
|
|
218
|
+
|
|
219
|
+
if (!note) return false
|
|
220
|
+
|
|
221
|
+
return tokens.some((token) => token.kind === 'positional' && token.index > note.index)
|
|
222
|
+
}
|
|
223
|
+
|
|
145
224
|
export function route(argv) {
|
|
146
|
-
const { values, positionals } = parseArgs({
|
|
225
|
+
const { values, positionals, tokens } = parseArgs({
|
|
147
226
|
args: protectNegativeChatIds(argv),
|
|
148
227
|
options: OPTIONS,
|
|
149
228
|
allowPositionals: true,
|
|
229
|
+
tokens: true,
|
|
150
230
|
})
|
|
151
231
|
|
|
152
232
|
const [first, ...rest] = positionals
|
|
233
|
+
const filesAfterNote = filesNamedAfterNote(tokens)
|
|
153
234
|
|
|
154
|
-
// `telstore --
|
|
235
|
+
// `telstore --chat @chan` with no file used to mean "remember this destination". Flags no
|
|
155
236
|
// longer write anything, so that line now asks for a run that has nothing to upload —
|
|
156
237
|
// say where the destination actually lives instead of printing help at someone who was
|
|
157
238
|
// perfectly clear about what they wanted.
|
|
158
|
-
if (first === undefined && values.
|
|
239
|
+
if (first === undefined && values.chat && !values.help) {
|
|
159
240
|
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 --
|
|
241
|
+
`Nothing to upload. To change the destination for good, run "npx telstore config chat ${values.chat}". ` +
|
|
242
|
+
'To use it for one run, pass --chat alongside a file or a command.',
|
|
162
243
|
)
|
|
163
244
|
}
|
|
164
245
|
|
|
165
246
|
if (values.help || first === undefined || first === 'help') {
|
|
166
|
-
return { command: 'help', args: [], options: values }
|
|
247
|
+
return { command: 'help', args: [], options: values, filesAfterNote }
|
|
167
248
|
}
|
|
168
249
|
|
|
169
250
|
if (SUBCOMMANDS.has(first)) {
|
|
170
|
-
return { command: first, args: rest, options: values }
|
|
251
|
+
return { command: first, args: rest, options: values, filesAfterNote }
|
|
171
252
|
}
|
|
172
253
|
|
|
173
|
-
|
|
254
|
+
// Every positional, not just the first: `telstore a b c` used to upload `a` and drop the
|
|
255
|
+
// rest without a word, which is the one thing this project never does.
|
|
256
|
+
return { command: 'upload', args: positionals, options: values, filesAfterNote }
|
|
174
257
|
}
|