data-ark 0.1.1 → 0.1.3

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
@@ -7,6 +7,9 @@ Split large files into 1.8GB chunks, store them on Telegram, and restore them in
7
7
  ```bash
8
8
  npx data-ark login # once only
9
9
  npx data-ark data.tar --to @my_backups # the destination is remembered
10
+ npx data-ark --to @my_backups # only change the destination, upload nothing
11
+ npx data-ark status # account, destination, unfinished backups
12
+ npx data-ark list # what is already stored in the destination
10
13
  npx data-ark data.tar # from the second run on
11
14
  npx data-ark restore ark-20260905-7f3a91
12
15
  ```
@@ -22,10 +25,46 @@ data-ark signs in with your own Telegram account (MTProto), not a bot. That is a
22
25
 
23
26
  | Flag | Default | Meaning |
24
27
  |---|---|---|
25
- | `--to <chat>` | the remembered destination | `@username`, `-100123…`, or `me` |
26
- | `--chunk-size <n>` | `1800MB` | e.g. `1.8GB`, `500MB`. Hard ceiling 1950MB. |
28
+ | `--to <chat>` | the remembered destination | `@username`, `-100123…`, or `me`. `upload` and `status` remember it; `list` and `restore` only look there. A negative channel id works either way: `--to -100123…` or `--to=-100123…` |
29
+ | `--chunk-size <n>` | `1800MB` | e.g. `1.8GB`, `500MB`. Hard ceiling 1950MB. An unfinished backup keeps the size it started with. |
27
30
  | `--concurrency <n>` | `8` | 512KB parts sent in parallel. An integer from 1 to 64. |
28
31
  | `--out <path>` | the basename from the manifest | Where to write the restored file; relative paths resolve against the current directory |
32
+ | `--limit <n>` | `20` | How many backups `list` shows, newest first |
33
+ | `--verbose` | off | Show the Telegram client's own connection logs, hidden by default so they do not break up the progress bar |
34
+
35
+ ## What the chat looks like
36
+
37
+ Every chunk goes up as a document captioned `📦 <backupId> · 3/12`, and the manifest that
38
+ follows carries a summary card:
39
+
40
+ ```
41
+ 🗄 data.tar
42
+ ━━━━━━━━━━━━━━━
43
+ 💾 21.4 GB · 12 chunks
44
+ 🆔 ark-20260905-7f3a91
45
+ 📅 2026-09-05 16:40 UTC
46
+
47
+ ↩ npx data-ark restore ark-20260905-7f3a91
48
+ #dataark
49
+ ```
50
+
51
+ `npx data-ark list` reads those cards straight out of the chat — one search, no downloads —
52
+ and lays them out as a table:
53
+
54
+ ```
55
+ Destination https://web.telegram.org/k/#@my_backups
56
+
57
+ BACKUP ID FILE SIZE CHUNKS CREATED
58
+ ark-20260905-7f3a91 data.tar 21.4 GB 12 2026-09-05
59
+ ark-20260901-9de447 photos.zip 940.3 MB 1 2026-09-01
60
+
61
+ 2 backups. Restore with: npx data-ark restore <backup-id>
62
+ ```
63
+
64
+ A backup uploaded before the card existed still gets a row, with dashes where the caption
65
+ says nothing — `list` reports what the chat holds and never fills gaps with guesses.
66
+ `--to` here only chooses which chat to look at; it does not move the destination the way
67
+ `status --to` does.
29
68
 
30
69
  ## How it works
31
70
 
@@ -34,7 +73,10 @@ Every run mints a `backupId`. The file is read directly by offset — no tempora
34
73
  If the connection drops during an **upload**, just run the same command again — progress lives in `~/.data-ark/state/` and finished chunks are skipped, keeping the same `backupId`. Two things to know about rerunning:
35
74
 
36
75
  - Running again with a `--to` that differs from the destination stored in the unfinished progress makes data-ark **refuse to run** rather than silently redirect — one backup cannot be split across two destinations. The error points the way out: drop `--to` to keep sending to the original destination, or delete the state file to start a new backup.
