imtoagent 0.3.23 → 0.3.24

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.
@@ -14,6 +14,7 @@
14
14
 
15
15
  import * as fs from 'fs';
16
16
  import * as path from 'path';
17
+ import * as readline from 'readline';
17
18
  import { spawn, execSync } from 'child_process';
18
19
  import { getDataDir } from '../modules/utils/paths';
19
20
 
@@ -54,6 +55,15 @@ switch (command) {
54
55
  await cmdUpdateBackend(backendType);
55
56
  break;
56
57
  }
58
+ case 'uninstall':
59
+ await cmdUninstall();
60
+ break;
61
+ case 'health':
62
+ await cmdHealth();
63
+ break;
64
+ case 'autostart':
65
+ await cmdAutostart();
66
+ break;
57
67
  case undefined: {
58
68
  const dataDir = getDataDir();
59
69
  const configPath = path.join(dataDir, 'config.json');
@@ -108,6 +118,12 @@ Usage:
108
118
  imtoagent update-system Upgrade imtoagent itself
109
119
  imtoagent update-backend Upgrade current Bot's backend
110
120
  imtoagent update-backend TYPE Upgrade specific backend (codex|claude|opencode)
121
+ imtoagent uninstall Uninstall imtoagent (keep data by default)
122
+ imtoagent uninstall --purge Uninstall and delete all data
123
+ imtoagent health Run comprehensive health check
124
+ imtoagent autostart enable Enable auto-start on login (launchd)
125
+ imtoagent autostart disable Disable auto-start
126
+ imtoagent autostart status Check auto-start status
111
127
 
112
128
  Data directory: ${getDataDir()}
113
129
  `);
@@ -223,6 +239,9 @@ echo $PID`;
223
239
  process.exit(1);
224
240
  }
225
241
 
242
+ // Non-blocking version check
243
+ checkForUpdates().then(printUpdateHint).catch(() => {});
244
+
226
245
  // Explicitly exit — Bun may keep event loop alive due to inherited stdio
227
246
  process.exit(0);
228
247
  }
@@ -616,3 +635,534 @@ async function cmdUpdateBackend(backendType?: 'claude' | 'codex' | 'opencode'):
616
635
  process.exit(1);
617
636
  }
618
637
  }
