data-ark 0.1.0 → 0.1.2

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.
@@ -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 })
@@ -16,11 +16,11 @@ function createPrompts() {
16
16
  }
17
17
 
18
18
  const LOGIN_ERROR_MESSAGES = {
19
- PHONE_NUMBER_INVALID: 'số điện thoại không hợp lệ',
20
- PHONE_CODE_INVALID: ' xác nhận sai',
21
- PHONE_CODE_EXPIRED: ' xác nhận đã hết hạn',
22
- PASSWORD_HASH_INVALID: 'mật khẩu hai lớp sai',
23
- FLOOD_WAIT: 'bị Telegram giới hạn tần suất, cần chờ',
19
+ PHONE_NUMBER_INVALID: 'invalid phone number',
20
+ PHONE_CODE_INVALID: 'wrong verification code',
21
+ PHONE_CODE_EXPIRED: 'verification code expired',
22
+ PASSWORD_HASH_INVALID: 'wrong two-step password',
23
+ FLOOD_WAIT: 'rate limited by Telegram, need to wait',
24
24
  }
25
25
 
26
26
  export function describeLoginError(err) {
@@ -33,42 +33,60 @@ 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('Cần api_id api_hash của riêng bạn. Lấy tại 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
 
43
58
  if (apiIdAnswer !== '' && !/^\d+$/.test(apiIdAnswer)) {
44
59
  prompts.close()
45
- throw new Error('api_id phải số nguyên.')
60
+ throw new Error('api_id must be an integer.')
46
61
  }
47
62
 
48
63
  const apiId = apiIdAnswer === '' ? config.apiId : Number(apiIdAnswer)
49
- const apiHash = (await prompts.ask(`api_hash${config.apiHash ? ' [giữ nguyên]' : ''}: `)) || config.apiHash
64
+ const apiHash = (await prompts.ask(`api_hash${config.apiHash ? ' [keep current]' : ''}: `)) || config.apiHash
50
65
 
51
66
  if (!apiId || !apiHash) {
52
67
  prompts.close()
53
- throw new Error('Thiếu api_id hoặc api_hash.')
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
- phoneNumber: () => prompts.ask('Số điện thoại (dạng +84...): '),
60
- phoneCode: () => prompts.ask(' xác nhận Telegram vừa gửi: '),
61
- password: () => prompts.ask('Mật khẩu hai lớp (bỏ trống nếu không bật): '),
62
- onError: (err) => console.error(`Lỗi đăng nhập: ${describeLoginError(err)}`),
77
+ phoneNumber: () => prompts.ask('Phone number (e.g. +1...): '),
78
+ phoneCode: () => prompts.ask('Verification code Telegram just sent: '),
79
+ password: () => prompts.ask('Two-step password (leave blank if not enabled): '),
80
+ onError: (err) => console.error(`Login failed: ${describeLoginError(err)}`),
63
81
  })
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(
71
- `Đẩy backup vào chat nào? (@username, -100..., hoặc me)${config.defaultChat ? ` [${config.defaultChat}]` : ''}, Enter để bỏ qua: `,
89
+ `Which chat should backups go to? (@username, -100..., or me)${config.defaultChat ? ` [${config.defaultChat}]` : ''}, Enter to skip: `,
72
90
  )
73
91
  ).trim()
74
92
 
@@ -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(`\nĐã đăng nhập với tài khoản ${me.username ? `@${me.username}` : me.firstName}.`)
86
- console.log(`Cấu hình đã lưu vào ${configDir}/config.json`)
103
+ log(`\nLogged in as ${me.username ? `@${me.username}` : me.firstName}.`)
104
+ log(`Config saved to ${configDir}/config.json`)
87
105
  }
