@miraj181/ipingyou 2.0.14 → 2.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -1
- package/package.json +7 -6
- package/src/cli.js +216 -0
- package/src/lib/ai/groq.js +116 -0
- package/src/lib/ai/safety.js +114 -0
- package/src/lib/allowlist.js +35 -0
- package/src/lib/broker.js +139 -13
- package/src/lib/cleanup.js +14 -6
- package/src/lib/config.js +8 -3
- package/src/lib/path-browser.js +2 -2
- package/src/lib/platform.js +0 -31
- package/src/lib/session-log.js +93 -0
- package/src/modes/ai.js +627 -0
- package/src/modes/client.js +288 -31
- package/src/modes/doctor.js +293 -0
- package/src/modes/host.js +485 -38
- package/src/server.js +226 -14
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Doctor Mode — non-invasive diagnostics for iPingYou.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { execa, execaCommand } from 'execa';
|
|
6
|
+
import chalk from 'chalk';
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import os from 'node:os';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { commandExists, detectOS } from '../lib/platform.js';
|
|
11
|
+
import { pingBroker } from '../lib/broker.js';
|
|
12
|
+
import { classifyCommand, redactSensitive } from '../lib/ai/safety.js';
|
|
13
|
+
|
|
14
|
+
let BROKER_URL = process.env.BROKER_URL || 'https://ipingyou.onrender.com';
|
|
15
|
+
|
|
16
|
+
const results = [];
|
|
17
|
+
|
|
18
|
+
function record(status, name, detail = '', hint = '') {
|
|
19
|
+
results.push({ status, name, detail, hint });
|
|
20
|
+
const icon = {
|
|
21
|
+
pass: chalk.green('✓'),
|
|
22
|
+
warn: chalk.yellow('!'),
|
|
23
|
+
fail: chalk.red('✗'),
|
|
24
|
+
skip: chalk.dim('-'),
|
|
25
|
+
}[status] || chalk.dim('-');
|
|
26
|
+
|
|
27
|
+
const coloredName = status === 'fail'
|
|
28
|
+
? chalk.red(name)
|
|
29
|
+
: status === 'warn'
|
|
30
|
+
? chalk.yellow(name)
|
|
31
|
+
: status === 'skip'
|
|
32
|
+
? chalk.dim(name)
|
|
33
|
+
: chalk.white(name);
|
|
34
|
+
|
|
35
|
+
console.log(` ${icon} ${coloredName}${detail ? chalk.dim(` — ${detail}`) : ''}`);
|
|
36
|
+
if (hint) console.log(chalk.dim(` ${hint}`));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function check(name, fn) {
|
|
40
|
+
try {
|
|
41
|
+
const outcome = await fn();
|
|
42
|
+
if (typeof outcome === 'string') record('pass', name, outcome);
|
|
43
|
+
else record(outcome.status || 'pass', name, outcome.detail || '', outcome.hint || '');
|
|
44
|
+
} catch (err) {
|
|
45
|
+
record('fail', name, redactSensitive(err.message));
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function commandVersion(command, args = ['--version']) {
|
|
50
|
+
if (!(await commandExists(command))) {
|
|
51
|
+
return { status: 'fail', detail: 'missing', hint: `Install ${command} and ensure it is on PATH.` };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const result = await execa(command, args, { reject: false, timeout: 5000 });
|
|
55
|
+
const output = (result.stdout || result.stderr || '').split(/\r?\n/)[0].trim();
|
|
56
|
+
return { status: 'pass', detail: output || 'found' };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function commandFound(command) {
|
|
60
|
+
if (!(await commandExists(command))) {
|
|
61
|
+
return { status: 'fail', detail: 'missing', hint: `Install ${command} and ensure it is on PATH.` };
|
|
62
|
+
}
|
|
63
|
+
return { status: 'pass', detail: 'found' };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function checkSshService() {
|
|
67
|
+
const osInfo = detectOS();
|
|
68
|
+
if (osInfo.isMac) {
|
|
69
|
+
const launchctl = await execaCommand('launchctl print-disabled system', { reject: false, timeout: 5000 });
|
|
70
|
+
const launchctlOutput = `${launchctl.stdout || ''}\n${launchctl.stderr || ''}`.toLowerCase();
|
|
71
|
+
const launchctlMatch = launchctlOutput.match(/"com\.openssh\.sshd"\s*=>\s*(enabled|disabled)/);
|
|
72
|
+
if (launchctlMatch) {
|
|
73
|
+
if (launchctlMatch[1] === 'enabled') return { status: 'pass', detail: 'Remote Login is enabled' };
|
|
74
|
+
return {
|
|
75
|
+
status: 'warn',
|
|
76
|
+
detail: 'Remote Login appears off',
|
|
77
|
+
hint: 'Enable System Settings → General → Sharing → Remote Login before hosting SSH.',
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const result = await execaCommand('systemsetup -getremotelogin', { reject: false, timeout: 5000 });
|
|
82
|
+
const output = result.stdout || result.stderr || '';
|
|
83
|
+
if (/on/i.test(output)) return { status: 'pass', detail: 'Remote Login is on' };
|
|
84
|
+
if (/off/i.test(output)) {
|
|
85
|
+
return {
|
|
86
|
+
status: 'warn',
|
|
87
|
+
detail: 'Remote Login appears off',
|
|
88
|
+
hint: 'Enable System Settings → General → Sharing → Remote Login before hosting SSH.',
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
return {
|
|
92
|
+
status: 'warn',
|
|
93
|
+
detail: 'Could not determine Remote Login state',
|
|
94
|
+
hint: 'macOS may require admin permission to query this setting.',
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (osInfo.isLinux) {
|
|
99
|
+
const ssh = await execaCommand('systemctl is-active ssh', { reject: false, timeout: 5000 });
|
|
100
|
+
const sshd = ssh.exitCode === 0 ? ssh : await execaCommand('systemctl is-active sshd', { reject: false, timeout: 5000 });
|
|
101
|
+
if (sshd.exitCode === 0) return { status: 'pass', detail: 'SSH service is active' };
|
|
102
|
+
return {
|
|
103
|
+
status: 'warn',
|
|
104
|
+
detail: 'SSH service is not reported active',
|
|
105
|
+
hint: 'Run `sudo systemctl start ssh` or `sudo systemctl start sshd` before hosting.',
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (osInfo.isWindows) {
|
|
110
|
+
const result = await execaCommand('sc query sshd', { reject: false, timeout: 5000 });
|
|
111
|
+
if (/RUNNING/i.test(result.stdout)) return { status: 'pass', detail: 'OpenSSH Server is running' };
|
|
112
|
+
return {
|
|
113
|
+
status: 'warn',
|
|
114
|
+
detail: 'OpenSSH Server is not reported running',
|
|
115
|
+
hint: 'Start OpenSSH Server from Services before hosting.',
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return { status: 'skip', detail: 'unsupported OS check' };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function checkEphemeralKeyParse() {
|
|
123
|
+
if (!(await commandExists('ssh-keygen'))) {
|
|
124
|
+
return { status: 'skip', detail: 'ssh-keygen missing' };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ipingyou-doctor-key-'));
|
|
128
|
+
const keyPath = path.join(dir, 'key');
|
|
129
|
+
try {
|
|
130
|
+
const generated = await execa('ssh-keygen', ['-t', 'ed25519', '-C', 'ipingyou-doctor', '-f', keyPath, '-N', ''], {
|
|
131
|
+
reject: false,
|
|
132
|
+
timeout: 10000,
|
|
133
|
+
});
|
|
134
|
+
if (generated.exitCode !== 0) {
|
|
135
|
+
return { status: 'fail', detail: generated.stderr.trim() || 'ssh-keygen failed' };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const parsed = await execa('ssh-keygen', ['-y', '-f', keyPath], { reject: false, timeout: 10000 });
|
|
139
|
+
if (parsed.exitCode !== 0) {
|
|
140
|
+
return { status: 'fail', detail: parsed.stderr.trim() || 'generated key could not be parsed' };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return { status: 'pass', detail: 'generated ed25519 key parses correctly' };
|
|
144
|
+
} finally {
|
|
145
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function checkConfigDirectory() {
|
|
150
|
+
const configDir = path.join(os.homedir(), '.ipingyou');
|
|
151
|
+
if (!fs.existsSync(configDir)) return { status: 'pass', detail: 'no config directory yet' };
|
|
152
|
+
const stat = fs.statSync(configDir);
|
|
153
|
+
if (!stat.isDirectory()) return { status: 'fail', detail: `${configDir} is not a directory` };
|
|
154
|
+
return { status: 'pass', detail: configDir };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function checkTempDirectory() {
|
|
158
|
+
const tmp = os.tmpdir();
|
|
159
|
+
const probe = path.join(tmp, `ipingyou-doctor-${Date.now()}`);
|
|
160
|
+
fs.writeFileSync(probe, 'ok', { mode: 0o600 });
|
|
161
|
+
fs.unlinkSync(probe);
|
|
162
|
+
return { status: 'pass', detail: tmp };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function checkMacPrivacyHints() {
|
|
166
|
+
if (process.platform !== 'darwin') return { status: 'skip', detail: 'not macOS' };
|
|
167
|
+
const protectedDirs = ['Downloads', 'Desktop', 'Documents']
|
|
168
|
+
.map(name => path.join(os.homedir(), name))
|
|
169
|
+
.filter(dir => fs.existsSync(dir));
|
|
170
|
+
|
|
171
|
+
if (protectedDirs.length === 0) return { status: 'skip', detail: 'no standard protected folders found' };
|
|
172
|
+
return {
|
|
173
|
+
status: 'skip',
|
|
174
|
+
detail: 'macOS protected folder note',
|
|
175
|
+
hint: 'Prefer the iPingYou drop folder, or grant Full Disk Access to sshd/Remote Login.',
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function checkAiSafety() {
|
|
180
|
+
const cases = [
|
|
181
|
+
['pwd', false],
|
|
182
|
+
['cat .env', true],
|
|
183
|
+
['printenv', true],
|
|
184
|
+
['cat ~/.ssh/id_ed25519', true],
|
|
185
|
+
['cat ~/.ipingyou/config.json', true],
|
|
186
|
+
];
|
|
187
|
+
|
|
188
|
+
for (const [command, shouldBlock] of cases) {
|
|
189
|
+
const result = classifyCommand(command);
|
|
190
|
+
if (result.blocked !== shouldBlock) {
|
|
191
|
+
return { status: 'fail', detail: `AI safety mismatch for ${command}` };
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return { status: 'pass', detail: 'secret path/command guards active' };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async function runProjectSelfTest(label, command) {
|
|
199
|
+
const result = await execaCommand(command, {
|
|
200
|
+
reject: false,
|
|
201
|
+
timeout: 30000,
|
|
202
|
+
maxBuffer: 1024 * 1024,
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
if (result.exitCode === 0) return { status: 'pass', detail: label };
|
|
206
|
+
return {
|
|
207
|
+
status: 'fail',
|
|
208
|
+
detail: `${label} failed`,
|
|
209
|
+
hint: redactSensitive((result.stderr || result.stdout || '').split(/\r?\n/).slice(-4).join(' ')),
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export async function startDoctorMode(options = {}) {
|
|
214
|
+
BROKER_URL = process.env.BROKER_URL || BROKER_URL;
|
|
215
|
+
results.length = 0;
|
|
216
|
+
|
|
217
|
+
console.log('');
|
|
218
|
+
console.log(chalk.bold.cyan(' 🩺 iPingYou Doctor'));
|
|
219
|
+
console.log(chalk.dim(' ─────────────────────────────────────'));
|
|
220
|
+
console.log(chalk.dim(' Non-invasive diagnostics. Doctor reports issues but does not install, delete, or change settings.'));
|
|
221
|
+
console.log('');
|
|
222
|
+
|
|
223
|
+
const osInfo = detectOS();
|
|
224
|
+
record('pass', 'Runtime', `${osInfo.platform} ${osInfo.arch}, Node ${process.version}, host ${osInfo.hostname}`);
|
|
225
|
+
|
|
226
|
+
console.log(chalk.bold('\n Core tools'));
|
|
227
|
+
await check('ssh client', () => commandVersion('ssh', ['-V']));
|
|
228
|
+
await check('scp client', () => commandFound('scp'));
|
|
229
|
+
await check('ssh-keygen', () => commandFound('ssh-keygen'));
|
|
230
|
+
await check('cloudflared', () => commandVersion('cloudflared', ['--version']));
|
|
231
|
+
await check('tmux', () => commandVersion('tmux', ['-V']));
|
|
232
|
+
|
|
233
|
+
console.log(chalk.bold('\n Host readiness'));
|
|
234
|
+
await check('SSH service', checkSshService);
|
|
235
|
+
await check('Ephemeral SSH key parse', checkEphemeralKeyParse);
|
|
236
|
+
await check('Temporary directory writable', checkTempDirectory);
|
|
237
|
+
await check('Config directory', checkConfigDirectory);
|
|
238
|
+
await check('macOS protected folder note', checkMacPrivacyHints);
|
|
239
|
+
|
|
240
|
+
console.log(chalk.bold('\n Broker'));
|
|
241
|
+
await check(`Broker health (${BROKER_URL})`, async () => {
|
|
242
|
+
const ok = await pingBroker(BROKER_URL);
|
|
243
|
+
return ok
|
|
244
|
+
? { status: 'pass', detail: 'reachable' }
|
|
245
|
+
: { status: 'warn', detail: 'unreachable', hint: 'Use a private broker or check your network/BROKER_URL.' };
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
console.log(chalk.bold('\n AI mode'));
|
|
249
|
+
await check('Groq API key', () => process.env.GROQ_API_KEY
|
|
250
|
+
? { status: 'pass', detail: 'GROQ_API_KEY is set' }
|
|
251
|
+
: { status: 'warn', detail: 'GROQ_API_KEY is not set', hint: 'AI mode will ask for it interactively and keep it in memory only.' });
|
|
252
|
+
await check('AI safety rules', checkAiSafety);
|
|
253
|
+
|
|
254
|
+
console.log(chalk.bold('\n Project self-tests'));
|
|
255
|
+
await check('CLI syntax', () => runProjectSelfTest('node --check src/cli.js', 'node --check src/cli.js'));
|
|
256
|
+
await check('AI syntax', () => runProjectSelfTest('node --check src/modes/ai.js', 'node --check src/modes/ai.js'));
|
|
257
|
+
await check('Crypto proof', () => runProjectSelfTest('node test/test_e2e_crypto.js', 'node test/test_e2e_crypto.js'));
|
|
258
|
+
|
|
259
|
+
if (options.full) {
|
|
260
|
+
await check('Broker integration', async () => {
|
|
261
|
+
const localBroker = await pingBroker('http://localhost:4000');
|
|
262
|
+
if (!localBroker) {
|
|
263
|
+
return {
|
|
264
|
+
status: 'skip',
|
|
265
|
+
detail: 'localhost:4000 broker is not running',
|
|
266
|
+
hint: 'Start `node src/server.js` in another terminal, then rerun `ipingyou doctor --full`.',
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
return runProjectSelfTest('node test/test_broker_integration.js', 'node test/test_broker_integration.js');
|
|
270
|
+
});
|
|
271
|
+
} else {
|
|
272
|
+
record('skip', 'Broker integration', 'run `ipingyou doctor --full` with local broker running');
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const counts = results.reduce((acc, item) => {
|
|
276
|
+
acc[item.status] = (acc[item.status] || 0) + 1;
|
|
277
|
+
return acc;
|
|
278
|
+
}, {});
|
|
279
|
+
|
|
280
|
+
console.log('');
|
|
281
|
+
console.log(chalk.bold(' Summary'));
|
|
282
|
+
console.log(chalk.dim(' ─────────────────────────────────────'));
|
|
283
|
+
console.log(` ${chalk.green(`${counts.pass || 0} pass`)} ${chalk.yellow(`${counts.warn || 0} warn`)} ${chalk.red(`${counts.fail || 0} fail`)} ${chalk.dim(`${counts.skip || 0} skip`)}`);
|
|
284
|
+
|
|
285
|
+
if (counts.fail) {
|
|
286
|
+
console.log(chalk.red('\n Doctor found failures that should be fixed before release.'));
|
|
287
|
+
process.exitCode = 1;
|
|
288
|
+
} else if (counts.warn) {
|
|
289
|
+
console.log(chalk.yellow('\n Doctor found warnings. The app can run, but review the notes above.'));
|
|
290
|
+
} else {
|
|
291
|
+
console.log(chalk.green('\n Doctor found no issues. Looking crisp.'));
|
|
292
|
+
}
|
|
293
|
+
}
|