telstore 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.
@@ -12,7 +12,7 @@ const GAP = ' '
12
12
  function unknownKey(name) {
13
13
  if (isManagedByLogin(name)) {
14
14
  return new Error(
15
- `"${name}" is managed by "npx telark login", not by config. ` +
15
+ `"${name}" is managed by "npx telstore login", not by config. ` +
16
16
  `Settings you can change: ${SETTING_KEYS.join(', ')}.`,
17
17
  )
18
18
  }
@@ -37,7 +37,7 @@ function listLines(values, source, stored) {
37
37
  return [key, `${spec.format(value)}${note}`, source(key) === 'settings' ? '' : '(default)']
38
38
  })
39
39
 
40
- // Keys telark does not know are left in the file untouched, but a value that silently
40
+ // Keys telstore does not know are left in the file untouched, but a value that silently
41
41
  // does nothing is worse than one that is refused: show them, so a typo is visible from
42
42
  // the command rather than only from opening the file that also holds the session.
43
43
  const strays = Object.keys(stored).filter((key) => !SETTING_KEYS.includes(key))
@@ -50,8 +50,8 @@ function listLines(values, source, stored) {
50
50
  if (strays.length > 0) {
51
51
  lines.push('')
52
52
  lines.push(
53
- `Ignored, telark does not know these: ${strays.join(', ')}. ` +
54
- 'Remove one with: npx telark config <name> --unset',
53
+ `Ignored, telstore does not know these: ${strays.join(', ')}. ` +
54
+ 'Remove one with: npx telstore config <name> --unset',
55
55
  )
56
56
  }
57
57
 
@@ -65,21 +65,21 @@ export async function runConfig(args = [], options = {}, deps = {}) {
65
65
  if (extra.length > 0) {
66
66
  throw new Error(
67
67
  `Too many arguments: a setting takes one value, but got ${args.length}. ` +
68
- `Did you mean: npx telark config ${name} "${[value, ...extra].join(' ')}"`,
68
+ `Did you mean: npx telstore config ${name} "${[value, ...extra].join(' ')}"`,
69
69
  )
70
70
  }
71
71
 
72
72
  // Both of these would otherwise be obeyed halfway and reported as a success: a listing
73
73
  // that quietly dropped the --unset, or an unset that quietly dropped the value beside it.
74
74
  if (options.unset && name === undefined) {
75
- throw new Error('--unset needs the setting to drop. Try: npx telark config chat --unset')
75
+ throw new Error('--unset needs the setting to drop. Try: npx telstore config chat --unset')
76
76
  }
77
77
 
78
78
  if (options.unset && value !== undefined) {
79
79
  throw new Error(
80
80
  `--unset takes no value, but "${value}" was given. ` +
81
- `Use "npx telark config ${name} --unset" to drop it, ` +
82
- `or "npx telark config ${name} ${value}" to set it.`,
81
+ `Use "npx telstore config ${name} --unset" to drop it, ` +
82
+ `or "npx telstore config ${name} ${value}" to set it.`,
83
83
  )
84
84
  }
85
85
 
@@ -56,7 +56,7 @@ function stateMessageIds(record) {
56
56
  throw new Error(
57
57
  `The record of unfinished backup ${record.state.id} gives ` +
58
58
  `${JSON.stringify(msgId)} as the message id of chunk ${Number(index) + 1}, which ` +
59
- `is not a message id. ${record.file} is damaged, so telark is not deleting ` +
59
+ `is not a message id. ${record.file} is damaged, so telstore is not deleting ` +
60
60
  'anything.',
61
61
  )
62
62
  }
@@ -106,7 +106,7 @@ export async function runDelete(backupId, options = {}, deps = {}) {
106
106
  if (records.length > 1) {
107
107
  throw new Error(
108
108
  `Two local records both claim to be backup ${backupId}: ` +
109
- `${records.map((r) => r.file).join(' and ')}. telark will not guess which one to ` +
109
+ `${records.map((r) => r.file).join(' and ')}. telstore will not guess which one to ` +
110
110
  'drop — remove the wrong one by hand and run again.',
111
111
  )
112
112
  }
@@ -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 telark list", or use --to to point at the ' +
123
+ 'this machine. Check the id with "npx telstore list", or use --to to point at the ' +
124
124
  'right chat.',
125
125
  )
126
126
  }
@@ -131,7 +131,7 @@ export async function runDelete(backupId, options = {}, deps = {}) {
131
131
  if (manifestMessage) {
132
132
  manifest = parseManifestJson(await readMessageBytes(client, manifestMessage))
133
133
 
134
- // The manifest was found by the file name telark itself wrote, and that name is the
134
+ // The manifest was found by the file name telstore itself wrote, and that name is the
135
135
  // id this command was asked about. A body naming a different backup is a file that was
136
136
  // renamed or replaced, and its message ids point at somebody else's chunks — the one
137
137
  // mistake in this whole command that nothing can undo.
@@ -139,7 +139,7 @@ export async function runDelete(backupId, options = {}, deps = {}) {
139
139
  throw new Error(
140
140
  `The manifest named ${manifestFileName(backupId)} describes backup ` +
141
141
  `${JSON.stringify(manifest.id)}, not ${backupId}. Its message ids point at ` +
142
- 'another backup\'s chunks, so telark is not deleting anything.',
142
+ 'another backup\'s chunks, so telstore is not deleting anything.',
143
143
  )
144
144
  }
145
145
 
@@ -36,7 +36,7 @@ function utcDay(unixSeconds) {
36
36
  }
37
37
 
38
38
  // The card is text in a chat, which means a person can edit or predate it. The id is the
39
- // one field restore cannot be wrong about, so it always comes from the file name telark
39
+ // one field restore cannot be wrong about, so it always comes from the file name telstore
40
40
  // wrote; the caption only decorates. A card that cannot be read back leaves the rest
41
41
  // unknown, because inventing it would describe a backup that does not exist.
42
42
  function toRow(message) {
@@ -106,14 +106,14 @@ export async function runList(options = {}, deps = {}) {
106
106
  .map(toRow)
107
107
 
108
108
  if (rows.length === 0) {
109
- log(`No backups found in ${chatName(chat)}. Upload one with: npx telark <file>`)
109
+ log(`No backups found in ${chatName(chat)}. Upload one with: npx telstore <file>`)
110
110
  return rows
111
111
  }
112
112
 
113
113
  for (const line of renderTable(rows)) log(line)
114
114
 
115
115
  log('')
116
- log(`${rows.length} backup${rows.length === 1 ? '' : 's'}. Restore with: npx telark restore <backup-id>`)
116
+ log(`${rows.length} backup${rows.length === 1 ? '' : 's'}. Restore with: npx telstore restore <backup-id>`)
117
117
 
118
118
  return rows
119
119
  }
@@ -1,21 +1,12 @@
1
- import readline from 'node:readline/promises'
2
- import { stdin, stdout } from 'node:process'
3
-
4
1
  import { TelegramClient } from 'telegram'
5
2
  import { StringSession } from 'telegram/sessions/index.js'
6
3
 
7
4
  import { loadConfig, saveConfig, defaultConfigDir } from '../config.js'
8
5
  import { normalizeChatTarget } from '../chat.js'
9
- import { createLogger } from '../client.js'
10
- import { resolveSettings } from '../settings.js'
11
-
12
- function createPrompts() {
13
- const rl = readline.createInterface({ input: stdin, output: stdout })
14
- return {
15
- ask: (question) => rl.question(question),
16
- close: () => rl.close(),
17
- }
18
- }
6
+ import { closeQuietly, connect as realConnect, createLogger } from '../client.js'
7
+ import { createPrompts } from '../prompt.js'
8
+ import { knownSettings, resolveSettings } from '../settings.js'
9
+ import { decodeToken, isSealedToken } from '../token.js'
19
10
 
20
11
  const LOGIN_ERROR_MESSAGES = {
21
12
  PHONE_NUMBER_INVALID: 'invalid phone number',
@@ -43,14 +34,96 @@ export function describeLoginError(err) {
43
34
  const createTelegramClient = (apiId, apiHash, options) =>
44
35
  new TelegramClient(new StringSession(''), apiId, apiHash, options)
45
36
 
37
+ // The token never reaches here as an argument, and telstore refuses to pretend otherwise: a
38
+ // token on the command line sits in the shell history of a machine the user does not trust,
39
+ // and ignoring it silently would leave it there for nothing.
40
+ function refuseArguments(args) {
41
+ if (args.length === 0) return
42
+
43
+ throw new Error(
44
+ 'login takes no arguments. A session token is pasted at a prompt, never written on the ' +
45
+ 'command line — there it stays in this machine\'s shell history and is visible in "ps" ' +
46
+ 'for as long as the command runs. Run "npx telstore login --token" and paste it when asked.',
47
+ )
48
+ }
49
+
50
+ // Logging in with a token telstore printed elsewhere. The blob is stored exactly as it
51
+ // arrived rather than opened and written back out: what makes this worth doing is that the
52
+ // session never exists on this machine's disk in a form anyone can read.
53
+ async function loginWithToken({ configDir, prompts, connectWith, shutdown, verbose, log }) {
54
+ const token = (await prompts.askSecret('Session token: ')).trim()
55
+ const passphrase = isSealedToken(token) ? await prompts.askSecret('Passphrase for the token: ') : ''
56
+
57
+ // Opened here, before anything is written, so a wrong passphrase is a sentence now rather
58
+ // than a failure at the start of a restore that was going to take twenty minutes.
59
+ const bundle = await decodeToken(token, passphrase)
60
+ const account = { apiId: bundle.apiId, apiHash: bundle.apiHash, session: bundle.session }
61
+
62
+ // A token says what the session was when it was made. Only Telegram can say whether that
63
+ // session is still alive, and finding out now is the difference between a login that failed
64
+ // and a login that appeared to work.
65
+ const client = await connectWith(account, { verbose })
66
+
67
+ let me
68
+ try {
69
+ me = await client.getMe()
70
+ } finally {
71
+ await closeQuietly(client, shutdown)
72
+ }
73
+
74
+ const config = await loadConfig(configDir)
75
+ // Built fresh rather than merged over what was there: a config holding both a sealed
76
+ // session and a plain one is refused on the next run, and merging is how it would come to
77
+ // hold both. Settings are the exception, because they are nobody's secret.
78
+ const next = passphrase === '' ? { ...account } : { sealed: token }
79
+ const settings = { ...config.settings, ...knownSettings(bundle.settings) }
80
+
81
+ if (Object.keys(settings).length > 0) next.settings = settings
82
+
83
+ await saveConfig(next, configDir)
84
+
85
+ log(`\nLogged in as ${me.username ? `@${me.username}` : me.firstName}.`)
86
+
87
+ if (passphrase === '') {
88
+ log(
89
+ `Config saved to ${configDir}/config.json. This token had no passphrase, so the session ` +
90
+ 'is stored here in plain text, exactly as an ordinary login would store it.',
91
+ )
92
+ return
93
+ }
94
+
95
+ log(
96
+ `Config saved to ${configDir}/config.json, with the session sealed behind your ` +
97
+ 'passphrase. Every command that talks to Telegram will ask for it.',
98
+ )
99
+ }
100
+
46
101
  export async function runLogin({
47
102
  configDir = defaultConfigDir(),
48
- prompts = createPrompts(),
103
+ args = [],
104
+ token = false,
105
+ prompts = null,
106
+ connectWith = realConnect,
49
107
  verbose = false,
50
108
  createClient = createTelegramClient,
51
109
  shutdown = (client) => client.destroy(),
52
110
  log = (line) => console.log(line),
53
111
  } = {}) {
112
+ refuseArguments(args)
113
+
114
+ // Opened here and not in the parameter list: a default argument runs on every call, so the
115
+ // refusal above would open a readline on stdin it never reads from and the process would
116
+ // hang with nothing left to do.
117
+ prompts = prompts ?? createPrompts()
118
+
119
+ if (token) {
120
+ try {
121
+ return await loginWithToken({ configDir, prompts, connectWith, shutdown, verbose, log })
122
+ } finally {
123
+ prompts.close()
124
+ }
125
+ }
126
+
54
127
  const config = await loadConfig(configDir)
55
128
  const storedChat = config.settings?.chat
56
129
  const loud = verbose || resolveSettings({}, config).values.verbose
@@ -80,7 +153,7 @@ export async function runLogin({
80
153
  await client.start({
81
154
  phoneNumber: () => prompts.ask('Phone number (e.g. +1...): '),
82
155
  phoneCode: () => prompts.ask('Verification code Telegram just sent: '),
83
- password: () => prompts.ask('Two-step password (leave blank if not enabled): '),
156
+ password: () => prompts.askSecret('Two-step password (leave blank if not enabled): '),
84
157
  onError: (err) => console.error(`Login failed: ${describeLoginError(err)}`),
85
158
  })
86
159
 
@@ -1,11 +1,21 @@
1
- import { clearSession, defaultConfigDir } from '../config.js'
1
+ import { clearSession, defaultConfigDir, loadConfig } from '../config.js'
2
+
3
+ // The same true fact in both cases — the session outlives this machine — but not the same
4
+ // sentence about what is left behind. An ordinary login keeps the api_id and api_hash beside
5
+ // the session, and a sealed one keeps them inside it, so there the api_hash goes too. Saying
6
+ // otherwise would describe a machine other than the one in front of the reader.
7
+ export async function runLogout({ configDir = defaultConfigDir(), log = (line) => console.log(line) } = {}) {
8
+ const { sealed } = await loadConfig(configDir)
2
9
 
3
- export async function runLogout({ configDir = defaultConfigDir() } = {}) {
4
10
  await clearSession(configDir)
5
- console.log(
6
- 'Removed the session stored on this machine. api_id, api_hash and the destination are kept.',
11
+
12
+ log(
13
+ sealed
14
+ ? 'Removed the sealed session stored on this machine. The api_id and api_hash were ' +
15
+ 'inside it, so they are gone with it; the destination is kept.'
16
+ : 'Removed the session stored on this machine. api_id, api_hash and the destination are kept.',
7
17
  )
8
- console.log(
18
+ log(
9
19
  'Note: this only deletes the local copy — the session is still alive on Telegram\'s side. ' +
10
20
  'To revoke access for good, open Telegram → Settings → Devices (Active sessions) ' +
11
21
  'and terminate that session.',
@@ -28,8 +28,8 @@ async function realGetMessage(client, peer, msgId) {
28
28
  return message ?? null
29
29
  }
30
30
 
31
- export async function realDownloadChunk(client, message, handle, offset, onProgress, retryOptions) {
32
- return await downloadToFile(client, message, handle.fd, { offset, onProgress, retryOptions })
31
+ export async function realDownloadChunk(client, message, handle, offset, onProgress, options) {
32
+ return await downloadToFile(client, message, handle.fd, { offset, onProgress, ...options })
33
33
  }
34
34
 
35
35
  // manifest.name comes from data downloaded off Telegram — don't trust it when picking
@@ -78,7 +78,7 @@ export async function runRestore(backupId, options = {}, deps = {}) {
78
78
  if (delayMs > LONG_WAIT_MS) {
79
79
  warn(
80
80
  `\nTelegram wants ${formatDuration(delayMs / 1000)} of waiting before the next part ` +
81
- `(${err.message}). telark is waiting and will carry on by itself, leave it running.\n`,
81
+ `(${err.message}). telstore is waiting and will carry on by itself, leave it running.\n`,
82
82
  )
83
83
  return
84
84
  }
@@ -112,7 +112,7 @@ export async function runRestore(backupId, options = {}, deps = {}) {
112
112
  const partial = `${target}.partial`
113
113
 
114
114
  // Only ENOENT means "no file yet". Treating a permission or I/O error as absence
115
- // would have telark overwrite the user's file without asking.
115
+ // would have telstore overwrite the user's file without asking.
116
116
  let exists = true
117
117
  try {
118
118
  await fs.stat(target)
@@ -165,7 +165,10 @@ export async function runRestore(backupId, options = {}, deps = {}) {
165
165
  handle,
166
166
  chunk.i * manifest.chunkSize,
167
167
  progress.advance,
168
- { ...retryOptions, onRetry },
168
+ {
169
+ retryOptions: { ...retryOptions, onRetry },
170
+ concurrency: settings.downloadConcurrency,
171
+ },
169
172
  )
170
173
 
171
174
  if (size !== chunk.size) {
@@ -1,12 +1,10 @@
1
- import path from 'node:path'
2
-
3
1
  import { countChunks } from '../chunking.js'
4
2
  import { describeChat } from '../chat.js'
5
3
  import { assertLoggedIn, closeQuietly, connect as realConnect } from '../client.js'
6
4
  import { configFile, defaultConfigDir, loadConfig } from '../config.js'
7
5
  import { formatBytes } from '../progress.js'
8
6
  import { resolveSettings } from '../settings.js'
9
- import { listStates } from '../state.js'
7
+ import { canResume, listStates } from '../state.js'
10
8
 
11
9
  const LABEL_WIDTH = 'Destination'.length + 2
12
10
 
@@ -14,6 +12,66 @@ function row(label, value) {
14
12
  return `${label.padEnd(LABEL_WIDTH)}${value}`
15
13
  }
16
14
 
15
+ // Each unfinished backup gets its own indented block, so the fields line up under a heading
16
+ // that is the id — the one string `restore` and `delete` both take.
17
+ const FIELD_WIDTH = 'Resume'.length + 3
18
+ const CONTINUATION = ' '.repeat(FIELD_WIDTH + 2)
19
+
20
+ function field(label, value) {
21
+ return ` ${label.padEnd(FIELD_WIDTH)}${value}`
22
+ }
23
+
24
+ // The Resume line is a command meant to be pasted, so anything a shell would take apart has
25
+ // to come back quoted — a path with a space in it is the ordinary case, not an exotic one.
26
+ const BARE_ARG = /^[A-Za-z0-9_@%+:,./-]+$/
27
+
28
+ function shellArg(text) {
29
+ const value = String(text)
30
+
31
+ return BARE_ARG.test(value) ? value : `'${value.replaceAll("'", `'\\''`)}'`
32
+ }
33
+
34
+ // runUpload refuses to send the rest of a backup to a different chat, so the command has to
35
+ // name the one the chunks are already in — unless the destination in force is that chat
36
+ // anyway, where --to would just be noise. Not knowing the destination counts as not matching:
37
+ // leaving --to out would be a guess about where a backup already in progress went.
38
+ function resumeCommand(state, destination) {
39
+ const matches = destination !== null && state.chat === String(destination)
40
+
41
+ return `npx telstore ${shellArg(state.path)}${matches ? '' : ` --to ${shellArg(state.chat)}`}`
42
+ }
43
+
44
+ // Why a resume is off the table, in the words of the thing the user would have to fix. The
45
+ // record is keyed on the file's path, size and mtime, so any of these means runUpload would
46
+ // hash the file to a different key, find nothing, and start a second backup instead.
47
+ const NO_RESUME = {
48
+ missing: 'the file is no longer there',
49
+ changed: 'the file has changed since the backup started',
50
+ 'not-a-file': 'that path is no longer a file',
51
+ unreadable: 'the record does not name a file that can be read',
52
+ }
53
+
54
+ // A command is printed only when it will really resume. Printing one regardless would be
55
+ // telling the user to run something that quietly starts a second backup and abandons every
56
+ // chunk this one already sent — and those chunks are then findable only by this id, which
57
+ // is worth saying while there are any.
58
+ async function resumeLines(key, state, destination, done) {
59
+ const check = await canResume(key, state)
60
+
61
+ if (check.ok) return [field('Resume', resumeCommand(state, destination))]
62
+
63
+ const lines = [field('Resume', `not possible: ${NO_RESUME[check.reason]}.`)]
64
+
65
+ if (done > 0) {
66
+ lines.push(
67
+ `${CONTINUATION}${done} chunk${done === 1 ? ' is' : 's are'} already in the chat, ` +
68
+ 'searchable by this id.',
69
+ )
70
+ }
71
+
72
+ return lines
73
+ }
74
+
17
75
  function describeAccount(me) {
18
76
  const name = [me.firstName, me.lastName].filter(Boolean).join(' ')
19
77
  const handle = me.username ? ` (@${me.username})` : ''
@@ -72,13 +130,24 @@ export async function runStatus(options = {}, deps = {}) {
72
130
  settingsError = err.message
73
131
  }
74
132
 
133
+ // Which config this report is about, and whether the session in it can be read. Printed
134
+ // unconditionally: status never said where it looked before, and a row that shows up only
135
+ // when something is unusual reads as a warning rather than as a fact.
136
+ log(
137
+ row(
138
+ 'Session',
139
+ config.sealed
140
+ ? `${configFile(configDir)} (sealed — opened with a passphrase)`
141
+ : configFile(configDir),
142
+ ),
143
+ )
75
144
  log(row('Account', await accountLine(config, settings?.verbose ?? false, { connect, disconnect })))
76
145
  log(
77
146
  row(
78
147
  'Destination',
79
148
  settingsError ??
80
149
  (settings.chat === null
81
- ? 'none set — run "npx telark config chat @my_backups" to set one'
150
+ ? 'none set — run "npx telstore config chat @my_backups" to set one'
82
151
  : describeChat(settings.chat)),
83
152
  ),
84
153
  )
@@ -92,11 +161,20 @@ export async function runStatus(options = {}, deps = {}) {
92
161
 
93
162
  log(row('Unfinished', `${states.length} backup${states.length === 1 ? '' : 's'}`))
94
163
 
95
- for (const state of states) {
164
+ // The destination is what decides whether the resume command needs a --to. A row that
165
+ // failed to parse leaves nothing to compare against, which is not the same as a match.
166
+ const destination = settings?.chat ?? null
167
+
168
+ for (const { key, state } of states) {
96
169
  const total = countChunks(state.size, state.chunkSize)
97
170
  const done = Object.keys(state.done ?? {}).length
98
171
 
99
- log(` ${state.id} ${path.basename(state.path)} ${done}/${total} chunks ${formatBytes(state.size)}`)
100
- log(` → ${describeChat(state.chat)}`)
172
+ log('')
173
+ log(` ${state.id}`)
174
+ log(field('File', `${state.path} (${formatBytes(state.size)})`))
175
+ log(field('Chunks', `${done} of ${total} uploaded`))
176
+ log(field('Chat', describeChat(state.chat)))
177
+
178
+ for (const line of await resumeLines(key, state, destination, done)) log(line)
101
179
  }
102
180
  }
@@ -0,0 +1,80 @@
1
+ import { assertLoggedIn } from '../client.js'
2
+ import { defaultConfigDir, loadConfig } from '../config.js'
3
+ import { createPrompts } from '../prompt.js'
4
+ import { unlockConfig } from '../session.js'
5
+ import { knownSettings } from '../settings.js'
6
+ import { encodeToken } from '../token.js'
7
+
8
+ // Short enough that whoever holds the token can work through the possibilities faster than
9
+ // scrypt can slow them down. Not a rule — a length minimum is a preference wearing a check's
10
+ // clothes, and the main thing one teaches is to append digits — so this warns and goes on.
11
+ const SHORT_PASSPHRASE = 12
12
+
13
+ export async function runToken(args = [], options = {}, deps = {}) {
14
+ const {
15
+ configDir = defaultConfigDir(),
16
+ prompts = null,
17
+ log = (line) => console.log(line),
18
+ writeErr = (line) => process.stderr.write(line),
19
+ } = deps
20
+
21
+ const config = await loadConfig(configDir)
22
+
23
+ // Before anything is asked for. Someone who has never logged in should be told that, not
24
+ // asked to invent a passphrase for an account that is not there.
25
+ assertLoggedIn(config)
26
+
27
+ // Opened here rather than in the parameter list, so the command that refuses above never
28
+ // touches stdin. Every question in this run goes through this one interface: asking each
29
+ // through its own would leave the second one at end-of-input having read nothing.
30
+ const ask = prompts ?? createPrompts({ output: process.stderr })
31
+
32
+ try {
33
+ return await mint(config, ask, { log, writeErr })
34
+ } finally {
35
+ ask.close()
36
+ }
37
+ }
38
+
39
+ async function mint(config, ask, { log, writeErr }) {
40
+ const readSecret = (question) => ask.askSecret(question)
41
+ const account = await unlockConfig(config, { readSecret })
42
+
43
+ const passphrase = await readSecret('Passphrase to protect the token: ')
44
+ const again = await readSecret('Repeat it: ')
45
+
46
+ if (passphrase !== again) {
47
+ throw new Error('The two passphrases are different. Nothing was printed — run the command again.')
48
+ }
49
+
50
+ if (passphrase === '') {
51
+ writeErr(
52
+ 'This token has no passphrase, so anyone who reads it can use your Telegram account. ' +
53
+ 'Do not send it through anything that keeps a copy.\n',
54
+ )
55
+ } else if (passphrase.length < SHORT_PASSPHRASE) {
56
+ writeErr(
57
+ `That passphrase is ${passphrase.length} characters. Whoever holds the token can try ` +
58
+ 'passphrases offline as fast as their hardware allows, and telstore can only make ' +
59
+ 'each attempt cost about half a second.\n',
60
+ )
61
+ }
62
+
63
+ // The same true fact logout already states, said the same way: telstore cannot take a token
64
+ // back, and the session it carries outlives every copy of the token.
65
+ writeErr(
66
+ 'This token carries your Telegram session. telstore cannot take it back — as with ' +
67
+ 'logout, the session stays alive on Telegram\'s side until you open Telegram → ' +
68
+ 'Settings → Devices (Active sessions) and terminate it. It is about to be printed ' +
69
+ 'here, so it will sit in this terminal\'s scrollback until you clear it.\n\n',
70
+ )
71
+
72
+ // stdout carries the token and nothing else, the rule `config <name>` already follows, so
73
+ // this can be piped into a QR encoder with every warning above still on the screen.
74
+ log(
75
+ await encodeToken(
76
+ { ...account, settings: knownSettings(config.settings) },
77
+ passphrase,
78
+ ),
79
+ )
80
+ }
@@ -95,7 +95,7 @@ export async function runUpload(filePath, options = {}, deps = {}) {
95
95
  file: configFile(configDir),
96
96
  })
97
97
  const chat = requireChat(settings)
98
- const concurrency = settings.concurrency
98
+ const concurrency = settings.uploadConcurrency
99
99
 
100
100
  const key = stateKey(absPath, stat.size, stat.mtimeMs)
101
101
 
@@ -103,7 +103,7 @@ export async function runUpload(filePath, options = {}, deps = {}) {
103
103
 
104
104
  // The chunks already in the chat were cut at the size this backup started with, and
105
105
  // nothing can re-cut them. Carrying on at a different size would abandon every one of
106
- // them in the chat, where telark can no longer find them — so an unfinished backup
106
+ // them in the chat, where telstore can no longer find them — so an unfinished backup
107
107
  // keeps its own chunk size, and a flag that disagrees is refused rather than obeyed.
108
108
  //
109
109
  // Only a flag is a disagreement. A configured chunkSize says what to use when nobody asks
@@ -164,10 +164,10 @@ export async function runUpload(filePath, options = {}, deps = {}) {
164
164
 
165
165
  // Only a new backup adds to the directory, so this is the one place it can grow.
166
166
  // The report goes out even when the caller asked for silence: this is not narration
167
- // about a transfer, it is telark dropping the only record of someone else's chunks.
167
+ // about a transfer, it is telstore dropping the only record of someone else's chunks.
168
168
  for (const gone of await pruneStates(configDir)) {
169
169
  writeErr(
170
- `\nDropped the record of unfinished backup ${gone.id}: telark keeps the ` +
170
+ `\nDropped the record of unfinished backup ${gone.id}: telstore keeps the ` +
171
171
  `${MAX_STATES} most recent. The chunks it sent are still in ${gone.chat}, ` +
172
172
  'searchable by that id, but that backup can no longer be resumed.\n',
173
173
  )
@@ -185,7 +185,7 @@ export async function runUpload(filePath, options = {}, deps = {}) {
185
185
  if (delayMs > LONG_WAIT_MS) {
186
186
  warn(
187
187
  `\nTelegram wants ${formatDuration(delayMs / 1000)} of waiting before the next send ` +
188
- `(${err.message}). telark is waiting and will carry on by itself, leave it running.\n`,
188
+ `(${err.message}). telstore is waiting and will carry on by itself, leave it running.\n`,
189
189
  )
190
190
  return
191
191
  }
@@ -289,7 +289,7 @@ export async function runUpload(filePath, options = {}, deps = {}) {
289
289
  throw new Error(
290
290
  `${absPath} changed during the upload ` +
291
291
  `(size ${stat.size} → ${after.size}, mtime ${stat.mtimeMs} → ${after.mtimeMs}). ` +
292
- 'This backup mixes old and new data and cannot be trusted — telark is not sending the manifest. ' +
292
+ 'This backup mixes old and new data and cannot be trusted — telstore is not sending the manifest. ' +
293
293
  'Wait until the file settles, then run again to create a new backup.',
294
294
  )
295
295
  }
@@ -316,7 +316,7 @@ export async function runUpload(filePath, options = {}, deps = {}) {
316
316
 
317
317
  await clearState(key, configDir)
318
318
 
319
- log(`\nDone. Restore with:\n npx telark restore ${state.id}`)
319
+ log(`\nDone. Restore with:\n npx telstore restore ${state.id}`)
320
320
 
321
321
  return { id: state.id, chunks: chunks.length }
322
322
  } finally {
package/src/config.js CHANGED
@@ -5,7 +5,7 @@ import path from 'node:path'
5
5
  const FILE_NAME = 'config.json'
6
6
 
7
7
  export function defaultConfigDir() {
8
- return path.join(os.homedir(), '.telark')
8
+ return path.join(os.homedir(), '.telstore')
9
9
  }
10
10
 
11
11
  export function configFile(dir = defaultConfigDir()) {
@@ -19,7 +19,7 @@ function isPlainObject(value) {
19
19
  // `config.json` is hand-editable now that the `config` command invites people into it,
20
20
  // which puts it in the same category as a manifest or a state file: believed only after it
21
21
  // has been checked. A `settings` that is not an object would make every lookup below it
22
- // return undefined, and telark would then run happily on built-in defaults while the
22
+ // return undefined, and telstore would then run happily on built-in defaults while the
23
23
  // user's own choices sat there ignored — so it is named and refused instead.
24
24
  export function checkConfigShape(raw, file) {
25
25
  if (!isPlainObject(raw)) {
@@ -32,11 +32,31 @@ export function checkConfigShape(raw, file) {
32
32
  if (raw.settings !== undefined && !isPlainObject(raw.settings)) {
33
33
  throw new Error(
34
34
  `"settings" in ${file} holds ${Array.isArray(raw.settings) ? 'a list' : typeof raw.settings}, ` +
35
- 'not a group of settings. telark will not guess what was meant — fix that entry, ' +
35
+ 'not a group of settings. telstore will not guess what was meant — fix that entry, ' +
36
36
  'or remove it to fall back to the defaults.',
37
37
  )
38
38
  }
39
39
 
40
+ // What `login --token` leaves behind: the session and the api_hash sealed inside one blob
41
+ // that only a passphrase opens. It is read by `connect`, never by anything that writes.
42
+ if (raw.sealed !== undefined && typeof raw.sealed !== 'string') {
43
+ throw new Error(
44
+ `"sealed" in ${file} holds ${Array.isArray(raw.sealed) ? 'a list' : typeof raw.sealed}, ` +
45
+ 'not a sealed session. Log in again with "npx telstore login --token", or remove that ' +
46
+ 'entry and log in with a phone number.',
47
+ )
48
+ }
49
+
50
+ // Two sources of truth for one account, and nothing to say which one was meant. Guessing
51
+ // would mean connecting as an account the user did not choose — either the stale one they
52
+ // thought they had replaced, or the one they thought they had left behind.
53
+ if (raw.sealed !== undefined && (raw.session !== undefined || raw.apiHash !== undefined)) {
54
+ throw new Error(
55
+ `${file} holds both a sealed session and a plain one. telstore will not guess which ` +
56
+ 'account was meant — delete the file and log in again.',
57
+ )
58
+ }
59
+
40
60
  return raw
41
61
  }
42
62
 
@@ -57,7 +77,7 @@ export async function loadConfig(dir = defaultConfigDir()) {
57
77
  } catch (err) {
58
78
  throw new Error(
59
79
  `Corrupt config file: ${file} is not valid JSON (${err.message}). ` +
60
- 'Fix the syntax to keep your session, or delete the file and run "telark login" again.',
80
+ 'Fix the syntax to keep your session, or delete the file and run "telstore login" again.',
61
81
  )
62
82
  }
63
83
 
@@ -79,8 +99,12 @@ export async function saveConfig(config, dir = defaultConfigDir()) {
79
99
  await writeJsonAtomic(configFile(dir), config)
80
100
  }
81
101
 
102
+ // Both shapes go, because they are the same thing written two ways. On a machine that logged
103
+ // in with a token the api_hash lives inside the sealed blob, so keeping it "like an ordinary
104
+ // logout does" would keep the entire account.
82
105
  export async function clearSession(dir = defaultConfigDir()) {
83
106
  const config = await loadConfig(dir)
84
107
  delete config.session
108
+ delete config.sealed
85
109
  await saveConfig(config, dir)
86
110
  }