638
+
639
+ // ================================================================
640
+ // uninstall — remove imtoagent
641
+ // ================================================================
642
+ async function cmdUninstall(): Promise<void> {
643
+ const dataDir = getDataDir();
644
+ const purge = process.argv.includes('--purge');
645
+ const force = process.argv.includes('--force') || process.argv.includes('-f');
646
+
647
+ console.log('\n🗑️ imtoagent Uninstall');
648
+ console.log(` Data directory: ${dataDir}`);
649
+
650
+ if (!purge && !force) {
651
+ console.log('\n ⚠️ This will uninstall the imtoagent npm package but KEEP your data.');
652
+ console.log(` Your data in ${dataDir} will be preserved.`);
653
+ console.log('');
654
+ console.log(' To also delete all data, use: imtoagent uninstall --purge');
655
+ console.log('');
656
+ console.log(' Continue? [y/N]');
657
+ } else if (purge && !force) {
658
+ console.log('\n 🔴 This will DELETE all imtoagent data and the npm package.');
659
+ console.log(` The following will be permanently removed:`);
660
+ console.log(` - Data directory: ${dataDir}`);
661
+ console.log(' - npm global package: imtoagent');
662
+ console.log('');
663
+ console.log(' Continue? [y/N]');
664
+ } else {
665
+ console.log(' --force: skipping confirmation\n');
666
+ }
667
+
668
+ // Confirmation
669
+ if (!force) {
670
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
671
+ const answer = await new Promise<string>((resolve) => {
672
+ rl.question(' > ', resolve);
673
+ }).finally(() => rl.close());
674
+ if (answer.trim().toLowerCase() !== 'y' && answer.trim().toLowerCase() !== 'yes') {
675
+ console.log('❌ Uninstall cancelled.');
676
+ return;
677
+ }
678
+ }
679
+
680
+ // Step 1: Stop gateway if running
681
+ if (fs.existsSync(PID_FILE)) {
682
+ const pid = parseInt(fs.readFileSync(PID_FILE, 'utf-8').trim());
683
+ try {
684
+ process.kill(pid, 0);
685
+ console.log(`⏹ Stopping gateway (PID=${pid})...`);
686
+ process.kill(pid, 'SIGTERM');
687
+ for (let i = 0; i < 10; i++) {
688
+ try { process.kill(pid, 0); await new Promise(r => setTimeout(r, 500)); } catch { break; }
689
+ }
690
+ try { process.kill(pid, 0); process.kill(pid, 'SIGKILL'); } catch {}
691
+ console.log('✅ Gateway stopped');
692
+ } catch {}
693
+ try { fs.unlinkSync(PID_FILE); } catch {}
694
+ } else {
695
+ console.log('ℹ️ Gateway is not running');
696
+ }
697
+
698
+ // Step 2: Remove launchd plist
699
+ const plistPath = path.join(process.env.HOME || '', 'Library', 'LaunchAgents', 'com.imtoagent.gateway.plist');
700
+ if (fs.existsSync(plistPath)) {
701
+ try {
702
+ execSync(`launchctl unload "${plistPath}" 2>/dev/null`, { encoding: 'utf-8' });
703
+ console.log('⏹ Launchd service unloaded');
704
+ } catch {}
705
+ try {
706
+ fs.unlinkSync(plistPath);
707
+ console.log('🗑️ Launchd plist removed');
708
+ } catch {}
709
+ } else {
710
+ console.log('ℹ️ No launchd service configured');
711
+ }
712
+
713
+ // Step 3: Uninstall npm package
714
+ console.log('\n📦 Uninstalling npm package...');
715
+ try {
716
+ execSync('npm uninstall -g imtoagent', {
717
+ encoding: 'utf-8',
718
+ stdio: 'inherit',
719
+ timeout: 30000,
720
+ });
721
+ console.log('✅ npm package uninstalled');
722
+ } catch (e: any) {
723
+ console.error(`⚠️ npm uninstall may have failed: ${e.message}`);
724
+ }
725
+
726
+ // Step 4: Purge data if requested
727
+ if (purge) {
728
+ console.log(`\n🔥 Deleting data directory: ${dataDir}`);
729
+ try {
730
+ execSync(`rm -rf "${dataDir}"`, { encoding: 'utf-8', timeout: 10000 });
731
+ console.log('✅ Data directory deleted');
732
+ } catch (e: any) {
733
+ console.error(`⚠️ Failed to delete data directory: ${e.message}`);
734
+ console.error(` Please remove manually: rm -rf "${dataDir}"`);
735
+ }
736
+ } else {
737
+ console.log(`\n💾 Data preserved at: ${dataDir}`);
738
+ console.log(' To remove it later: rm -rf ~/.imtoagent');
739
+ }
740
+
741
+ console.log('\n✅ imtoagent uninstalled');
742
+ }
743
+
744
+ // ================================================================
745
+ // health — comprehensive health check
746
+ // ================================================================
747
+ async function cmdHealth(): Promise<void> {
748
+ const dataDir = getDataDir();
749
+ const configPath = path.join(dataDir, 'config.json');
750
+ const logFile = path.join(dataDir, 'logs', 'imtoagent.log');
751
+
752
+ console.log(`\n🔍 imtoagent Health Check`);
753
+ console.log(` Data directory: ${dataDir}`);
754
+ console.log(` Time: ${new Date().toISOString()}\n`);
755
+
756
+ let errorCount = 0;
757
+ let warnCount = 0;
758
+
759
+ // ---- 1. Gateway Process ----
760
+ console.log('── Gateway Process ──');
761
+ if (fs.existsSync(PID_FILE)) {
762
+ const pid = parseInt(fs.readFileSync(PID_FILE, 'utf-8').trim());
763
+ try {
764
+ process.kill(pid, 0);
765
+ console.log(` ✅ Running (PID=${pid})`);
766
+ } catch {
767
+ console.log(` ❌ PID file exists but process ${pid} is gone (stale)`);
768
+ errorCount++;
769
+ }
770
+ } else {
771
+ console.log(` ⏸ Not running`);
772
+ }
773
+
774
+ // ---- 2. Configuration ----
775
+ console.log('\n── Configuration ──');
776
+ if (fs.existsSync(configPath)) {
777
+ try {
778
+ const cfg = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
779
+ console.log(` ✅ config.json parse OK`);
780
+
781
+ // Check required fields
782
+ if (!cfg.bots || !Array.isArray(cfg.bots) || cfg.bots.length === 0) {
783
+ console.log(` ⚠️ No bots configured`);
784
+ warnCount++;
785
+ } else {
786
+ console.log(` ✅ ${cfg.bots.length} Bot(s) configured`);
787
+ for (const bot of cfg.bots) {
788
+ const issues: string[] = [];
789
+ if (!bot.name) issues.push('missing name');
790
+ if (!bot.im) issues.push('missing IM platform');
791
+ if (!bot.backend) issues.push('missing backend');
792
+ if (issues.length > 0) {
793
+ console.log(` ⚠️ Bot "${bot.name || '?'}": ${issues.join(', ')}`);
794
+ warnCount++;
795
+ } else {
796
+ console.log(` ✅ ${bot.name} (${bot.im} + ${bot.backend})`);
797
+ }
798
+ }
799
+ }
800
+
801
+ // Check providers.json
802
+ const providersPath = path.join(dataDir, 'providers.json');
803
+ if (fs.existsSync(providersPath)) {
804
+ try {
805
+ const prov = JSON.parse(fs.readFileSync(providersPath, 'utf-8'));
806
+ console.log(` ✅ providers.json parse OK`);
807
+ // Check for placeholder API keys
808
+ const provStr = JSON.stringify(prov);
809
+ if (provStr.includes('YOUR_') || provStr.includes('sk-xxx') || provStr.includes('placehold')) {
810
+ console.log(` ⚠️ providers.json may contain placeholder API keys`);
811
+ warnCount++;
812
+ }
813
+ } catch {
814
+ console.log(` ❌ providers.json parse error`);
815
+ errorCount++;
816
+ }
817
+ } else {
818
+ console.log(` ⚠️ providers.json not found`);
819
+ warnCount++;
820
+ }
821
+ } catch {
822
+ console.log(` ❌ config.json parse error`);
823
+ errorCount++;
824
+ }
825
+ } else {
826
+ console.log(` ❌ config.json not found (run "imtoagent setup")`);
827
+ errorCount++;
828
+ }
829
+
830
+ // ---- 3. Backend Status ----
831
+ console.log('\n── Backend Status ──');
832
+ try {
833
+ const { checkBackend } = await import('../modules/utils/backend-check');
834
+ if (fs.existsSync(configPath)) {
835
+ const cfg = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
836
+ const checkedTypes = new Set<string>();
837
+ for (const bot of cfg.bots || []) {
838
+ if (bot.backend && ['claude', 'codex', 'opencode'].includes(bot.backend) && !checkedTypes.has(bot.backend)) {
839
+ checkedTypes.add(bot.backend);
840
+ const info = checkBackend(bot.backend as any);
841
+ if (info.installed) {
842
+ console.log(` ✅ ${info.label} v${info.version} (${info.installSource})`);
843
+ } else {
844
+ console.log(` ❌ ${info.label} not installed → ${info.installHint}`);
845
+ errorCount++;
846
+ }
847
+ }
848
+ }
849
+ if (checkedTypes.size === 0) {
850
+ console.log(` ℹ️ No backends to check`);
851
+ }
852
+ }
853
+ } catch {
854
+ console.log(` ⚠️ Backend check skipped`);
855
+ warnCount++;
856
+ }
857
+
858
+ // ---- 4. Proxy Port ----
859
+ console.log('\n── Proxy Port (:18899) ──');
860
+ try {
861
+ const net = await import('net');
862
+ const checkPort = (port: number): Promise<boolean> => {
863
+ return new Promise((resolve) => {
864
+ const socket = new net.Socket();
865
+ socket.setTimeout(2000);
866
+ socket.on('connect', () => { socket.destroy(); resolve(true); });
867
+ socket.on('error', () => resolve(false));
868
+ socket.on('timeout', () => { socket.destroy(); resolve(false); });
869
+ socket.connect(port, '127.0.0.1');
870
+ });
871
+ };
872
+ const reachable = await checkPort(18899);
873
+ if (reachable) {
874
+ console.log(` ✅ Port 18899 is reachable`);
875
+ } else {
876
+ // Check if port is in use by another process
877
+ try {
878
+ const lsofOut = execSync(`lsof -i :18899 2>/dev/null`, { encoding: 'utf-8', timeout: 3000 }).trim();
879
+ if (lsofOut) {
880
+ console.log(` ❌ Port 18899 is occupied by another process:`);
881
+ lsofOut.split('\n').forEach(line => console.log(` ${line}`));
882
+ } else {
883
+ console.log(` ⏸ Port 18899 is free (gateway not running)`);
884
+ }
885
+ } catch {
886
+ console.log(` ⏸ Port 18899 is free (gateway not running)`);
887
+ }
888
+ }
889
+ } catch {
890
+ console.log(` ⚠️ Port check failed`);
891
+ warnCount++;
892
+ }
893
+
894
+ // ---- 5. Recent Log Errors ----
895
+ console.log('\n── Recent Log Errors ──');
896
+ if (fs.existsSync(logFile)) {
897
+ const stats = fs.statSync(logFile);
898
+ const size = stats.size > 1024 * 1024
899
+ ? (stats.size / (1024 * 1024)).toFixed(1) + ' MB'
900
+ : (stats.size / 1024).toFixed(1) + ' KB';
901
+ console.log(` Log: ${size}`);
902
+
903
+ // Read last 50 lines looking for ERROR/WARN
904
+ try {
905
+ const content = fs.readFileSync(logFile, 'utf-8');
906
+ const lines = content.split('\n');
907
+ const recent = lines.slice(-50);
908
+ const errors = recent.filter(l => l.includes('ERROR') || l.includes('[error]'));
909
+ const warns = recent.filter(l => l.includes('WARN') || l.includes('[warn]'));
910
+
911
+ if (errors.length > 0) {
912
+ console.log(` ❌ ${errors.length} recent ERROR(s):`);
913
+ for (const line of errors.slice(-3)) {
914
+ console.log(` ${line.trim().slice(0, 120)}`);
915
+ }
916
+ errorCount++;
917
+ } else {
918
+ console.log(` ✅ No recent errors`);
919
+ }
920
+ if (warns.length > 0 && errors.length === 0) {
921
+ console.log(` ℹ️ ${warns.length} recent warning(s) (non-critical)`);
922
+ }
923
+ } catch {
924
+ console.log(` ⚠️ Could not read log file`);
925
+ warnCount++;
926
+ }
927
+ } else {
928
+ console.log(` ℹ️ No log file yet`);
929
+ }
930
+
931
+ // ---- Summary ----
932
+ console.log('\n── Summary ──');
933
+ if (errorCount === 0 && warnCount === 0) {
934
+ console.log(` ✅ All checks passed!`);
935
+ } else {
936
+ if (errorCount > 0) console.log(` ❌ ${errorCount} error(s)`);
937
+ if (warnCount > 0) console.log(` ⚠️ ${warnCount} warning(s)`);
938
+ }
939
+ console.log();
940
+ }
941
+
942
+ // ================================================================
943
+ // autostart — launchd integration (macOS only)
944
+ // ================================================================
945
+ async function cmdAutostart(): Promise<void> {
946
+ const subcommand = process.argv[3];
947
+ const home = process.env.HOME || '';
948
+ const plistDir = path.join(home, 'Library', 'LaunchAgents');
949
+ const plistPath = path.join(plistDir, 'com.imtoagent.gateway.plist');
950
+
951
+ if (process.platform !== 'darwin') {
952
+ console.error('❌ autostart is only supported on macOS (launchd)');
953
+ process.exit(1);
954
+ }
955
+
956
+ switch (subcommand) {
957
+ case 'enable':
958
+ await cmdAutostartEnable(plistPath, plistDir, home);
959
+ break;
960
+ case 'disable':
961
+ await cmdAutostartDisable(plistPath);
962
+ break;
963
+ case 'status':
964
+ await cmdAutostartStatus(plistPath);
965
+ break;
966
+ default:
967
+ console.log('Usage:');
968
+ console.log(' imtoagent autostart enable Enable auto-start on login');
969
+ console.log(' imtoagent autostart disable Disable auto-start');
970
+ console.log(' imtoagent autostart status Check auto-start status');
971
+ }
972
+ }
973
+
974
+ async function cmdAutostartEnable(plistPath: string, plistDir: string, home: string): Promise<void> {
975
+ const dataDir = getDataDir();
976
+ const binPath = execSync('command -v imtoagent', { encoding: 'utf-8', timeout: 3000 }).trim();
977
+
978
+ if (!binPath) {
979
+ console.error('❌ Cannot find imtoagent binary');
980
+ process.exit(1);
981
+ }
982
+
983
+ // Find node and bun paths for launchd (which has minimal PATH)
984
+ const nodePath = execSync('which node', { encoding: 'utf-8', timeout: 3000 }).trim();
985
+ const bunPath = execSync('which bun', { encoding: 'utf-8', timeout: 3000 }).trim();
986
+ const launchdPATH = `${path.dirname(nodePath)}:${path.dirname(bunPath)}:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:/opt/homebrew/bin:/opt/homebrew/opt/node@24/bin`;
987
+
988
+ const plist = `<?xml version="1.0" encoding="UTF-8"?>
989
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
990
+ <plist version="1.0">
991
+ <dict>
992
+ <key>Label</key>
993
+ <string>com.imtoagent.gateway</string>
994
+ <key>ProgramArguments</key>
995
+ <array>
996
+ <string>${binPath}</string>
997
+ <string>daemon</string>
998
+ </array>
999
+ <key>WorkingDirectory</key>
1000
+ <string>${dataDir}</string>
1001
+ <key>EnvironmentVariables</key>
1002
+ <dict>
1003
+ <key>IMTOAGENT_HOME</key>
1004
+ <string>${dataDir}</string>
1005
+ <key>PATH</key>
1006
+ <string>${launchdPATH}</string>
1007
+ </dict>
1008
+ <key>StandardOutPath</key>
1009
+ <string>${path.join(dataDir, 'logs', 'launchd.log')}</string>
1010
+ <key>StandardErrorPath</key>
1011
+ <string>${path.join(dataDir, 'logs', 'launchd-error.log')}</string>
1012
+ <key>RunAtLoad</key>
1013
+ <true/>
1014
+ <key>KeepAlive</key>
1015
+ <dict>
1016
+ <key>SuccessfulExit</key>
1017
+ <false/>
1018
+ </dict>
1019
+ <key>ThrottleInterval</key>
1020
+ <integer>30</integer>
1021
+ <key>ProcessType</key>
1022
+ <string>Background</string>
1023
+ </dict>
1024
+ </plist>
1025
+ `;
1026
+
1027
+ fs.mkdirSync(plistDir, { recursive: true });
1028
+ fs.writeFileSync(plistPath, plist);
1029
+ console.log(`✅ Plist written: ${plistPath}`);
1030
+
1031
+ // Unload existing (if any) then reload
1032
+ try { execSync(`launchctl unload "${plistPath}" 2>/dev/null`, { encoding: 'utf-8' }); } catch {}
1033
+
1034
+ try {
1035
+ execSync(`launchctl load "${plistPath}"`, { encoding: 'utf-8', timeout: 5000 });
1036
+ console.log('✅ launchd service loaded');
1037
+ } catch (e: any) {
1038
+ console.error(`⚠️ launchctl load failed: ${e.message}`);
1039
+ console.error(' Plist written. Manual: launchctl load ' + plistPath);
1040
+ }
1041
+
1042
+ console.log(`\n📌 Auto-start enabled. Gateway starts on login.`);
1043
+ console.log(` Logs: ${path.join(dataDir, 'logs', 'launchd.log')}`);
1044
+ }
1045
+
1046
+ async function cmdAutostartDisable(plistPath: string): Promise<void> {
1047
+ if (!fs.existsSync(plistPath)) {
1048
+ console.log('ℹ️ No autostart configured');
1049
+ return;
1050
+ }
1051
+
1052
+ try {
1053
+ execSync(`launchctl unload "${plistPath}" 2>/dev/null`, { encoding: 'utf-8', timeout: 5000 });
1054
+ console.log('✅ launchd service unloaded');
1055
+ } catch {
1056
+ console.log('ℹ️ Service was not loaded');
1057
+ }
1058
+
1059
+ try {
1060
+ fs.unlinkSync(plistPath);
1061
+ console.log('✅ Plist removed');
1062
+ } catch (e: any) {
1063
+ console.error(`⚠️ Failed to remove plist: ${e.message}`);
1064
+ }
1065
+
1066
+ console.log('\n📌 Auto-start disabled');
1067
+ }
1068
+
1069
+ async function cmdAutostartStatus(plistPath: string): Promise<void> {
1070
+ const exists = fs.existsSync(plistPath);
1071
+ console.log(`\n📌 Autostart Status`);
1072
+ console.log(` Plist file: ${plistPath}`);
1073
+ console.log(` Installed: ${exists ? '✅ Yes' : '❌ No'}`);
1074
+
1075
+ if (exists) {
1076
+ try {
1077
+ const out = execSync(`launchctl list | grep com.imtoagent`, { encoding: 'utf-8', timeout: 3000 }).trim();
1078
+ console.log(` Running: ${out ? '✅ Yes' : '❌ No (plist exists but not loaded)'}`);
1079
+ if (out) console.log(` ${out}`);
1080
+ } catch {
1081
+ console.log(` Running: ❌ No`);
1082
+ }
1083
+ }
1084
+ console.log();
1085
+ }
1086
+
1087
+ // ================================================================
1088
+ // version-check — non-blocking npm registry check
1089
+ // ================================================================
1090
+
1091
+ /**
1092
+ * Check if a newer version is available on npm.
1093
+ * Returns null if check fails or no update available.
1094
+ * Caches result for 24 hours.
1095
+ */
1096
+ async function checkForUpdates(): Promise<string | null> {
1097
+ const dataDir = getDataDir();
1098
+ const cacheFile = path.join(dataDir, '.last-version-check');
1099
+ const pkg = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf-8'));
1100
+ const currentVer = pkg.version;
1101
+
1102
+ // Check cache (24h TTL)
1103
+ try {
1104
+ if (fs.existsSync(cacheFile)) {
1105
+ const cached = JSON.parse(fs.readFileSync(cacheFile, 'utf-8'));
1106
+ const ageMs = Date.now() - cached.timestamp;
1107
+ if (ageMs < 24 * 60 * 60 * 1000) {
1108
+ // Cache hit — return stored result
1109
+ if (cached.latest && cached.latest !== currentVer) {
1110
+ return cached.latest;
1111
+ }
1112
+ return null;
1113
+ }
1114
+ }
1115
+ } catch {}
1116
+
1117
+ // Fetch from npm registry (3s timeout, npmmirror for China)
1118
+ try {
1119
+ const http = await import('http');
1120
+ const https = await import('https');
1121
+
1122
+ const fetchJson = (url: string): Promise<any> => {
1123
+ return new Promise((resolve, reject) => {
1124
+ const client = url.startsWith('https') ? https : http;
1125
+ const req = client.get(url, { timeout: 3000 }, (res) => {
1126
+ let data = '';
1127
+ res.on('data', chunk => data += chunk);
1128
+ res.on('end', () => {
1129
+ try { resolve(JSON.parse(data)); } catch { reject(new Error('parse error')); }
1130
+ });
1131
+ });
1132
+ req.on('error', reject);
1133
+ req.on('timeout', () => { req.destroy(); reject(new Error('timeout')); });
1134
+ });
1135
+ };
1136
+
1137
+ // Try npmmirror first (China-friendly), fallback to npmjs
1138
+ let result: any;
1139
+ try {
1140
+ result = await fetchJson('https://registry.npmmirror.com/imtoagent');
1141
+ } catch {
1142
+ result = await fetchJson('https://registry.npmjs.org/imtoagent');
1143
+ }
1144
+
1145
+ const latest = result['dist-tags']?.latest;
1146
+ if (latest && latest !== currentVer) {
1147
+ // Cache the result
1148
+ fs.writeFileSync(cacheFile, JSON.stringify({ latest, timestamp: Date.now() }));
1149
+ return latest;
1150
+ }
1151
+
1152
+ // Cache "no update" result too
1153
+ fs.writeFileSync(cacheFile, JSON.stringify({ latest: currentVer, timestamp: Date.now() }));
1154
+ return null;
1155
+ } catch {
1156
+ // Silently fail — non-blocking
1157
+ return null;
1158
+ }
1159
+ }
1160
+
1161
+ /** Print version update hint if available */
1162
+ function printUpdateHint(latestVer: string | null): void {
1163
+ if (latestVer) {
1164
+ const pkg = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf-8'));
1165
+ console.log(`\n⬆️ New version available: ${pkg.version} → ${latestVer}`);
1166
+ console.log(` Upgrade: imtoagent update-system`);
1167
+ }
1168
+ }
@@ -91,10 +91,14 @@ async function selectMenu(title: string, options: string[]): Promise<number> {
91
91
  }