37
- - Running again with a different `--chunk-size` is treated as an entirely new backup (new backup id), not a resume.
76
+ - Running again **without** `--chunk-size` resumes at the size the backup started with, whatever the default is today.
77
+ - Running again **with** a `--chunk-size` that differs from that size makes data-ark **refuse to run**, for the same reason as `--to`: the chunks already in the chat were cut that way and cannot be re-cut. Drop the flag to carry on, or delete the state file to start a new backup — which leaves the chunks already sent in the chat with nothing pointing at them.
78
+
79
+ `Ctrl-C` during an upload names the backup it was working on, so `data-ark status` and a later `restore` have something to go on. data-ark keeps the **20 most recent** unfinished backups in `~/.data-ark/state/`; starting a new one past that drops the oldest record and says which id it dropped. Only the local record goes — the chunks that backup sent stay in the chat, searchable by that id, but it can no longer be resumed.
38
80
 
39
81
  **Restore keeps no state to resume from.** Pressing `Ctrl-C` mid-restore saves nothing — running again starts over.
40
82
 
package/bin/data-ark.js CHANGED
@@ -1,30 +1,23 @@
1
1
  #!/usr/bin/env node
2
- import { route, HELP } from '../src/cli.js'
2
+ import { route, HELP, interruptMessage } from '../src/cli.js'
3
3
  import { runLogin } from '../src/commands/login.js'
4
+ import { runList } from '../src/commands/list.js'
4
5
  import { runLogout } from '../src/commands/logout.js'
5
6
  import { runRestore } from '../src/commands/restore.js'
7
+ import { runSetDestination } from '../src/commands/set-destination.js'
8
+ import { runStatus } from '../src/commands/status.js'
6
9
  import { runUpload } from '../src/commands/upload.js'
7
10
 
8
11
  const SIGINT_EXIT_CODE = 130
9
12
 
10
13
  // Which command is running when Ctrl-C arrives — each one tells a different truth
11
14
  // about whether progress was saved, so we need to know which to pick the right line.
15
+ // The backup id arrives a moment later, once upload knows which backup this run is.
12
16
  let currentCommand = null
13
-
14
- function sigintMessage(command) {
15
- if (command === 'upload') {
16
- return '\nStopped. Progress is saved, run the same command again to continue.\n'
17
- }
18
-
19
- if (command === 'restore') {
20
- return '\nStopped. Download progress is not saved, running again starts over.\n'
21
- }
22
-
23
- return '\nStopped.\n'
24
- }
17
+ let currentBackupId = null
25
18
 
26
19
  process.on('SIGINT', () => {
27
- process.stderr.write(sigintMessage(currentCommand))
20
+ process.stderr.write(interruptMessage(currentCommand, { backupId: currentBackupId }))
28
21
  process.exit(SIGINT_EXIT_CODE)
29
22
  })
30
23
 
