@uniquedivine/tg-sai 0.0.3 → 0.0.5

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/main.ts CHANGED
@@ -11,8 +11,11 @@ import {
11
11
  import { createRequire } from "node:module"
12
12
  import { tmpdir } from "node:os"
13
13
  import { join } from "node:path"
14
+ import { stdin as input, stdout as output } from "node:process"
15
+ import { createInterface } from "node:readline/promises"
14
16
  import { Command } from "commander"
15
-
17
+ import { addProvisionCommands } from "./provisioningCli"
18
+ import { addCustodyWorkerCommands } from "./custodyWorkerCli"
16
19
  import type { BatchLogger } from "./actionExecutor"
17
20
  import { withFloodWaitRetry } from "./actionExecutor"
18
21
  import type {
@@ -23,6 +26,7 @@ import type {
23
26
  import { planAdminBootstrap } from "./adminAudit"
24
27
  import { saiBot, USERS } from "./cfg"
25
28
  import { runHealthCheck } from "./health"
29
+ import { addRuntimeHealthCommands } from "./runtimeHealthCli"
26
30
  import {
27
31
  addTargetToChat,
28
32
  createMtcuteClient,
@@ -32,14 +36,20 @@ import {
32
36
  promoteTargetAdmin,
33
37
  transferTargetOwnership,
34
38
  } from "./mtcuteAdapter"
35
- import { formatSaiChatJsonLine, parseChatInputFile } from "./ops"
39
+ import {
40
+ formatSaiChatJsonLine,
41
+ parseChatInputFile,
42
+ resolveBootstrapTarget,
43
+ } from "./ops"
36
44
  import type { ChatInput } from "./ops"
37
45
  import {
38
46
  addProfile,
39
47
  configDir,
40
48
  discoveryReport,
49
+ findProfileIndex,
41
50
  getProfile,
42
51
  profileInputFromUser,
52
+ profileLabel,
43
53
  readConfig,
44
54
  redactedProfile,
45
55
  removeProfile,
@@ -106,8 +116,11 @@ interface SaiBootstrapOptions {
106
116
  run?: boolean
107
117
  /** Attempt ownership transfer when the Telegram password is available. */
108
118
  transferOwner?: boolean
119
+ /** Owner target: user ID or username. Defaults to the Sai custody account. */
120
+ targetUser?: number | string
109
121
  }
110
122
 
123
+
111
124
  interface TelegramEnv {
112
125
  apiId: number
113
126
  apiHash: string
@@ -116,7 +129,7 @@ interface TelegramEnv {
116
129
  }
117
130
 
118
131
  interface ProfileAddOptions {
119
- name: string
132
+ name?: string
120
133
  apiId?: string
121
134
  apiHash?: string
122
135
  sessionString?: string
@@ -128,6 +141,7 @@ interface ProfileAddOptions {
128
141
  login?: boolean
129
142
  qr?: boolean
130
143
  phone?: boolean
144
+ interactive?: boolean
131
145
  }
132
146
 
133
147
  const saiBotAdminRights = {
@@ -285,12 +299,15 @@ async function getTelegramEnv(profileName?: string): Promise<TelegramEnv> {
285
299
  }
286
300
  }
287
301
 
288
- async function selectedProfileName(profileName?: string): Promise<string | null> {
302
+ async function selectedProfileName(
303
+ profileName?: string,
304
+ ): Promise<string | null> {
289
305
  if (profileName) {
290
306
  return profileName
291
307
  }
292
308
  const config = await readConfig()
293
- return config.activeProfile
309
+ const profile = config.profiles[0]
310
+ return profile ? profileLabel(profile, 0) : null
294
311
  }
295
312
 
296
313
  function parsePeer(value: string): number | string {
@@ -334,7 +351,7 @@ function printAudit(input: ChatAuditInput) {
334
351
  console.log(
335
352
  `target_can_receive_ownership: ${audit.targetCanReceiveOwnership}`,
336
353
  )
337
- console.log(`proposed_actions: ${audit.proposedActions.join("; ")}`)
354
+ console.log(`proposed_actions: ${formatActionList(audit.proposedActions)}`)
338
355
  }
339
356
 
340
357
  function shouldRunAction(
@@ -344,6 +361,10 @@ function shouldRunAction(
344
361
  return proposedActions.includes(action)
345
362
  }
346
363
 
364
+ function formatActionList(actions: ProposedAction[]): string {
365
+ return JSON.stringify(actions)
366
+ }
367
+
347
368
  async function withClient<T>(
348
369
  profileName: string | undefined,
349
370
  fn: (
@@ -404,7 +425,9 @@ async function runAddBot(options: AddBotOptions): Promise<void> {
404
425
  console.log(`target_peer: ${options.targetPeer}`)
405
426
  console.log(`actor_role: ${beforePlan.actorRole}`)
406
427
  console.log(`target_role_before: ${beforePlan.targetRole}`)
407
- console.log(`proposed_actions: ${beforePlan.proposedActions.join("; ")}`)
428
+ console.log(
429
+ `proposed_actions: ${formatActionList(beforePlan.proposedActions)}`,
430
+ )
408
431
 
409
432
  if (options.run !== true) {
410
433
  console.log("dry_run: pass --run to add/promote the bot")
@@ -451,7 +474,9 @@ async function runAddBot(options: AddBotOptions): Promise<void> {
451
474
 
452
475
  console.log(`target_role_after: ${afterPlan.targetRole}`)
453
476
  console.log(`target_is_admin_after: ${afterPlan.targetIsAdmin}`)
454
- console.log(`remaining_actions: ${afterPlan.proposedActions.join("; ")}`)
477
+ console.log(
478
+ `remaining_actions: ${formatActionList(afterPlan.proposedActions)}`,
479
+ )
455
480
  })
456
481
  }
457
482
 
@@ -472,7 +497,9 @@ async function runAddOwner(options: AddOwnerOptions): Promise<void> {
472
497
  console.log(
473
498
  `target_can_receive_ownership_before: ${beforePlan.targetCanReceiveOwnership}`,
474
499
  )
475
- console.log(`proposed_actions: ${beforePlan.proposedActions.join("; ")}`)
500
+ console.log(
501
+ `proposed_actions: ${formatActionList(beforePlan.proposedActions)}`,
502
+ )
476
503
 
477
504
  if (options.run !== true) {
478
505
  console.log("dry_run: pass --run to add/promote the target user")
@@ -522,7 +549,9 @@ async function runAddOwner(options: AddOwnerOptions): Promise<void> {
522
549
  console.log(
523
550
  `target_can_receive_ownership_after: ${afterPlan.targetCanReceiveOwnership}`,
524
551
  )
525
- console.log(`remaining_actions: ${afterPlan.proposedActions.join("; ")}`)
552
+ console.log(
553
+ `remaining_actions: ${formatActionList(afterPlan.proposedActions)}`,
554
+ )
526
555
 
527
556
  if (!afterPlan.actorCanTransferOwnership) {
528
557
  console.log("ownership_transfer: skipped_actor_not_owner")
@@ -762,6 +791,7 @@ async function runSaiBootstrap(options: SaiBootstrapOptions): Promise<void> {
762
791
  const actor = normalizeUser(await client.getMe())
763
792
  const actorUsername = actor.username ?? ""
764
793
  const profileName = await selectedProfileName(options.profile)
794
+ const ownerTarget = resolveBootstrapTarget(options.targetUser)
765
795
  const ownershipTransferEnabled =
766
796
  options.transferOwner !== false && env.password !== null
767
797
  console.log(`wrote_run_log: ${logPath}`)
@@ -775,7 +805,7 @@ async function runSaiBootstrap(options: SaiBootstrapOptions): Promise<void> {
775
805
  transferOwner: ownershipTransferEnabled,
776
806
  passwordAvailable: env.password !== null,
777
807
  delayMs,
778
- targetUser: USERS.uniqueDivine.id,
808
+ targetUser: ownerTarget,
779
809
  targetBot: saiBot.id,
780
810
  })
781
811
 
@@ -804,8 +834,8 @@ async function runSaiBootstrap(options: SaiBootstrapOptions): Promise<void> {
804
834
  client,
805
835
  chat,
806
836
  actorUsername,
807
- targetUserId: USERS.uniqueDivine.id,
808
- targetPeer: USERS.uniqueDivine.handle,
837
+ targetUserId: ownerTarget,
838
+ targetPeer: ownerTarget,
809
839
  rights: ownerTargetAdminRights,
810
840
  actionPrefix: "owner_target",
811
841
  run: options.run === true,
@@ -815,7 +845,7 @@ async function runSaiBootstrap(options: SaiBootstrapOptions): Promise<void> {
815
845
  env,
816
846
  chat,
817
847
  actorUsername,
818
- targetUser: USERS.uniqueDivine.handle,
848
+ targetUser: ownerTarget,
819
849
  enabled: ownershipTransferEnabled,
820
850
  run: options.run === true,
821
851
  })
@@ -854,19 +884,6 @@ async function runSaiBootstrap(options: SaiBootstrapOptions): Promise<void> {
854
884
  })
855
885
  }
856
886
 
857
- function getCredentialsFromProfileAddOptions(
858
- options: ProfileAddOptions,
859
- ): TelegramCredentials | null {
860
- if (options.apiId && options.apiHash && options.sessionString) {
861
- return {
862
- apiId: Number(options.apiId),
863
- apiHash: options.apiHash,
864
- sessionString: options.sessionString,
865
- }
866
- }
867
- return null
868
- }
869
-
870
887
  function credentialValuesFromProfileAddOptions(
871
888
  options: ProfileAddOptions,
872
889
  ): TelegramCredentialValues {
@@ -878,6 +895,17 @@ function credentialValuesFromProfileAddOptions(
878
895
  }
879
896
  }
880
897
 
898
+ function credentialValuesFromEnv(
899
+ env: NodeJS.ProcessEnv = process.env,
900
+ ): TelegramCredentialValues {
901
+ return {
902
+ TELEGRAM_API_ID: env.TELEGRAM_API_ID,
903
+ TELEGRAM_API_HASH: env.TELEGRAM_API_HASH,
904
+ TELEGRAM_SESSION_STRING: env.TELEGRAM_SESSION_STRING,
905
+ TELEGRAM_PASSWORD: env.TELEGRAM_PASSWORD,
906
+ }
907
+ }
908
+
881
909
  function profileFieldsFromCredentialValues(values: TelegramCredentialValues) {
882
910
  return {
883
911
  apiId: values.TELEGRAM_API_ID ? Number(values.TELEGRAM_API_ID) : null,
@@ -887,6 +915,10 @@ function profileFieldsFromCredentialValues(values: TelegramCredentialValues) {
887
915
  }
888
916
  }
889
917
 
918
+ type ProfileCredentialFields = ReturnType<
919
+ typeof profileFieldsFromCredentialValues
920
+ >
921
+
890
922
  function mergeCredentialValues(
891
923
  ...valuesList: Array<TelegramCredentialValues | null>
892
924
  ): TelegramCredentialValues {
@@ -904,10 +936,112 @@ function mergeCredentialValues(
904
936
  return merged
905
937
  }
906
938
 
939
+ function redactedCredentialValue(key: string, value: string): string {
940
+ if (
941
+ key === "TELEGRAM_API_HASH" ||
942
+ key === "TELEGRAM_SESSION_STRING" ||
943
+ key === "TELEGRAM_PASSWORD"
944
+ ) {
945
+ return value ? "[redacted]" : ""
946
+ }
947
+ return value
948
+ }
949
+
950
+ function sourceLabel(candidate: DiscoveryReport["candidates"][number]): string {
951
+ return `${candidate.name} ${candidate.path} ${candidate.objectPath}`
952
+ }
953
+
954
+ function mergeDiscoveredValues(
955
+ candidates: DiscoveryReport["candidates"],
956
+ ): TelegramCredentialValues {
957
+ const merged: TelegramCredentialValues = {}
958
+ const origins: Partial<
959
+ Record<
960
+ keyof TelegramCredentialValues,
961
+ DiscoveryReport["candidates"][number]
962
+ >
963
+ > = {}
964
+
965
+ for (const candidate of candidates) {
966
+ for (const [key, value] of Object.entries(candidate.values)) {
967
+ if (value === undefined || value === "") {
968
+ continue
969
+ }
970
+ const typedKey = key as keyof TelegramCredentialValues
971
+ const previous = merged[typedKey]
972
+ if (previous !== undefined && previous !== value) {
973
+ const previousOrigin = origins[typedKey]!
974
+ throw new Error(
975
+ [
976
+ `Conflicting Telegram credential values for ${key}.`,
977
+ `- ${sourceLabel(previousOrigin)}: ${redactedCredentialValue(
978
+ key,
979
+ previous,
980
+ )}`,
981
+ `- ${sourceLabel(candidate)}: ${redactedCredentialValue(
982
+ key,
983
+ value,
984
+ )}`,
985
+ "Run `tg-sai profile find` and choose one source with `--src <number>`.",
986
+ ].join("\n"),
987
+ )
988
+ }
989
+ merged[typedKey] = value
990
+ origins[typedKey] = candidate
991
+ }
992
+ }
993
+
994
+ return merged
995
+ }
996
+
997
+ function assertNoExplicitSourceConflicts(params: {
998
+ sourceValues: TelegramCredentialValues
999
+ higherPrecedenceValues: TelegramCredentialValues
1000
+ sourceDescription: string
1001
+ }): void {
1002
+ for (const [key, value] of Object.entries(params.sourceValues)) {
1003
+ if (value === undefined || value === "") {
1004
+ continue
1005
+ }
1006
+ const higherValue =
1007
+ params.higherPrecedenceValues[key as keyof TelegramCredentialValues]
1008
+ if (
1009
+ higherValue !== undefined &&
1010
+ higherValue !== "" &&
1011
+ higherValue !== value
1012
+ ) {
1013
+ throw new Error(
1014
+ [
1015
+ `Conflicting Telegram credential values for ${key}.`,
1016
+ `- ${params.sourceDescription}: ${redactedCredentialValue(
1017
+ key,
1018
+ value,
1019
+ )}`,
1020
+ `- CLI/env: ${redactedCredentialValue(key, higherValue)}`,
1021
+ "Remove the conflicting flag/env var or choose a different `--src`.",
1022
+ ].join("\n"),
1023
+ )
1024
+ }
1025
+ }
1026
+ }
1027
+
907
1028
  function validateLoginOptions(options: ProfileAddOptions): void {
908
1029
  if (options.qr && options.phone) {
909
1030
  throw new Error("--qr and --phone are mutually exclusive")
910
1031
  }
1032
+ if ((options.login || options.qr || options.phone) && options.sessionString) {
1033
+ throw new Error("Fresh login cannot be combined with --session-string")
1034
+ }
1035
+ }
1036
+
1037
+ /** A new login can reuse app credentials, never another account's session or 2FA. */
1038
+ function profileLoginValues(
1039
+ values: TelegramCredentialValues | null,
1040
+ options: ProfileAddOptions,
1041
+ ): TelegramCredentialValues {
1042
+ if (!(options.login || options.qr || options.phone)) return values ?? {}
1043
+ const { TELEGRAM_SESSION_STRING: _session, TELEGRAM_PASSWORD: _password, ...app } = values ?? {}
1044
+ return app
911
1045
  }
912
1046
 
913
1047
  async function generateSessionString(params: {
@@ -953,49 +1087,139 @@ async function generateSessionString(params: {
953
1087
  }
954
1088
  }
955
1089
 
956
- async function getCredentialValuesFromSourceOption(
1090
+ async function discoveredValuesForProfileAdd(
957
1091
  options: ProfileAddOptions,
958
- ): Promise<TelegramCredentialValues | null> {
959
- if (!options.src) {
960
- return null
961
- }
962
- const index = Number(options.src)
963
- if (!Number.isInteger(index) || index < 1) {
964
- throw new Error("--src must be a 1-based source number")
1092
+ ): Promise<{
1093
+ values: TelegramCredentialValues | null
1094
+ sourceDescription: string | null
1095
+ explicit: boolean
1096
+ }> {
1097
+ if (options.src) {
1098
+ const index = Number(options.src)
1099
+ if (!Number.isInteger(index) || index < 1) {
1100
+ throw new Error("--src must be a 1-based source number")
1101
+ }
1102
+
1103
+ const report = await discoveryReport()
1104
+ const candidate = report.candidates[index - 1]
1105
+ if (!candidate) {
1106
+ throw new Error(`Unknown discovery source: ${options.src}`)
1107
+ }
1108
+ return {
1109
+ values: profileLoginValues(candidate.values, options),
1110
+ sourceDescription: sourceLabel(candidate),
1111
+ explicit: true,
1112
+ }
965
1113
  }
966
1114
 
967
1115
  const report = await discoveryReport()
968
- const candidate = report.candidates[index - 1]
969
- if (!candidate) {
970
- throw new Error(`Unknown discovery source: ${options.src}`)
1116
+ return {
1117
+ values: mergeDiscoveredValues(report.candidates.map((candidate) => ({
1118
+ ...candidate, values: profileLoginValues(candidate.values, options),
1119
+ }))),
1120
+ sourceDescription: null,
1121
+ explicit: false,
1122
+ }
1123
+ }
1124
+
1125
+ async function promptForMissingProfileFields(
1126
+ profileFields: ProfileCredentialFields,
1127
+ options: ProfileAddOptions,
1128
+ ): Promise<void> {
1129
+ if (!options.interactive) {
1130
+ return
1131
+ }
1132
+
1133
+ const rl = createInterface({ input, output })
1134
+ try {
1135
+ if (!profileFields.apiId) {
1136
+ const value = (await rl.question("Telegram API ID: ")).trim()
1137
+ if (value) {
1138
+ profileFields.apiId = Number(value)
1139
+ }
1140
+ }
1141
+ if (!profileFields.apiHash) {
1142
+ const value = (await rl.question("Telegram API hash: ")).trim()
1143
+ if (value) {
1144
+ profileFields.apiHash = value
1145
+ }
1146
+ }
1147
+ if (
1148
+ !profileFields.sessionString &&
1149
+ !options.qr &&
1150
+ !options.phone &&
1151
+ !options.login
1152
+ ) {
1153
+ const value = (
1154
+ await rl.question("Generate a Telegram session now? [Y=QR/n/phone]: ")
1155
+ )
1156
+ .trim()
1157
+ .toLowerCase()
1158
+ if (value === "phone" || value === "p") {
1159
+ options.phone = true
1160
+ } else if (value === "" || value === "y" || value === "yes") {
1161
+ options.qr = true
1162
+ }
1163
+ }
1164
+ } finally {
1165
+ rl.close()
971
1166
  }
972
- return candidate.values
973
1167
  }
974
1168
 
975
1169
  async function inputFromProfileAddOptions(
976
1170
  options: ProfileAddOptions,
977
1171
  ): Promise<ProfileAddInput> {
978
1172
  validateLoginOptions(options)
979
- const credentials = getCredentialsFromProfileAddOptions(options)
980
- const sourceValues = credentials
981
- ? null
982
- : await getCredentialValuesFromSourceOption(options)
1173
+ const saved = getProfile(await readConfig(), options.name)
1174
+ const enrollsAccount = Boolean(
1175
+ options.login || options.qr || options.phone || options.src || options.sessionString,
1176
+ )
1177
+ if (saved && !enrollsAccount) {
1178
+ // Resolve the saved target before discovery. A password/app metadata edit
1179
+ // must never authenticate another discovered account and overwrite this one.
1180
+ if (
1181
+ (options.userId !== undefined && Number(options.userId) !== saved.userId) ||
1182
+ (options.handle !== undefined && options.handle !== saved.handle)
1183
+ ) {
1184
+ throw new Error("Changing a saved account identity requires an explicit fresh login or session import")
1185
+ }
1186
+ return {
1187
+ name: saved.name,
1188
+ apiId: options.apiId === undefined ? saved.apiId : Number(options.apiId),
1189
+ apiHash: options.apiHash ?? saved.apiHash,
1190
+ sessionString: saved.sessionString,
1191
+ password: options.password ?? saved.password,
1192
+ handle: saved.handle,
1193
+ userId: saved.userId,
1194
+ displayName: options.displayName ?? saved.displayName,
1195
+ }
1196
+ }
1197
+ if (!saved && options.password !== undefined && !enrollsAccount) {
1198
+ throw new Error(`Unknown configured profile: ${options.name ?? "active profile"}. Use tg-sai profile list and select it with --name; --src selects credential discovery sources.`)
1199
+ }
983
1200
  const optionValues = credentialValuesFromProfileAddOptions(options)
984
- const profileFields = credentials
985
- ? {
986
- apiId: credentials.apiId,
987
- apiHash: credentials.apiHash,
988
- sessionString: credentials.sessionString,
989
- password: optionValues.TELEGRAM_PASSWORD ?? "",
990
- }
991
- : profileFieldsFromCredentialValues(
992
- mergeCredentialValues(sourceValues, optionValues),
993
- )
1201
+ const envValues = profileLoginValues(credentialValuesFromEnv(), options)
1202
+ const discovery = await discoveredValuesForProfileAdd(options)
1203
+ const higherPrecedenceValues = mergeCredentialValues(envValues, optionValues)
1204
+ if (discovery.explicit && discovery.values && discovery.sourceDescription) {
1205
+ assertNoExplicitSourceConflicts({
1206
+ sourceValues: discovery.values,
1207
+ higherPrecedenceValues,
1208
+ sourceDescription: discovery.sourceDescription,
1209
+ })
1210
+ }
1211
+ const profileFields = profileFieldsFromCredentialValues(
1212
+ mergeCredentialValues(discovery.values, envValues, optionValues),
1213
+ )
1214
+ await promptForMissingProfileFields(profileFields, options)
994
1215
 
995
- if (options.login && !profileFields.sessionString) {
1216
+ if (
1217
+ (options.login || options.qr || options.phone) &&
1218
+ !profileFields.sessionString
1219
+ ) {
996
1220
  if (!profileFields.apiId || !profileFields.apiHash) {
997
1221
  throw new Error(
998
- "--login requires apiId and apiHash from flags, --src, env, or .env discovery",
1222
+ "Session generation requires apiId and apiHash from flags, env, or discovery",
999
1223
  )
1000
1224
  }
1001
1225
  profileFields.sessionString = await generateSessionString({
@@ -1060,33 +1284,117 @@ async function inputFromProfileAddOptions(
1060
1284
 
1061
1285
  async function runProfileList(): Promise<void> {
1062
1286
  const config = await readConfig()
1063
- const profiles = Object.values(config.profiles)
1064
- if (profiles.length === 0) {
1287
+ if (config.profiles.length === 0) {
1065
1288
  console.log("No profiles configured.")
1066
1289
  return
1067
1290
  }
1068
- for (const profile of profiles) {
1069
- const active = config.activeProfile === profile.name ? "*" : " "
1291
+ for (const [index, profile] of config.profiles.entries()) {
1292
+ const active = index === 0 ? "*" : " "
1070
1293
  console.log(
1071
- `${active} ${profile.name}\t${profile.handle ?? ""}\t${profile.userId}\t${profile.displayName}`,
1294
+ `${active} ${index}\t${profileLabel(profile, index)}\t${profile.userId}\t${profile.displayName}`,
1072
1295
  )
1073
1296
  }
1074
1297
  }
1075
1298
 
1076
- async function runProfileShow(name: string): Promise<void> {
1299
+ async function runProfileShow(selector = "0"): Promise<void> {
1077
1300
  const config = await readConfig()
1078
- const profile = getProfile(config, name)
1301
+ const profile = getProfile(config, selector)
1079
1302
  if (!profile) {
1080
- throw new Error(`Unknown profile: ${name}`)
1303
+ throw new Error(`Unknown profile: ${selector}`)
1081
1304
  }
1082
1305
  console.log(JSON.stringify(redactedProfile(profile), null, 2))
1083
1306
  }
1084
1307
 
1308
+ async function printConfiguredProfiles(): Promise<void> {
1309
+ console.log("\nConfigured local profiles (* marks the active/default profile):")
1310
+ await runProfileList()
1311
+ }
1312
+
1313
+ async function printProfileAddHelp(command: Command): Promise<void> {
1314
+ command.outputHelp()
1315
+ console.log(
1316
+ "\nNo profile is saved and no Telegram login starts without explicit options.",
1317
+ )
1318
+ console.log(
1319
+ "Use a new --name to add a separate account. Without --name, explicit options update the active profile.",
1320
+ )
1321
+ console.log("\nFresh QR login for a separate custody account:")
1322
+ console.log(
1323
+ " tg-sai profile add --name sai-custody --login --qr --interactive",
1324
+ )
1325
+ console.log(
1326
+ "Scan while signed into the intended Telegram account, then inspect it with:",
1327
+ )
1328
+ console.log(" tg-sai profile show sai-custody")
1329
+ await printConfiguredProfiles()
1330
+ console.log("\nInspect an existing profile with tg-sai profile show <selector>.")
1331
+ console.log("Update a saved password with --name <saved-name-or-handle-or-index> --password '<password>'.")
1332
+ console.log("--src selects a numbered credential source from profile find, not a saved profile index.")
1333
+ }
1334
+
1335
+ async function printProfileRemoveHelp(command: Command): Promise<void> {
1336
+ command.outputHelp()
1337
+ console.log(
1338
+ "\nRemoves a saved local profile, not the Telegram account or its chat memberships.",
1339
+ )
1340
+ console.log("Removing a profile does not revoke its Telegram sessions.")
1341
+ console.log("No profile is removed without an explicit selector.")
1342
+ const config = await readConfig()
1343
+ if (config.profiles.length === 0) {
1344
+ console.log("\nNo saved profiles are available to remove.")
1345
+ console.log(
1346
+ "Add an account with: tg-sai profile add --login --qr --interactive",
1347
+ )
1348
+ return
1349
+ }
1350
+
1351
+ console.log("\nRunnable examples for saved profiles:")
1352
+ let examples = 0
1353
+ for (const [index, saved] of config.profiles.entries()) {
1354
+ // Handles and names can overlap. Emit only selectors that resolve to this row.
1355
+ const selector = [
1356
+ saved.handle,
1357
+ saved.name,
1358
+ saved.userId?.toString(),
1359
+ String(index),
1360
+ ].find(
1361
+ (candidate) =>
1362
+ candidate != null && findProfileIndex(config, candidate) === index,
1363
+ )
1364
+ if (selector == null) continue
1365
+ const argument = /^[A-Za-z0-9_@./:-]+$/.test(selector)
1366
+ ? selector
1367
+ : `'${selector.replaceAll("'", "'\\''")}'`
1368
+ const label = JSON.stringify(profileLabel(saved, index))
1369
+ console.log(
1370
+ ` tg-sai profile remove -- ${argument} # ${label}${index === 0 ? " [active]" : ""}`,
1371
+ )
1372
+ examples++
1373
+ }
1374
+ if (examples < config.profiles.length) {
1375
+ console.log(
1376
+ "Some profiles have ambiguous selectors; inspect tg-sai profile list before removing them.",
1377
+ )
1378
+ }
1379
+ console.log(
1380
+ "\nExamples use the current profile configuration. Run tg-sai profile list to inspect it.",
1381
+ )
1382
+ }
1383
+
1085
1384
  async function runProfileAdd(options: ProfileAddOptions): Promise<void> {
1385
+ const previous = getProfile(await readConfig(), options.name)
1086
1386
  const input = await inputFromProfileAddOptions(options)
1387
+ if (previous?.userId != null && input.userId !== previous.userId) {
1388
+ throw new Error("Login or import does not match the saved account. Use a new --name for another account, or explicitly remove the old profile before replacing it.")
1389
+ }
1390
+ if (previous) input.name = previous.name
1087
1391
  const config = await addProfile(input)
1088
- const active = config.activeProfile === input.name ? "active" : "saved"
1089
- console.log(`profile_${active}: ${input.name}`)
1392
+ const profile = getProfile(config, input.name)
1393
+ const index = profile ? config.profiles.indexOf(profile) : 0
1394
+ const savedProfile = profile ?? config.profiles[0]!
1395
+ const active = index === 0 ? "default" : "saved"
1396
+ console.log(`profile_${active}: ${profileLabel(savedProfile, index)}`)
1397
+ console.log(`profile_index: ${index}`)
1090
1398
  console.log(`user_id: ${input.userId}`)
1091
1399
  console.log(`handle: ${input.handle ?? ""}`)
1092
1400
  console.log(`telegram_password: ${input.password ? "stored" : "missing"}`)
@@ -1122,7 +1430,23 @@ function printDiscoveryReport(report: DiscoveryReport): void {
1122
1430
  )
1123
1431
  }
1124
1432
  console.log("next:")
1125
- console.log(" tg-sai profile add --name <name> --src <number>")
1433
+ try {
1434
+ const values = mergeDiscoveredValues(report.candidates)
1435
+ if (Object.keys(values).length === 0) {
1436
+ console.log(" tg-sai profile add")
1437
+ return
1438
+ }
1439
+ console.log(" tg-sai profile add")
1440
+ if (!values.TELEGRAM_SESSION_STRING) {
1441
+ console.log(" tg-sai profile add --qr")
1442
+ console.log(" tg-sai profile add --phone")
1443
+ }
1444
+ } catch (error) {
1445
+ console.log(
1446
+ ` conflict: ${error instanceof Error ? error.message : String(error)}`,
1447
+ )
1448
+ console.log(" tg-sai profile add --src <number>")
1449
+ }
1126
1450
  }
1127
1451
 
1128
1452
  export function createProgram(): Command {
@@ -1131,29 +1455,20 @@ export function createProgram(): Command {
1131
1455
  program
1132
1456
  .name("tg-sai")
1133
1457
  .description("Audit and manage Sai Telegram chat admin state")
1134
- .version(packageJson.version)
1458
+ .version(packageJson.version, "--version", "output the version number")
1135
1459
  .helpOption("--help", "display help for command")
1136
1460
 
1137
1461
  program.action(() => {
1138
1462
  program.help({ error: false })
1139
1463
  })
1140
1464
 
1141
- program
1142
- .command("health")
1143
- .description("Read-only preflight for the local tg-sai setup")
1144
- .action(async () => {
1145
- await runHealth()
1146
- })
1465
+ addRuntimeHealthCommands(program, runHealth)
1147
1466
 
1148
1467
  program
1149
1468
  .command("audit")
1150
1469
  .description("Read-only audit for one chat and target user")
1151
1470
  .option("--profile <name>", "Profile name to use for Telegram credentials")
1152
- .requiredOption(
1153
- "--chat <id>",
1154
- "Telegram chat ID or username",
1155
- parsePeer,
1156
- )
1471
+ .requiredOption("--chat <id>", "Telegram chat ID or username", parsePeer)
1157
1472
  .option(
1158
1473
  "--target-user <id>",
1159
1474
  "Target user ID or username",
@@ -1176,11 +1491,7 @@ Examples:
1176
1491
  .command("add-bot")
1177
1492
  .description("Add SaiTeam_bot and promote it to admin when possible")
1178
1493
  .option("--profile <name>", "Profile name to use for Telegram credentials")
1179
- .requiredOption(
1180
- "--chat <id>",
1181
- "Telegram chat ID or username",
1182
- parsePeer,
1183
- )
1494
+ .requiredOption("--chat <id>", "Telegram chat ID or username", parsePeer)
1184
1495
  .option(
1185
1496
  "--target-user <id>",
1186
1497
  "Bot user ID used for membership audit",
@@ -1202,11 +1513,7 @@ Examples:
1202
1513
  .command("add-owner")
1203
1514
  .description("Add a target user as owner if possible, otherwise admin")
1204
1515
  .option("--profile <name>", "Profile name to use for Telegram credentials")
1205
- .requiredOption(
1206
- "--chat <id>",
1207
- "Telegram chat ID or username",
1208
- parsePeer,
1209
- )
1516
+ .requiredOption("--chat <id>", "Telegram chat ID or username", parsePeer)
1210
1517
  .requiredOption(
1211
1518
  "--target-user <peer>",
1212
1519
  "Target user ID or username",
@@ -1253,6 +1560,11 @@ Examples:
1253
1560
  "--no-transfer-owner",
1254
1561
  "Skip ownership transfer even when TELEGRAM_PASSWORD is available",
1255
1562
  )
1563
+ .option(
1564
+ "--target-user <peer>",
1565
+ "Owner target user ID or username; default is the Sai custody account",
1566
+ parsePeer,
1567
+ )
1256
1568
  .action(async (options: SaiBootstrapOptions) => {
1257
1569
  await runSaiBootstrap(options)
1258
1570
  })
@@ -1260,6 +1572,10 @@ Examples:
1260
1572
  const profile = program
1261
1573
  .command("profile")
1262
1574
  .description("Manage local Telegram session profiles")
1575
+ .action(async () => {
1576
+ profile.outputHelp()
1577
+ await printConfiguredProfiles()
1578
+ })
1263
1579
 
1264
1580
  profile
1265
1581
  .command("find")
@@ -1275,10 +1591,13 @@ Examples:
1275
1591
  await runProfileList()
1276
1592
  })
1277
1593
 
1278
- profile
1594
+ const addProfileCommand = profile
1279
1595
  .command("add")
1280
1596
  .description("Add or update a Telegram session profile")
1281
- .requiredOption("--name <name>", "Profile name")
1597
+ .option(
1598
+ "--name <name>",
1599
+ "Local profile name; use a distinct name for another account",
1600
+ )
1282
1601
  .option("--api-id <id>", "Telegram API ID")
1283
1602
  .option("--api-hash <hash>", "Telegram API hash")
1284
1603
  .option("--session-string <value>", "Telegram session string")
@@ -1286,43 +1605,78 @@ Examples:
1286
1605
  "--password <value>",
1287
1606
  "Telegram 2FA password for ownership transfer",
1288
1607
  )
1289
- .option("--src <number>", "Use a numbered source from profile find")
1290
- .option("--login", "Generate a session string when missing")
1291
- .option("--qr", "Use QR login with --login")
1292
- .option("--phone", "Use phone login with --login")
1608
+ .option("--src <number>", "Import a numbered credential source from profile find, not a saved profile")
1609
+ .option(
1610
+ "--login",
1611
+ "Start a fresh account login, ignoring discovered sessions (defaults to QR)",
1612
+ )
1613
+ .option("--qr", "Generate a session string with QR login")
1614
+ .option("--phone", "Generate a session string with phone login")
1615
+ .option("-i, --interactive", "Prompt for missing profile values")
1293
1616
  .option("--handle <handle>", "Resolved Telegram handle")
1294
1617
  .option("--user-id <id>", "Resolved Telegram user ID")
1295
1618
  .option("--display-name <name>", "Resolved Telegram display name")
1619
+ .addHelpText(
1620
+ "after",
1621
+ `
1622
+ Examples:
1623
+ tg-sai profile add --name sai-custody --login --qr --interactive
1624
+ tg-sai profile add --name another-account --phone --interactive
1625
+ tg-sai profile add --name another-account --src 1
1626
+ tg-sai profile add --name existing-profile --password '<password>'
1627
+
1628
+ If apiId/apiHash are missing, create a Telegram API app at:
1629
+ https://my.telegram.org/apps
1630
+ `,
1631
+ )
1296
1632
  .action(async (options: ProfileAddOptions) => {
1633
+ if (Object.keys(options).length === 0) {
1634
+ await printProfileAddHelp(addProfileCommand)
1635
+ return
1636
+ }
1297
1637
  await runProfileAdd(options)
1298
1638
  })
1299
1639
 
1300
1640
  profile
1301
1641
  .command("show")
1302
1642
  .description("Show one configured profile")
1303
- .argument("<name>", "Profile name")
1304
- .action(async (name: string) => {
1305
- await runProfileShow(name)
1643
+ .argument(
1644
+ "[selector]",
1645
+ "Profile index, handle, user ID, or legacy name",
1646
+ "0",
1647
+ )
1648
+ .action(async (selector: string) => {
1649
+ await runProfileShow(selector)
1306
1650
  })
1307
1651
 
1308
1652
  profile
1309
1653
  .command("use")
1310
1654
  .description("Set the active profile")
1311
- .argument("<name>", "Profile name")
1312
- .action(async (name: string) => {
1313
- await setActiveProfile(name)
1314
- console.log(`active_profile: ${name}`)
1655
+ .argument("<selector>", "Profile index, handle, user ID, or legacy name")
1656
+ .action(async (selector: string) => {
1657
+ const config = await setActiveProfile(selector)
1658
+ const active = config.profiles[0]!
1659
+ console.log(`active_profile: ${profileLabel(active, 0)}`)
1315
1660
  })
1316
1661
 
1317
- profile
1662
+ const removeProfileCommand = profile
1318
1663
  .command("remove")
1319
1664
  .description("Remove one configured profile")
1320
- .argument("<name>", "Profile name")
1321
- .action(async (name: string) => {
1322
- await removeProfile(name)
1323
- console.log(`removed_profile: ${name}`)
1665
+ .argument(
1666
+ "[selector]",
1667
+ "Profile index, handle, user ID, or legacy name; omit for examples",
1668
+ )
1669
+ .action(async (selector: string | undefined) => {
1670
+ if (selector === undefined) {
1671
+ await printProfileRemoveHelp(removeProfileCommand)
1672
+ return
1673
+ }
1674
+ await removeProfile(selector)
1675
+ console.log(`removed_profile: ${selector}`)
1324
1676
  })
1325
1677
 
1678
+ addProvisionCommands(program)
1679
+ addCustodyWorkerCommands(program)
1326
1680
  return program
1327
1681
  }
1328
1682