@@ -3,11 +3,11 @@ import { clearSession, defaultConfigDir } from '../config.js'
3
3
  export async function runLogout({ configDir = defaultConfigDir() } = {}) {
4
4
  await clearSession(configDir)
5
5
  console.log(
6
- 'Đã xoá phiên đăng nhập lưu trên máy này. api_id, api_hash đích lưu vẫn được giữ lại.',
6
+ 'Removed the session stored on this machine. api_id, api_hash and the destination are kept.',
7
7
  )
8
8
  console.log(
9
- 'Lưu ý: lệnh này chỉ xoá bản lưu dưới máy, phiên vẫn còn sống phía Telegram. ' +
10
- 'Muốn cắt hẳn quyền truy cập, mở Telegram → Settings → Devices (Active sessions) ' +
11
- ' thu hồi phiên đó.',
9
+ 'Note: this only deletes the local copy the session is still alive on Telegram\'s side. ' +
10
+ 'To revoke access for good, open Telegram → Settings → Devices (Active sessions) ' +
11
+ 'and terminate that session.',
12
12
  )
13
13
  }
@@ -3,32 +3,17 @@ import path from 'node:path'
3
3
  import readline from 'node:readline/promises'
4
4
  import { stdin, stdout } from 'node:process'
5
5
 
6
- import { Api } from 'telegram'
7
-
8
- import { connect as realConnect, requireChat } from '../client.js'
6
+ import { closeQuietly, connect as realConnect, requireChat, searchDocuments } from '../client.js'
9
7
  import { defaultConfigDir, loadConfig } from '../config.js'
10
8
  import { downloadToFile } from '../downloader.js'
11
9
  import { manifestFileName, parseManifest } from '../manifest.js'
12
10
  import { createProgress, formatBytes } from '../progress.js'
13
11
 
14
- function documentFileName(message) {
15
- const attributes = message?.media?.document?.attributes ?? []
16
- const named = attributes.find((a) => a instanceof Api.DocumentAttributeFilename)
17
- return named?.fileName ?? null
18
- }
19
-
20
12
  async function realSearchManifest(client, peer, backupId) {
21
13
  const wanted = manifestFileName(backupId)
14
+ const found = await searchDocuments(client, peer, { search: backupId, limit: 100 })
22
15
 
23
- // Dùng client.getMessages thay vì raw Api.messages.Search: tự lo offset,
24
- // hash và phân trang, nên không phải tự dựng các trường dễ sai kiểu.
25
- const messages = await client.getMessages(peer, {
26
- search: backupId,
27
- filter: new Api.InputMessagesFilterDocument(),
28
- limit: 100,
29
- })
30
-
31
- return messages.find((m) => documentFileName(m) === wanted) ?? null
16
+ return found.find((doc) => doc.fileName === wanted)?.message ?? null
32
17
  }
33
18
 