92
92
 
93
93
  // ================================================================
94
- // Text input (Enter confirm, ESC returns -1)
94
+ // Text input (Enter confirm, ESC returns null)
95
95
  // ================================================================
96
96
 
97
- async function promptText(label: string, defaultValue = ''): Promise<string> {
97
+ /**
98
+ * Prompt the user for text input.
99
+ * @returns The entered string, or `null` if user pressed ESC.
100
+ */
101
+ async function promptText(label: string, defaultValue = ''): Promise<string | null> {
98
102
  const buf: string[] = [];
99
103
  const defaultHint = defaultValue ? ` [${defaultValue}]` : '';
100
104
 
@@ -110,7 +114,7 @@ async function promptText(label: string, defaultValue = ''): Promise<string> {
110
114
  break;
111
115
  } else if (key === KEY.ESC) {
112
116
  process.stdout.write('\x1B[0K\n');
113
- return -1 as unknown as string; // Special return value for ESC
117
+ return null;
114
118
  } else if (key === KEY.BACKSPACE) {
115
119
  if (buf.length > 0) {
116
120
  buf.pop();
@@ -377,8 +381,18 @@ export async function runSetupWizard(options?: SetupOptions): Promise<void> {
377
381
  // 3b: Auto-generate Bot name, customizable
378
382
  const defaultName = IM_PLATFORMS[imIdx].label + 'Bot';
379
383
  const nameInput = await promptText('Bot name', defaultName);
380
- if ((nameInput as any) === -1) { if (bots.length === 0) return; break; } // ESC
381
- const botName = nameInput || defaultName; // Use default if empty
384
+ if (nameInput === null) { if (bots.length === 0) return; break; } // ESC
385
+ const botName = nameInput || defaultName;
386
+
387
+ // Validate Bot name — no whitespace-only, no dangerous characters
388
+ if (!botName || !/^\S/.test(botName)) {
389
+ console.log('⚠️ Bot name must not be empty or whitespace-only. Using default.');
390
+ }
391
+ // Sanitize: remove characters that would break directory names
392
+ const sanitized = botName.replace(/[^\w\s.-]/g, '');
393
+ if (sanitized !== botName) {
394
+ console.log(`⚠️ Bot name sanitized: "${botName}" → "${sanitized}"`);
395
+ }
382
396
 
383
397
  // 3c: Select backend
384
398
  const backendLabels = backendStatus.map(b =>
@@ -419,14 +433,24 @@ export async function runSetupWizard(options?: SetupOptions): Promise<void> {
419
433
 
420
434
  for (const field of fields) {
421
435
  const val = await promptText(field.label + (field.required ? '' : ' (optional)'));
422
- if ((val as any) === -1) { credentials._escaped = 'true'; break; } // ESC
436
+ if (val === null) { credentials._escaped = 'true'; break; } // ESC
423
437
  credentials[field.key] = val;
424
438
  }
425
439
  if (credentials._escaped) continue; // ESC go back and re-select backend
426
440
 
427
- // 3e: Working directory
428
- const cwd = await promptText('Working directory', os.homedir());
429
- if ((cwd as any) === -1) continue;
441
+ // 3e: Working directory (validated)
442
+ let cwd = await promptText('Working directory', os.homedir());
443
+ if (cwd === null) continue;
444
+ cwd = cwd.trim() || os.homedir();
445
+
446
+ // Validate path — reject obviously invalid / dangerous paths
447
+ const badPaths = ['/dev/null', '/dev/zero', '/dev/random', '/etc/passwd', '/etc/shadow', '/System'];
448
+ if (badPaths.some(bp => cwd === bp || cwd.startsWith(bp + '/'))) {
449
+ console.log(`⚠️ Invalid path "${cwd}". Using home directory instead.`);
450
+ cwd = os.homedir();
451
+ }
452
+ // Resolve to absolute path
453
+ cwd = path.resolve(cwd);
430
454
 
431
455
  // Generate unique ID (UUID, for directory isolation, renaming doesn't affect it)
432
456
  const botId = randomUUID();
@@ -509,14 +533,22 @@ export async function runSetupWizard(options?: SetupOptions): Promise<void> {
509
533
 
510
534
  const wsLabels = ['Sandbox mode (isolated per Bot)', 'Global mode (shared root path)'];
511
535
  const wsIdx = await selectMenu('Select workspace mode', wsLabels);
512
- if (wsIdx === -1) { console.log('\n👋 Cancelled'); process.exit(0); }
536
+ if (wsIdx === -1) { console.log('\n👋 Cancelled'); process.exit(0); }
513
537
  workspaceMode = wsIdx === 1 ? 'global' : 'sandbox';
514
538
 
515
539
  if (workspaceMode === 'global') {
516
540
  const defaultGlobal = os.homedir() + '/imtoagent-workspace';
517
541
  const gpInput = await promptText('Global workspace root path', defaultGlobal);
518
- if ((gpInput as any) === -1) { console.log('\n👋 Cancelled'); process.exit(0); }
519
- workspaceGlobalPath = (gpInput || defaultGlobal).trim();
542
+ if (gpInput === null) { console.log('\n👋 Cancelled'); process.exit(0); }
543
+ let resolved = (gpInput || defaultGlobal).trim();
544
+
545
+ // Validate path
546
+ const badPaths = ['/dev/null', '/dev/zero', '/dev/random', '/etc', '/System', '/usr'];
547
+ if (badPaths.some(bp => resolved === bp || resolved.startsWith(bp + '/'))) {
548
+ console.log(`⚠️ Invalid path "${resolved}". Using default instead.`);
549
+ resolved = defaultGlobal;
550
+ }
551
+ workspaceGlobalPath = path.resolve(resolved);
520
552
  }
521
553
 
522
554
  const home = process.env.HOME || process.env.USERPROFILE?.replace(/\\/g, '/') || '';
@@ -573,19 +605,27 @@ export async function runSetupWizard(options?: SetupOptions): Promise<void> {
573
605
  console.log(` Format: ${preset.format}`);
574
606
  console.log(` Models: ${preset.models.join(', ')}\n`);
575
607
 
576
- // Confirm/edit short name
608
+ // Confirm/edit short name (validate — must be safe JSON key)
577
609
  const nameEdit = await promptText('Provider name (leave blank to confirm)', provName);
578
- if ((nameEdit as any) === -1) continue;
610
+ if (nameEdit === null) continue;
579
611
  provName = nameEdit || provName;
612
+ // Sanitize provider name: only alphanumeric, hyphens, underscores
613
+ const sanitizedProv = provName.replace(/[^a-zA-Z0-9_-]/g, '');
614
+ if (!sanitizedProv) {
615
+ console.log('⚠️ Invalid provider name. Using original.');
616
+ } else if (sanitizedProv !== provName) {
617
+ console.log(`⚠️ Provider name sanitized: "${provName}" → "${sanitizedProv}"`);
618
+ provName = sanitizedProv;
619
+ }
580
620
 
581
621
  // Confirm/edit Base URL
582
622
  const urlEdit = await promptText('Base URL', baseUrl);
583
- if ((urlEdit as any) === -1) continue;
623
+ if (urlEdit === null) continue;
584
624
  baseUrl = urlEdit || baseUrl;
585
625
 
586
626
  // Confirm/edit model list
587
627
  const modelsEdit = await promptText('Model list (comma-separated)', models.join(', '));
588
- if ((modelsEdit as any) === -1) continue;
628
+ if (modelsEdit === null) continue;
589
629
  if (modelsEdit) models = modelsEdit.split(',').map(s => s.trim()).filter(Boolean);
590
630
 
591
631
  if (providers[provName]) {
@@ -593,17 +633,18 @@ export async function runSetupWizard(options?: SetupOptions): Promise<void> {
593
633
  }
594
634
  } else {
595
635
  // Custom
596
- provName = await promptText('Provider name (e.g. deepseek, dashscope)');
597
- if ((provName as any) === -1) { addingProviders = false; continue; }
636
+ let customName = await promptText('Provider name (e.g. deepseek, dashscope)');
637
+ if (customName === null) { addingProviders = false; continue; }
638
+ provName = customName.trim().toLowerCase().replace(/[^a-zA-Z0-9_-]/g, '');
598
639
  if (!provName) { addingProviders = false; continue; }
599
640
  if (providers[provName]) {
600
641
  console.log(`⚠️ Provider "${provName}" already exists, will overwrite\n`);
601
642
  }
602
643
 
603
644
  baseUrl = await promptText('Base URL (e.g. https://api.deepseek.com/v1)');
604
- if ((baseUrl as any) === -1) continue;
645
+ if (baseUrl === null) continue;
605
646
  const modelsStr = await promptText('Model list (comma-separated)');
606
- if ((modelsStr as any) === -1) continue;
647
+ if (modelsStr === null) continue;
607
648
  models = (modelsStr || '').split(',').map(s => s.trim()).filter(Boolean);
608
649
 
609
650
  const formatIdx = await selectMenu('API format', ['openai', 'anthropic']);
@@ -613,14 +654,14 @@ export async function runSetupWizard(options?: SetupOptions): Promise<void> {
613
654
 
614
655
  // API Key (required for all providers)
615
656
  const apiKey = await promptText('API Key');
616
- if ((apiKey as any) === -1) continue;
657
+ if (apiKey === null) continue;
617
658
  if (!apiKey) {
618
659
  console.log('⚠️ API Key is empty, this provider will be temporarily unavailable\n');
619
660
  }
620
661
 
621
662
  // Pricing (optional)
622
663
  const priceInput = await promptText('Pricing (in/out per million tokens, e.g. 0.55,2.19, leave blank to skip)');
623
- if ((priceInput as any) === -1) continue;
664
+ if (priceInput === null) continue;
624
665
 
625
666
  const pricing: any = {};
626
667
  if (priceInput) {
@@ -663,10 +704,10 @@ export async function runSetupWizard(options?: SetupOptions): Promise<void> {
663
704
  if (allModels.length > 0) {
664
705
  const existingDefault = existingConfig?.defaultModel || allModels[0];
665
706
  const val = await promptText('Default model', existingDefault);
666
- defaultModel = (val as any) === -1 ? existingDefault : (val || existingDefault);
707
+ defaultModel = val === null ? existingDefault : (val || existingDefault);
667
708
  } else {
668
- defaultModel = await promptText('Default model (provider/model)') || 'deepseek/deepseek-v4-pro';
669
- if ((defaultModel as any) === -1) defaultModel = 'deepseek/deepseek-v4-pro';
709
+ const val = await promptText('Default model (provider/model)');
710
+ defaultModel = val === null ? 'deepseek/deepseek-v4-pro' : (val || 'deepseek/deepseek-v4-pro');
670
711
  }
671
712
 
672
713
  // ===== Step 7: Generate soul files =====
@@ -719,6 +760,8 @@ export async function runSetupWizard(options?: SetupOptions): Promise<void> {
719
760
 
720
761
  fs.mkdirSync(dataDir, { recursive: true });
721
762
 
763
+ // Atomic config write: write to temp file first, then rename.
764
+ // If the write fails, the original config (if any) is preserved.
722
765
  const config: any = {
723
766
  system: existingConfig?.system || {
724
767
  defaultProjectDir: os.homedir(),
@@ -758,19 +801,42 @@ export async function runSetupWizard(options?: SetupOptions): Promise<void> {
758
801
  bots,
759
802
  };
760
803
 
761
- fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
762
- console.log(`✅ ${configPath}`);
804
+ // Write to temp file first for atomicity
805
+ const configTmpPath = configPath + '.tmp';
806
+ const providersTmpPath = path.join(dataDir, 'providers.json.tmp');
807
+ let writeOk = true;
763
808
 
764
- const providersFile: any = { providers, defaultModel, modelAliases: config.modelAliases };
765
- const providersPath = path.join(dataDir, 'providers.json');
766
- fs.writeFileSync(providersPath, JSON.stringify(providersFile, null, 2) + '\n');
767
- console.log(`✅ ${providersPath}`);
809
+ try {
810
+ fs.writeFileSync(configTmpPath, JSON.stringify(config, null, 2) + '\n');
811
+ fs.renameSync(configTmpPath, configPath);
812
+ console.log(`✅ ${configPath}`);
813
+ } catch (e: any) {
814
+ console.error(`❌ Failed to write config.json: ${e.message}`);
815
+ writeOk = false;
816
+ }
768
817
 
769
- const opencodePath = path.join(dataDir, 'opencode.json');
770
- const opencodeTemplate = getTemplatePath('opencode.template.json');
771
- if (fs.existsSync(opencodeTemplate)) {
772
- fs.writeFileSync(opencodePath, fs.readFileSync(opencodeTemplate, 'utf-8'));
773
- console.log(`✅ ${opencodePath}`);
818
+ if (writeOk) {
819
+ try {
820
+ const providersFile: any = { providers, defaultModel, modelAliases: config.modelAliases };
821
+ const providersPath = path.join(dataDir, 'providers.json');
822
+ fs.writeFileSync(providersTmpPath, JSON.stringify(providersFile, null, 2) + '\n');
823
+ fs.renameSync(providersTmpPath, providersPath);
824
+ console.log(`✅ ${providersPath}`);
825
+ } catch (e: any) {
826
+ console.error(`❌ Failed to write providers.json: ${e.message}`);
827
+ console.error('⚠️ config.json was written successfully, but providers.json failed.');
828
+ console.error(' Please re-run "imtoagent setup" to fix.');
829
+ writeOk = false;
830
+ }
831
+ }
832
+
833
+ if (writeOk) {
834
+ const opencodePath = path.join(dataDir, 'opencode.json');
835
+ const opencodeTemplate = getTemplatePath('opencode.template.json');
836
+ if (fs.existsSync(opencodeTemplate)) {
837
+ fs.writeFileSync(opencodePath, fs.readFileSync(opencodeTemplate, 'utf-8'));
838
+ console.log(`✅ ${opencodePath}`);
839
+ }
774
840
  }
775
841
 
776
842
  fs.mkdirSync(path.join(dataDir, 'sessions'), { recursive: true });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "imtoagent",
3
- "version": "0.3.23",
3
+ "version": "0.3.24",
4
4
  "description": "IM ↔ Agent 统一网关 — 飞书/Telegram/微信/企业微信对接 Claude Code/Codex/OpenCode",
5
5
  "type": "module",
6
6
  "bin": {