@@ -47,15 +40,31 @@ async function main() {
47
40
  return
48
41
 
49
42
  case 'login':
50
- await runLogin()
43
+ await runLogin({ verbose: parsed.options.verbose })
51
44
  return
52
45
 
53
46
  case 'logout':
54
47
  await runLogout()
55
48
  return
56
49
 
50
+ case 'list':
51
+ await runList(parsed.options)
52
+ return
53
+
54
+ case 'status':
55
+ await runStatus(parsed.options)
56
+ return
57
+
58
+ case 'set-destination':
59
+ await runSetDestination(parsed.options)
60
+ return
61
+
57
62
  case 'upload':
58
- await runUpload(parsed.args[0], parsed.options)
63
+ await runUpload(parsed.args[0], parsed.options, {
64
+ onBackupId: (id) => {
65
+ currentBackupId = id
66
+ },
67
+ })
59
68
  return
60
69
 
61
70
  case 'restore':
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "data-ark",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Split large files into chunks and store them on Telegram",
5
5
  "keywords": [
6
6
  "telegram",
package/src/caption.js ADDED
@@ -0,0 +1,63 @@
1
+ import { formatBytes } from './progress.js'
2
+
3
+ // Captions are plain text on purpose. Telegram would render bold through a parse mode,
4
+ // but that turns every file name into something that has to be escaped correctly, and
5
+ // the fake client the tests talk to would never notice a mistake there.
6
+
7
+ const DIVIDER = '━'.repeat(15)
8
+
9
+ // The hashtag is what `list` searches for, and it lives on the manifest alone: chunk
10
+ // captions stay out of that search so a twelve-chunk backup is one hit, not thirteen.
11
+ export const MANIFEST_TAG = '#dataark'
12
+
13
+ // A file name may legally contain a newline or a tab, and either one would push the
14
+ // rest of the card down a row and take its shape apart.
15
+ function oneLine(name) {
16
+ return String(name).replace(/\s+/g, ' ').trim()
17
+ }
18
+
19
+ function utcMinutes(createdAt) {
20
+ return `${new Date(createdAt).toISOString().slice(0, 16).replace('T', ' ')} UTC`
21
+ }
22
+
23
+ export function chunkCaption({ id, number, total }) {
24
+ return `📦 ${id} · ${number}/${total}`
25
+ }
26
+
27
+ export function manifestCaption({ id, name, size, chunks, createdAt }) {
28
+ return [
29
+ `🗄 ${oneLine(name)}`,
30
+ DIVIDER,
31
+ `💾 ${formatBytes(size)} · ${chunks} chunk${chunks === 1 ? '' : 's'}`,
32
+ `🆔 ${id}`,
33
+ `📅 ${utcMinutes(createdAt)}`,
34
+ '',
35
+ `↩ npx data-ark restore ${id}`,
36
+ MANIFEST_TAG,
37
+ ].join('\n')
38
+ }
39
+
40
+ function marker(lines, emoji) {
41
+ const found = lines.find((line) => line.startsWith(`${emoji} `))
42
+ return found ? found.slice(emoji.length + 1).trim() : null
43
+ }
44
+
45
+ // list reads what the chat shows, and a caption is text a person can edit. Anything that
46
+ // does not carry the whole card is reported as unknown rather than half-guessed: a backup
47
+ // listed with invented numbers is worse than one listed with dashes.
48
+ export function parseManifestCaption(text) {
49
+ const lines = String(text ?? '').split('\n')
50
+
51
+ const name = marker(lines, '🗄')
52
+ const totals = marker(lines, '💾')
53
+ const id = marker(lines, '🆔')
54
+ const createdAt = marker(lines, '📅')
55
+
56
+ if (!name || !totals || !id || !createdAt) return null
57
+
58
+ const match = /^(.+) · (\d+) chunks?$/.exec(totals)
59
+
60
+ if (!match) return null
61
+
62
+ return { id, name, size: match[1], chunks: Number(match[2]), createdAt }
63
+ }
package/src/chunking.js CHANGED
@@ -36,6 +36,12 @@ export function parseSize(input) {
36
36
  return bytes
37
37
  }
38
38
 
39
+ // How many chunks a file of this size splits into. planChunks builds them and parseManifest
40
+ // checks them against this same rule, so the layout has one definition, not three.
41
+ export function countChunks(fileSize, chunkSize) {
42
+ return Math.ceil(fileSize / chunkSize)
43
+ }
44
+
39
45
  export function planChunks(fileSize, chunkSize) {
40
46
  if (fileSize <= 0) {
41
47
  throw new Error('File is empty, nothing to upload.')
package/src/cli.js CHANGED
@@ -1,12 +1,14 @@
1
1
  import { parseArgs } from 'node:util'
2
2
 
3
- const SUBCOMMANDS = new Set(['login', 'logout', 'restore', 'help'])
3
+ const SUBCOMMANDS = new Set(['login', 'logout', 'list', 'restore', 'status', 'help'])
4
4
 
5
5
  const OPTIONS = {
6
6
  to: { type: 'string' },
7
7
  'chunk-size': { type: 'string' },
8
8
  concurrency: { type: 'string' },
9
9
  out: { type: 'string' },
10
+ limit: { type: 'string' },
11
+ verbose: { type: 'boolean' },
10
12
  help: { type: 'boolean', short: 'h' },
11
13
  }
12
14
 
@@ -15,26 +17,86 @@ export const HELP = `data-ark — split large files into chunks and store them o
15
17
  Usage:
16
18
  npx data-ark login Log in to Telegram, only needed once
17
19
  npx data-ark <file> Split a file and upload it to Telegram
20
+ npx data-ark list List the backups stored in the destination
18
21
  npx data-ark restore <backup-id> Download the chunks and reassemble the file
22
+ npx data-ark status Show the account, the destination and unfinished backups
23
+ npx data-ark --to <chat> Set the destination without uploading anything
19
24
  npx data-ark logout Remove the saved session
20
25
 
21
26
  Options:
22
- --to <chat> Destination: @username, -100123..., or me. Remembered for next time.
27
+ --to <chat> Destination: @username, -100123..., or me. upload and status
28
+ remember it; list and restore only look there.
23
29
  --chunk-size <n> Size of each chunk, default 1800MB. Examples: 1.8GB, 500MB.
30
+ An unfinished backup keeps the size it started with.
24
31
  --concurrency <n> 512KB parts sent in parallel, default 8, max 64.
25
32
  --out <path> Where to write the restored file. Defaults to the basename in the manifest.
33
+ --limit <n> How many backups list shows, default 20.
34
+ --verbose Show Telegram connection logs, hidden by default.
26
35
  -h, --help Show this help.
27
36
  `
28
37
 
38
+ // What Ctrl-C means depends on the command that was running: upload has written every
39
+ // finished chunk to a state file, restore has not. Naming the backup matters because the
40
+ // id is what `status` lists and what a later `restore` needs — the chunks are already in
41
+ // the chat under that id, whether or not this run ever finishes.
42
+ export function interruptMessage(command, { backupId } = {}) {
43
+ if (command === 'upload') {
44
+ const backup = backupId ? `Backup ${backupId} is saved` : 'Progress is saved'
45
+
46
+ return (
47
+ `\n${backup} — run the same command again to continue, ` +
48
+ 'or "npx data-ark status" to see what is left.\n'
49
+ )
50
+ }
51
+
52
+ if (command === 'restore') {
53
+ return '\nStopped. Download progress is not saved, running again starts over.\n'
54
+ }
55
+
56
+ return '\nStopped.\n'
57
+ }
58
+
59
+ // A channel id is negative, and typing it separated by a space is the natural reflex —
60
+ // but parseArgs rejects any value starting with a dash as ambiguous. Join the pair itself
61
+ // so `--to -100123` works like `--to=-100123`. Only a bare negative integer qualifies, so
62
+ // `--to --verbose` still reports the missing value instead of eating the next flag, and
63
+ // everything after `--` is left alone because it is no longer an option there.
64
+ function joinNegativeChatId(argv) {
65
+ const joined = []
66
+
67
+ for (let i = 0; i < argv.length; i += 1) {
68
+ if (argv[i] === '--') {
69
+ joined.push(...argv.slice(i))
70
+ return joined
71
+ }
72
+
73
+ if (argv[i] === '--to' && /^-\d+$/.test(argv[i + 1] ?? '')) {
74
+ joined.push(`--to=${argv[i + 1]}`)
75
+ i += 1
76
+ continue
77
+ }
78
+
79
+ joined.push(argv[i])
80
+ }
81
+
82
+ return joined
83
+ }
84
+
29
85
  export function route(argv) {
30
86
  const { values, positionals } = parseArgs({
31
- args: argv,
87
+ args: joinNegativeChatId(argv),
32
88
  options: OPTIONS,
33
89
  allowPositionals: true,
34
90
  })
35
91
 
36
92
  const [first, ...rest] = positionals
37
93
 
94
+ // `data-ark --to @chan` with no file is not a malformed upload, it is someone changing
95
+ // where the next upload goes. Help would be an unhelpful answer to a clear request.
96
+ if (first === undefined && values.to && !values.help) {
97
+ return { command: 'set-destination', args: [], options: values }
98
+ }
99
+
38
100
  if (values.help || first === undefined || first === 'help') {
39
101
  return { command: 'help', args: [], options: values }
40
102
  }
package/src/client.js CHANGED
@@ -1,6 +1,16 @@
1
- import { TelegramClient } from 'telegram'
1
+ import { Api, TelegramClient } from 'telegram'
2
+ import { Logger } from 'telegram/extensions/index.js'
3
+ import { LogLevel } from 'telegram/extensions/Logger.js'
2
4
  import { StringSession } from 'telegram/sessions/index.js'
3
5
 
6
+ // GramJS narrates its version, every connection and every disconnect at info level, and
7
+ // those timestamped lines land in the middle of the progress bar. The client reads this
8
+ // logger before it prints anything, so LogLevel.NONE silences all of it; --verbose asks
9
+ // for the running commentary back when a connection needs diagnosing.
10
+ export function createLogger(verbose) {
11
+ return new Logger(verbose ? LogLevel.INFO : LogLevel.NONE)
12
+ }
13
+
4
14
  export function normalizeChatTarget(input) {
5
15
  const text = String(input).trim()
6
16
 
@@ -15,6 +25,71 @@ export function normalizeChatTarget(input) {
15
25
  return text
16
26
  }
17
27
 
28
+ // Telegram's web client addresses a chat by putting the raw target in the fragment, which
29
+ // covers both a negative channel id and an @username. Saved Messages is the exception: it
30
+ // is reached by the account's own id, which data-ark does not know, so it gets no link
31
+ // rather than a guessed one that lands somewhere else.
32
+ export function chatUrl(chat) {
33
+ const text = String(chat)
34
+
35
+ if (text === 'me') return null
36
+
37
+ return `https://web.telegram.org/k/#${text}`
38
+ }
39
+
40
+ // How a destination is spoken about. "me" is a target, not a name someone would recognise
41
+ // in a sentence, so every command that mentions a chat in prose goes through here.
42
+ export function chatName(chat) {
43
+ return String(chat) === 'me' ? 'Saved Messages' : String(chat)
44
+ }
45
+
46
+ // A destination is worth more as something clickable than as a raw id, but Saved Messages
47
+ // has no link to give, so it is named instead of being dressed up as one.
48
+ export function describeChat(chat) {
49
+ const url = chatUrl(chat)
50
+
51
+ return url ?? `${chat} (${chatName(chat)})`
52
+ }
53
+
54
+ // The name Telegram shows under a document lives in an attribute, not on the message.
55
+ export function documentFileName(message) {
56
+ const attributes = message?.media?.document?.attributes ?? []
57
+ const named = attributes.find((a) => a instanceof Api.DocumentAttributeFilename)
58
+ return named?.fileName ?? null
59
+ }
60
+
61
+ // The one place data-ark searches a chat. Both callers want documents and nothing else,
62
+ // and getMessages is preferred over a raw Api.messages.Search because it handles offsets,
63
+ // hashes and pagination itself, so we don't hand-build easily mistyped fields. The raw
64
+ // message is kept alongside the flat fields because downloading needs it whole.
65
+ export async function searchDocuments(client, peer, { search, limit }) {
66
+ const messages = await client.getMessages(peer, {
67
+ search,
68
+ filter: new Api.InputMessagesFilterDocument(),
69
+ limit,
70
+ })
71
+
72
+ return messages.map((message) => ({
73
+ id: message.id,
74
+ fileName: documentFileName(message),
75
+ caption: message.message ?? '',
76
+ date: message.date,
77
+ message,
78
+ }))
79
+ }
80
+
81
+ // Every command ends by putting the connection down, and a failure there must never
82
+ // swallow the real error already on its way up. Commands that print progress hand in an
83
+ // onWarn to say so; the quieter ones let it pass, because a connection that will not
84
+ // close cleanly says nothing about the work that already succeeded.
85
+ export async function closeQuietly(client, disconnect, onWarn) {
86
+ try {
87
+ await disconnect(client)
88
+ } catch (err) {
89
+ if (onWarn) onWarn(err)
90
+ }
91
+ }
92
+
18
93
  export function requireChat(options, config) {
19
94
  const raw = options.to ?? config.defaultChat
20
95
 
@@ -34,12 +109,13 @@ export function assertLoggedIn(config) {
34
109
  }
35
110
  }
36
111
 
37
- export async function connect(config) {
112
+ export async function connect(config, { verbose = false } = {}) {
38
113
  assertLoggedIn(config)
39
114
 
40
115
  const client = new TelegramClient(new StringSession(config.session), config.apiId, config.apiHash, {
41
116
  connectionRetries: 5,
42
117
  floodSleepThreshold: 60,
118
+ baseLogger: createLogger(verbose),
43
119
  })
44
120
 
45
121
  await client.connect()
@@ -0,0 +1,136 @@
1
+ import { MANIFEST_TAG, parseManifestCaption } from '../caption.js'
2
+ import {
3
+ assertLoggedIn,
4
+ chatName,
5
+ closeQuietly,
6
+ connect as realConnect,
7
+ describeChat,
8
+ requireChat,
9
+ searchDocuments,
10
+ } from '../client.js'
11
+ import { defaultConfigDir, loadConfig } from '../config.js'
12
+
13
+ export const DEFAULT_LIMIT = 20
14
+
15
+ const UNKNOWN = '—'
16
+ const GAP = ' '
17
+
18
+ const COLUMNS = [
19
+ { header: 'BACKUP ID', key: 'id' },
20
+ { header: 'FILE', key: 'name' },
21
+ { header: 'SIZE', key: 'size', right: true },
22
+ { header: 'CHUNKS', key: 'chunks', right: true },
23
+ { header: 'CREATED', key: 'created' },
24
+ ]
25
+
26
+ // Telegram indexes the tag the manifest caption carries, so one search returns one hit
27
+ // per backup instead of one per chunk. What comes back is still whatever the server
28
+ // decided to match, which is why the caller filters on the file name afterwards.
29
+ async function realSearchManifests(client, peer, limit) {
30
+ return await searchDocuments(client, peer, { search: MANIFEST_TAG, limit })
31
+ }
32
+
33
+ function backupIdFromFileName(fileName) {
34
+ return fileName.replace(/\.manifest\.json$/, '')
35
+ }
36
+
37
+ function utcDay(unixSeconds) {
38
+ return new Date(unixSeconds * 1000).toISOString().slice(0, 10)
39
+ }
40
+
41
+ // The card is text in a chat, which means a person can edit or predate it. The id is the
42
+ // one field restore cannot be wrong about, so it always comes from the file name data-ark
43
+ // wrote; the caption only decorates. A card that cannot be read back leaves the rest
44
+ // unknown, because inventing it would describe a backup that does not exist.
45
+ function toRow(message) {
46
+ const id = backupIdFromFileName(message.fileName)
47
+ const card = parseManifestCaption(message.caption)
48
+
49
+ if (!card) {
50
+ return { id, name: UNKNOWN, size: UNKNOWN, chunks: UNKNOWN, created: utcDay(message.date) }
51
+ }
52
+
53
+ return {
54
+ id,
55
+ name: card.name,
56
+ size: card.size,
57
+ chunks: String(card.chunks),
58
+ created: card.createdAt.slice(0, 10),
59
+ }
60
+ }
61
+
62
+ function renderTable(rows) {
63
+ const widths = COLUMNS.map((column) =>
64
+ Math.max(column.header.length, ...rows.map((row) => row[column.key].length)),
65
+ )
66
+
67
+ const line = (cells) =>
68
+ cells
69
+ .map((cell, i) => (COLUMNS[i].right ? cell.padStart(widths[i]) : cell.padEnd(widths[i])))
70
+ .join(GAP)
71
+ .trimEnd()
72
+
73
+ return [
74
+ line(COLUMNS.map((column) => column.header)),
75
+ ...rows.map((row) => line(COLUMNS.map((column) => row[column.key]))),
76
+ ]
77
+ }
78
+
79
+ function parseLimit(raw) {
80
+ if (raw === undefined) return DEFAULT_LIMIT
81
+
82
+ const limit = Number(raw)
83
+
84
+ if (!Number.isInteger(limit) || limit < 1) {
85
+ throw new Error(`Invalid --limit: "${raw}". Must be a whole number of backups, 1 or more.`)
86
+ }
87
+
88
+ return limit
89
+ }
90
+
91
+ export async function runList(options = {}, deps = {}) {
92
+ const {
93
+ configDir = defaultConfigDir(),
94
+ connect = realConnect,
95
+ disconnect = (client) => client.destroy(),
96
+ searchManifests = realSearchManifests,
97
+ log = (line) => console.log(line),
98
+ } = deps
99
+
100
+ const limit = parseLimit(options.limit)
101
+ const config = await loadConfig(configDir)
102
+ // Ask about the login before the destination: telling someone who has never logged in
103
+ // to pick a chat sends them off after the wrong thing.
104
+ assertLoggedIn(config)
105
+ // Unlike status, --to here means "look over there", not "send there from now on":
106
+ // listing another chat should not quietly redirect the next upload.
107
+ const chat = requireChat(options, config)
108
+
109
+ const client = await connect(config, { verbose: options.verbose })
110
+
111
+ let found
112
+ try {
113
+ found = await searchManifests(client, chat, limit)
114
+ } finally {
115
+ await closeQuietly(client, disconnect)
116
+ }
117
+
118
+ log(`Destination ${describeChat(chat)}`)
119
+ log('')
120
+
121
+ const rows = found
122
+ .filter((message) => message.fileName?.endsWith('.manifest.json'))
123
+ .map(toRow)
124
+
125
+ if (rows.length === 0) {
126
+ log(`No backups found in ${chatName(chat)}. Upload one with: npx data-ark <file>`)
127
+ return rows
128
+ }
129
+
130
+ for (const line of renderTable(rows)) log(line)
131
+
132
+ log('')
133
+ log(`${rows.length} backup${rows.length === 1 ? '' : 's'}. Restore with: npx data-ark restore <backup-id>`)
134
+
135
+ return rows
136
+ }
@@ -5,7 +5,7 @@ import { TelegramClient } from 'telegram'
5
5
  import { StringSession } from 'telegram/sessions/index.js'
6
6
 
7
7
  import { loadConfig, saveConfig, defaultConfigDir } from '../config.js'
8
- import { normalizeChatTarget } from '../client.js'
8
+ import { createLogger, normalizeChatTarget } from '../client.js'
9
9
 
10
10
  function createPrompts() {
11
11
  const rl = readline.createInterface({ input: stdin, output: stdout })
@@ -33,10 +33,25 @@ export function describeLoginError(err) {
33
33
  return `${description} (${message})`
34
34
  }
35
35
 
36
- export async function runLogin({ configDir = defaultConfigDir(), prompts = createPrompts() } = {}) {
36
+ // GramJS starts an update loop the moment a client connects, and that loop only stops when
37
+ // destroy() marks the client destroyed. disconnect() alone leaves it pinging a socket that is
38
+ // already closed: every ping fails with "Error: TIMEOUT" and asks the sender to reconnect,
39
+ // printed straight over the destination question login asks after signing in. Every other
40
+ // command shuts down the same way, and login is the seam tests need to reach it.
41
+ const createTelegramClient = (apiId, apiHash, options) =>
42
+ new TelegramClient(new StringSession(''), apiId, apiHash, options)
43
+
44
+ export async function runLogin({
45
+ configDir = defaultConfigDir(),
46
+ prompts = createPrompts(),
47
+ verbose = false,
48
+ createClient = createTelegramClient,
49
+ shutdown = (client) => client.destroy(),
50
+ log = (line) => console.log(line),
51
+ } = {}) {
37
52
  const config = await loadConfig(configDir)
38
53
 
39
- console.log('You need your own api_id and api_hash. Get them at https://my.telegram.org → API development tools.\n')
54
+ log('You need your own api_id and api_hash. Get them at https://my.telegram.org → API development tools.\n')
40
55
 
41
56
  const apiIdAnswer = (await prompts.ask(`api_id${config.apiId ? ` [${config.apiId}]` : ''}: `)).trim()
42
57
 
@@ -53,7 +68,10 @@ export async function runLogin({ configDir = defaultConfigDir(), prompts = creat
53
68
  throw new Error('Missing api_id or api_hash.')
54
69
  }
55
70
 
56
- const client = new TelegramClient(new StringSession(''), apiId, apiHash, { connectionRetries: 5 })
71
+ const client = createClient(apiId, apiHash, {
72
+ connectionRetries: 5,
73
+ baseLogger: createLogger(verbose),
74
+ })
57
75
 
58
76
  await client.start({
59
77
  phoneNumber: () => prompts.ask('Phone number (e.g. +1...): '),
@@ -64,7 +82,7 @@ export async function runLogin({ configDir = defaultConfigDir(), prompts = creat
64
82
 
65
83
  const me = await client.getMe()
66
84
  const session = client.session.save()
67
- await client.disconnect()
85
+ await shutdown(client)
68
86
 
69
87
  const chatAnswer = (
70
88
  await prompts.ask(
@@ -82,6 +100,6 @@ export async function runLogin({ configDir = defaultConfigDir(), prompts = creat
82
100
 
83
101
  await saveConfig(next, configDir)
84
102
 
85
- console.log(`\nLogged in as ${me.username ? `@${me.username}` : me.firstName}.`)
86
- console.log(`Config saved to ${configDir}/config.json`)
103
+ log(`\nLogged in as ${me.username ? `@${me.username}` : me.firstName}.`)
104
+ log(`Config saved to ${configDir}/config.json`)
87
105
  }