34
19
  async function realReadMessageBytes(client, message) {
@@ -44,17 +29,17 @@ export async function realDownloadChunk(client, message, handle, offset, onProgr
44
29
  return await downloadToFile(client, message, handle.fd, { offset, onProgress })
45
30
  }
46
31
 
47
- // manifest.name đến từ dữ liệu tải về Telegram — không tin khi tự chọn đường
48
- // dẫn. path.basename chặn được "../../x" nhưng vẫn trả về "..", "." hay "" cho
49
- // vài tên bệnh: path.resolve('..') thư mục cha, nên file .partial hàng GB sẽ
50
- // nằm ngoài thư mục hiện tại chỉ vỡ ra bước rename.
32
+ // manifest.name comes from data downloaded off Telegram — don't trust it when picking
33
+ // a path ourselves. path.basename stops "../../x" but still returns "..", "." or "" for
34
+ // a few pathological names: path.resolve('..') is the parent directory, so a multi-GB
35
+ // .partial file would land outside the current directory and only blow up at rename.
51
36
  function safeOutName(name) {
52
37
  const base = path.basename(String(name ?? ''))
53
38
 
54
39
  if (base === '' || base === '.' || base === '..') {
55
40
  throw new Error(
56
- `Tên file trong manifest ("${name}") không dùng được làm tên file. ` +
57
- 'Chạy lại kèm --out <đường-dẫn> để tự chỉ định nơi ghi.',
41
+ `The name in the manifest ("${name}") cannot be used as a file name. ` +
42
+ 'Run again with --out <path> to choose where to write.',
58
43
  )
59
44
  }
60
45
 
@@ -65,7 +50,7 @@ async function askConfirm(question) {
65
50
  const rl = readline.createInterface({ input: stdin, output: stdout })
66
51
  const answer = await rl.question(question)
67
52
  rl.close()
68
- return /^(c|y)/i.test(answer.trim())
53
+ return /^y/i.test(answer.trim())
69
54
  }
70
55
 
71
56
  export async function runRestore(backupId, options = {}, deps = {}) {
@@ -87,25 +72,25 @@ export async function runRestore(backupId, options = {}, deps = {}) {
87
72
  const log = silent ? () => {} : (line) => console.log(line)
88
73
  const warn = silent ? () => {} : writeErr
89
74
 
90
- const client = await connect(config)
75
+ const client = await connect(config, { verbose: options.verbose })
91
76
 
92
77
  try {
93
78
  const manifestMessage = await searchManifest(client, chat, backupId)
94
79
 
95
80
  if (!manifestMessage) {
96
81
  throw new Error(
97
- `Không tìm thấy manifest của ${backupId} trong ${chat}. ` +
98
- 'Kiểm tra lại backup id, hoặc dùng --to để trỏ đúng chat.',
82
+ `No manifest found for ${backupId} in ${chat}. ` +
83
+ 'Check the backup id, or use --to to point at the right chat.',
99
84
  )
100
85
  }
101
86
 
102
87
  const manifest = parseManifest(await readMessageBytes(client, manifestMessage))
103
- // Khi người dùng tự chỉ định --out thì tôn trọng nguyên văn đường dẫn đó.
88
+ // When the user passes --out, respect that path verbatim.
104
89
  const target = path.resolve(options.out ?? safeOutName(manifest.name))
105
90
  const partial = `${target}.partial`
106
91
 
107
- // Chỉ ENOENT mới có nghĩa là "chưa file". Lỗi quyền hay lỗi I/O bị coi
108
- // vắng mặt thì data-ark sẽ ghi đè file của người dùng mà không hỏi.
92
+ // Only ENOENT means "no file yet". Treating a permission or I/O error as absence
93
+ // would have data-ark overwrite the user's file without asking.
109
94
  let exists = true
110
95
  try {
111
96
  await fs.stat(target)
@@ -114,12 +99,12 @@ export async function runRestore(backupId, options = {}, deps = {}) {
114
99
  exists = false
115
100
  }
116
101
 
117
- if (exists && !(await confirm(`${target} đã tồn tại. Ghi đè? [c/K] `))) {
118
- throw new Error('Đã huỷ theo yêu cầu.')
102
+ if (exists && !(await confirm(`${target} already exists. Overwrite? [y/N] `))) {
103
+ throw new Error('Cancelled on request.')
119
104
  }
120
105
 
121
106
  log(`Backup ${manifest.id}`)
122
- log(`File ${target} (${formatBytes(manifest.size)}, ${manifest.chunks.length} chunk)\n`)
107
+ log(`File ${target} (${formatBytes(manifest.size)}, ${manifest.chunks.length} chunks)\n`)
123
108
 
124
109
  const handle = await fs.open(partial, 'w+')
125
110
 
@@ -131,18 +116,17 @@ export async function runRestore(backupId, options = {}, deps = {}) {
131
116
 
132
117
  if (!message) {
133
118
  throw new Error(
134
- `Thiếu chunk ${chunk.i + 1}/${manifest.chunks.length}: message ${chunk.msgId} không còn trong ${chat}. ` +
135
- 'Backup này không khôi phục được.',
119
+ `Missing chunk ${chunk.i + 1}/${manifest.chunks.length}: message ${chunk.msgId} is no longer in ${chat}. ` +
120
+ 'This backup cannot be restored.',
136
121
  )
137
122
  }
138
123
 
139
- const progress = silent
140
- ? { advance: () => {}, finish: () => {} }
141
- : createProgress({
142
- total: chunk.size,
143
- label: `Chunk ${chunk.i + 1}/${manifest.chunks.length}`,
144
- write: writeErr,
145
- })
124
+ // warn is already the no-op when silent, and createProgress draws through nothing else.
125
+ const progress = createProgress({
126
+ total: chunk.size,
127
+ label: `Chunk ${chunk.i + 1}/${manifest.chunks.length}`,
128
+ write: warn,
129
+ })
146
130
 
147
131
  const { sha256, size } = await downloadChunk(
148
132
  client,
@@ -156,13 +140,13 @@ export async function runRestore(backupId, options = {}, deps = {}) {
156
140
 
157
141
  if (size !== chunk.size) {
158
142
  throw new Error(
159
- `Chunk ${chunk.i + 1} ${size} byte, manifest ghi ${chunk.size} bytekhông khớp.`,
143
+ `Chunk ${chunk.i + 1} has ${size} bytes, the manifest records ${chunk.size} bytesmismatch.`,
160
144
  )
161
145
  }
162
146
 
163
147
  if (sha256 !== chunk.sha256) {
164
148
  throw new Error(
165
- `Chunk ${chunk.i + 1} sha256 không khớp manifest. File tải về giữ ${partial} để kiểm tra.`,
149
+ `Chunk ${chunk.i + 1} has a sha256 that does not match the manifest. The download is kept at ${partial} for inspection.`,
166
150
  )
167
151
  }
168
152
  }
@@ -170,28 +154,26 @@ export async function runRestore(backupId, options = {}, deps = {}) {
170
154
  await handle.close()
171
155
  }
172
156
 
173
- // Chốt chặn cuối: mọi chunk khớp sha256 file vẫn sai độ dài thì bố cục đã
174
- // lệch đâu đó. Thà báo lỗi còn hơn đổi tên một file sai thành file thật.
157
+ // Last line of defence: if every chunk matched its sha256 and the file is still the
158
+ // wrong length, the layout went wrong somewhere. Better to fail than to rename a
159
+ // wrong file into the real one.
175
160
  const written = await fs.stat(partial)
176
161
 
177
162
  if (written.size !== manifest.size) {
178
163
  throw new Error(
179
- `File ghép xong ${written.size} byte, manifest ghi ${manifest.size} bytekhông khớp. ` +
180
- `File tải về giữ ${partial} để kiểm tra.`,
164
+ `The assembled file has ${written.size} bytes, the manifest records ${manifest.size} bytesmismatch. ` +
165
+ `The download is kept at ${partial} for inspection.`,
181
166
  )
182
167
  }
183
168
 
184
169
  await fs.rename(partial, target)
185
170
 
186
- log(`\nXong. Đã ghi ${formatBytes(manifest.size)} vào ${target}`)
171
+ log(`\nDone. Wrote ${formatBytes(manifest.size)} to ${target}`)
187
172
 
188
173
  return { path: target, size: manifest.size }
189
174
  } finally {
190
- // Ngắt kết nối hỏng thì cũng không được nuốt mất lỗi thật đang bay lên.
191
- try {
192
- await disconnect(client)
193
- } catch (err) {
194
- warn(`\nCảnh báo: không đóng được kết nối Telegram: ${err.message}\n`)
195
- }
175
+ await closeQuietly(client, disconnect, (err) =>
176
+ warn(`\nWarning: could not close the Telegram connection: ${err.message}\n`),
177
+ )
196
178
  }
197
179
  }
@@ -0,0 +1,15 @@
1
+ import { normalizeChatTarget } from '../client.js'
2
+ import { defaultConfigDir, loadConfig, saveConfig } from '../config.js'
3
+
4
+ export async function runSetDestination(options = {}, deps = {}) {
5
+ const { configDir = defaultConfigDir(), log = (line) => console.log(line) } = deps
6
+
7
+ // Normalize before touching the config: an unusable destination must not be written,
8
+ // or the next upload fails with an error about a value the user never really set.
9
+ const chat = normalizeChatTarget(options.to)
10
+ const config = await loadConfig(configDir)
11
+
12
+ await saveConfig({ ...config, defaultChat: String(chat) }, configDir)
13
+
14
+ log(`Destination set to ${chat}. It will be used for the next upload.`)
15
+ }
@@ -0,0 +1,93 @@
1
+ import path from 'node:path'
2
+
3
+ import { countChunks } from '../chunking.js'
4
+ import { assertLoggedIn, closeQuietly, connect as realConnect, describeChat } from '../client.js'
5
+ import { defaultConfigDir, loadConfig } from '../config.js'
6
+ import { runSetDestination } from './set-destination.js'
7
+ import { formatBytes } from '../progress.js'
8
+ import { listStates } from '../state.js'
9
+
10
+ const LABEL_WIDTH = 'Destination'.length + 2
11
+
12
+ function row(label, value) {
13
+ return `${label.padEnd(LABEL_WIDTH)}${value}`
14
+ }
15
+
16
+ function describeAccount(me) {
17
+ const name = [me.firstName, me.lastName].filter(Boolean).join(' ')
18
+ const handle = me.username ? ` (@${me.username})` : ''
19
+
20
+ return `${name || me.username || me.phone || 'unknown'}${handle}`
21
+ }
22
+
23
+ // The account line is the only part that needs Telegram, and it is also the only part that
24
+ // can fail. Whatever it costs, it must not take the rest of the report down with it: the
25
+ // unfinished backups are exactly what someone runs status to see after a session expires.
26
+ async function accountLine(config, options, deps) {
27
+ const { connect, disconnect } = deps
28
+
29
+ try {
30
+ assertLoggedIn(config)
31
+ } catch (err) {
32
+ return err.message
33
+ }
34
+
35
+ let client
36
+ try {
37
+ client = await connect(config, { verbose: options.verbose })
38
+ } catch (err) {
39
+ return err.message
40
+ }
41
+
42
+ try {
43
+ return describeAccount(await client.getMe())
44
+ } catch (err) {
45
+ return `could not be read: ${err.message}`
46
+ } finally {
47
+ await closeQuietly(client, disconnect)
48
+ }
49
+ }
50
+
51
+ export async function runStatus(options = {}, deps = {}) {
52
+ const {
53
+ configDir = defaultConfigDir(),
54
+ connect = realConnect,
55
+ disconnect = (client) => client.destroy(),
56
+ log = (line) => console.log(line),
57
+ } = deps
58
+
59
+ // `status --to @chan` reads as one request: point somewhere new, then show me where I am.
60
+ // Setting it first is also what makes the Destination line below tell the current truth.
61
+ if (options.to) {
62
+ await runSetDestination(options, { configDir, log: () => {} })
63
+ }
64
+
65
+ const config = await loadConfig(configDir)
66
+
67
+ log(row('Account', await accountLine(config, options, { connect, disconnect })))
68
+ log(
69
+ row(
70
+ 'Destination',
71
+ config.defaultChat
72
+ ? describeChat(config.defaultChat)
73
+ : 'none set — pass --to @my_backups to set one',
74
+ ),
75
+ )
76
+
77
+ const states = await listStates(configDir)
78
+
79
+ if (states.length === 0) {
80
+ log(row('Unfinished', 'none'))
81
+ return
82
+ }
83
+
84
+ log(row('Unfinished', `${states.length} backup${states.length === 1 ? '' : 's'}`))
85
+
86
+ for (const state of states) {
87
+ const total = countChunks(state.size, state.chunkSize)
88
+ const done = Object.keys(state.done ?? {}).length
89
+
90
+ log(` ${state.id} ${path.basename(state.path)} ${done}/${total} chunks ${formatBytes(state.size)}`)
91
+ log(` → ${describeChat(state.chat)}`)
92
+ }
93
+ }