@pixelkit-labs/cli 1.5.5 → 1.5.7
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/LICENSE +21 -21
- package/README.md +70 -69
- package/build/index.d.ts +1 -1
- package/build/index.js +209 -16
- package/index.ts +715 -515
- package/package.json +50 -50
package/index.ts
CHANGED
|
@@ -1,515 +1,715 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* @file
|
|
4
|
-
* @description `pixelkit doctor`: the first and only command of `@pixelkit-labs/cli`. PixelKit hooks
|
|
5
|
-
* report `source: 'unavailable'` and render an em dash when a reading cannot be taken on real
|
|
6
|
-
* hardware; on the wrong device, without a development build, or without the native packages
|
|
7
|
-
* installed, every hook looks that way at once. `doctor` runs the checks a maintainer would run
|
|
8
|
-
* by hand — adb on PATH, the connected device's identity, the installed development build,
|
|
9
|
-
* whether `@pixelkit-labs/native` and `@pixelkit-labs/mlkit` resolve from the current project, AICore, and
|
|
10
|
-
* the Metro port forward — and reports each as pass, fail, not-applicable, or "could not
|
|
11
|
-
* determine" when the check itself could not be run. No dependency other than Node's
|
|
12
|
-
* `child_process` and `util`; nothing here is guessed.
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
import { execFile } from 'child_process';
|
|
16
|
-
import { promisify } from 'util';
|
|
17
|
-
|
|
18
|
-
const execFileAsync = promisify(execFile);
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* `pass` / `fail` / `n/a` are the three outcomes a check can reach. `unknown` is a fourth,
|
|
22
|
-
* deliberate state for when the check itself could not be run (adb missing, device offline,
|
|
23
|
-
* a transient adb error) — the same discipline as a hook's `source: 'unavailable'`: a result
|
|
24
|
-
* that cannot be verified is reported as such, never guessed into a pass.
|
|
25
|
-
*/
|
|
26
|
-
type Status = 'pass' | 'fail' | 'n/a' | 'unknown';
|
|
27
|
-
|
|
28
|
-
interface CheckResult {
|
|
29
|
-
name: string;
|
|
30
|
-
status: Status;
|
|
31
|
-
reason: string;
|
|
32
|
-
fix?: string;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
const ANSI = {
|
|
36
|
-
reset: '\x1b[0m',
|
|
37
|
-
bold: '\x1b[1m',
|
|
38
|
-
dim: '\x1b[2m',
|
|
39
|
-
green: '\x1b[32m',
|
|
40
|
-
red: '\x1b[31m',
|
|
41
|
-
yellow: '\x1b[33m',
|
|
42
|
-
};
|
|
43
|
-
|
|
44
|
-
const STATUS_LABEL: Record<Status, string> = {
|
|
45
|
-
pass: `${ANSI.green}PASS${ANSI.reset}`,
|
|
46
|
-
fail: `${ANSI.red}FAIL${ANSI.reset}`,
|
|
47
|
-
'n/a': `${ANSI.dim}N/A ${ANSI.reset}`,
|
|
48
|
-
unknown: `${ANSI.yellow}UNKN${ANSI.reset}`,
|
|
49
|
-
};
|
|
50
|
-
|
|
51
|
-
type AdbError = NodeJS.ErrnoException & {
|
|
52
|
-
stdout?: string;
|
|
53
|
-
stderr?: string;
|
|
54
|
-
killed?: boolean;
|
|
55
|
-
signal?: NodeJS.Signals | null;
|
|
56
|
-
};
|
|
57
|
-
|
|
58
|
-
/**
|
|
59
|
-
* Runs `adb <args>` via Node's `child_process.execFile` (promisified with `util.promisify`) and
|
|
60
|
-
* returns its stdout, or a description of why the command could not be run. Never throws: every
|
|
61
|
-
* failure mode (adb absent, device offline, timeout) becomes a value the caller reports.
|
|
62
|
-
*/
|
|
63
|
-
async function runAdb(
|
|
64
|
-
args: string[],
|
|
65
|
-
timeoutMs = 8000
|
|
66
|
-
): Promise<{ ok: true; stdout: string } | { ok: false; error: string }> {
|
|
67
|
-
try {
|
|
68
|
-
const { stdout } = await execFileAsync('adb', args, {
|
|
69
|
-
encoding: 'utf8',
|
|
70
|
-
timeout: timeoutMs,
|
|
71
|
-
windowsHide: true,
|
|
72
|
-
});
|
|
73
|
-
return { ok: true, stdout };
|
|
74
|
-
} catch (err) {
|
|
75
|
-
const e = err as AdbError;
|
|
76
|
-
if (e.code === 'ENOENT') return { ok: false, error: 'adb is not on PATH' };
|
|
77
|
-
if (e.killed || e.signal) {
|
|
78
|
-
return { ok: false, error: `adb ${args.join(' ')} timed out after ${timeoutMs}ms` };
|
|
79
|
-
}
|
|
80
|
-
const stderr = (e.stderr || '').toString().trim();
|
|
81
|
-
return { ok: false, error: stderr || e.message || String(err) };
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
interface DeviceLine {
|
|
86
|
-
serial: string;
|
|
87
|
-
state: string;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
/** Parses `adb devices` output: a header line, then `<serial>\t<state>` per connected device. */
|
|
91
|
-
function parseAdbDevices(stdout: string): DeviceLine[] {
|
|
92
|
-
return stdout
|
|
93
|
-
.split(/\r?\n/)
|
|
94
|
-
.slice(1)
|
|
95
|
-
.map((l) => l.trim())
|
|
96
|
-
.filter(Boolean)
|
|
97
|
-
.map((l) => {
|
|
98
|
-
const [serial, state] = l.split(/\s+/);
|
|
99
|
-
return { serial, state };
|
|
100
|
-
});
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
/**
|
|
104
|
-
* Check 1: adb is on PATH and exactly one device is connected (`adb devices`). When a `--serial`
|
|
105
|
-
* flag was given it is used to disambiguate instead of requiring exactly one device.
|
|
106
|
-
*/
|
|
107
|
-
async function checkAdbAndDevice(
|
|
108
|
-
explicitSerial: string | undefined
|
|
109
|
-
): Promise<{ result: CheckResult; serial: string | null }> {
|
|
110
|
-
const version = await runAdb(['version']);
|
|
111
|
-
if (!version.ok) {
|
|
112
|
-
return {
|
|
113
|
-
result: {
|
|
114
|
-
name: 'adb on PATH',
|
|
115
|
-
status: 'fail',
|
|
116
|
-
reason: version.error,
|
|
117
|
-
fix: 'Install Android platform-tools and add it to PATH: https://developer.android.com/tools/releases/platform-tools',
|
|
118
|
-
},
|
|
119
|
-
serial: null,
|
|
120
|
-
};
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
const devicesRes = await runAdb(['devices']);
|
|
124
|
-
if (!devicesRes.ok) {
|
|
125
|
-
return {
|
|
126
|
-
result: {
|
|
127
|
-
name: 'adb device',
|
|
128
|
-
status: 'unknown',
|
|
129
|
-
reason: `adb is on PATH but "adb devices" could not be run: ${devicesRes.error}`,
|
|
130
|
-
},
|
|
131
|
-
serial: null,
|
|
132
|
-
};
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
const devices = parseAdbDevices(devicesRes.stdout);
|
|
136
|
-
const ready = devices.filter((d) => d.state === 'device');
|
|
137
|
-
|
|
138
|
-
if (explicitSerial) {
|
|
139
|
-
const match = ready.find((d) => d.serial === explicitSerial);
|
|
140
|
-
if (!match) {
|
|
141
|
-
const seen = devices.length ? devices.map((d) => `${d.serial} (${d.state})`).join(', ') : 'none';
|
|
142
|
-
return {
|
|
143
|
-
result: {
|
|
144
|
-
name: 'adb device',
|
|
145
|
-
status: 'fail',
|
|
146
|
-
reason: `--serial ${explicitSerial} is not connected in "device" state. Connected: ${seen}.`,
|
|
147
|
-
fix: 'Run `adb devices` and pass one of the listed serials.',
|
|
148
|
-
},
|
|
149
|
-
serial: null,
|
|
150
|
-
};
|
|
151
|
-
}
|
|
152
|
-
return {
|
|
153
|
-
result: { name: 'adb device', status: 'pass', reason: `targeting ${explicitSerial} (--serial)` },
|
|
154
|
-
serial: explicitSerial,
|
|
155
|
-
};
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
if (devices.length === 0) {
|
|
159
|
-
return {
|
|
160
|
-
result: {
|
|
161
|
-
name: 'adb device',
|
|
162
|
-
status: 'fail',
|
|
163
|
-
reason: 'no device connected.',
|
|
164
|
-
fix: 'Connect the Pixel over USB (accept the RSA prompt) or `adb connect <ip>:<port>` for wireless adb, then re-run.',
|
|
165
|
-
},
|
|
166
|
-
serial: null,
|
|
167
|
-
};
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
if (ready.length === 0) {
|
|
171
|
-
return {
|
|
172
|
-
result: {
|
|
173
|
-
name: 'adb device',
|
|
174
|
-
status: 'fail',
|
|
175
|
-
reason: `${devices.length} device(s) present, none in "device" state: ${devices
|
|
176
|
-
.map((d) => `${d.serial} (${d.state})`)
|
|
177
|
-
.join(', ')}.`,
|
|
178
|
-
fix: 'unauthorized: accept the USB debugging prompt on the device screen. offline: reconnect the cable or re-run `adb connect`.',
|
|
179
|
-
},
|
|
180
|
-
serial: null,
|
|
181
|
-
};
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
if (ready.length > 1) {
|
|
185
|
-
return {
|
|
186
|
-
result: {
|
|
187
|
-
name: 'adb device',
|
|
188
|
-
status: 'fail',
|
|
189
|
-
reason: `${ready.length} devices connected: ${ready.map((d) => d.serial).join(', ')}.`,
|
|
190
|
-
fix: 'Re-run with --serial <serial> to target one of them.',
|
|
191
|
-
},
|
|
192
|
-
serial: null,
|
|
193
|
-
};
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
return {
|
|
197
|
-
result: { name: 'adb device', status: 'pass', reason: `${ready[0].serial} connected` },
|
|
198
|
-
serial: ready[0].serial,
|
|
199
|
-
};
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
/**
|
|
203
|
-
* Check 2: the connected device's manufacturer, model, Android release and SDK int, read with
|
|
204
|
-
* `adb shell getprop`. Most PixelKit hooks target Pixel-only APIs, so this states plainly whether
|
|
205
|
-
* the device is a Pixel rather than leaving that to be inferred from a wall of em dashes.
|
|
206
|
-
*/
|
|
207
|
-
async function checkDeviceIdentity(
|
|
208
|
-
serial: string | null
|
|
209
|
-
): Promise<{ result: CheckResult; manufacturer: string | null }> {
|
|
210
|
-
if (!serial) {
|
|
211
|
-
return {
|
|
212
|
-
result: {
|
|
213
|
-
name: 'Device identity',
|
|
214
|
-
status: 'unknown',
|
|
215
|
-
reason: 'skipped: no single adb device selected (see "adb device" above).',
|
|
216
|
-
},
|
|
217
|
-
manufacturer: null,
|
|
218
|
-
};
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
const props = [
|
|
222
|
-
'ro.product.manufacturer',
|
|
223
|
-
'ro.product.model',
|
|
224
|
-
'ro.build.version.release',
|
|
225
|
-
'ro.build.version.sdk',
|
|
226
|
-
];
|
|
227
|
-
const values: Record<string, string> = {};
|
|
228
|
-
for (const prop of props) {
|
|
229
|
-
const res = await runAdb(['-s', serial, 'shell', 'getprop', prop]);
|
|
230
|
-
if (!res.ok) {
|
|
231
|
-
return {
|
|
232
|
-
result: { name: 'Device identity', status: 'unknown', reason: `could not read ${prop}: ${res.error}` },
|
|
233
|
-
manufacturer: null,
|
|
234
|
-
};
|
|
235
|
-
}
|
|
236
|
-
values[prop] = res.stdout.trim();
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
const manufacturer = values['ro.product.manufacturer'];
|
|
240
|
-
const model = values['ro.product.model'];
|
|
241
|
-
const release = values['ro.build.version.release'];
|
|
242
|
-
const sdk = values['ro.build.version.sdk'];
|
|
243
|
-
const summary = `${manufacturer} ${model}, Android ${release} (SDK ${sdk})`;
|
|
244
|
-
const isPixel = manufacturer.toLowerCase() === 'google';
|
|
245
|
-
|
|
246
|
-
if (!isPixel) {
|
|
247
|
-
return {
|
|
248
|
-
result: {
|
|
249
|
-
name: 'Device identity',
|
|
250
|
-
status: 'fail',
|
|
251
|
-
reason: `${summary} — not a Pixel. Most PixelKit hooks are written against Pixel-only APIs and will report source: "unavailable" here.`,
|
|
252
|
-
},
|
|
253
|
-
manufacturer,
|
|
254
|
-
};
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
return { result: { name: 'Device identity', status: 'pass', reason: summary }, manufacturer };
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
/**
|
|
261
|
-
* Check 3: whether a PixelKit-based development build is installed (`adb shell pm path
|
|
262
|
-
* <package>`), keyed off `--package` (default `com.pixelkit.sdk`). Expo Go can never satisfy this
|
|
263
|
-
* check: `@pixelkit-labs/native` and `@pixelkit-labs/mlkit` are Kotlin Expo Modules that must be compiled
|
|
264
|
-
* into a development build, so that limitation is stated regardless of the result.
|
|
265
|
-
*/
|
|
266
|
-
async function checkDevBuild(serial: string | null, packageId: string): Promise<CheckResult> {
|
|
267
|
-
if (!serial) {
|
|
268
|
-
return { name: 'PixelKit development build', status: 'unknown', reason: 'skipped: no single adb device selected.' };
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
const res = await runAdb(['-s', serial, 'shell', 'pm', 'path', packageId]);
|
|
272
|
-
const installed = res.ok && /^package:/m.test(res.stdout.trim());
|
|
273
|
-
const goNote =
|
|
274
|
-
'Expo Go can never run PixelKit: @pixelkit-labs/native and @pixelkit-labs/mlkit are compiled Kotlin Expo Modules, so a development build is required.';
|
|
275
|
-
|
|
276
|
-
if (installed) {
|
|
277
|
-
return {
|
|
278
|
-
name: 'PixelKit development build',
|
|
279
|
-
status: 'pass',
|
|
280
|
-
reason: `${packageId} is installed on ${serial}. ${goNote}`,
|
|
281
|
-
};
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
return {
|
|
285
|
-
name: 'PixelKit development build',
|
|
286
|
-
status: 'fail',
|
|
287
|
-
reason: `${packageId} is not installed on ${serial}. ${goNote}`,
|
|
288
|
-
fix: `Build a development client and install it — npx expo run:android (or eas build --profile development), then adb -s ${serial} install -r -g <path-to-apk>. Pass --package <id> if your app id differs.`,
|
|
289
|
-
};
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
/**
|
|
293
|
-
* Check 4: whether `@pixelkit-labs/native` and `@pixelkit-labs/mlkit` resolve from the current project
|
|
294
|
-
* (`require.resolve` from `process.cwd()`, walking the real node_modules chain — nothing is
|
|
295
|
-
* assumed from package.json alone). `@pixelkit-labs/mlkit` is opt-in, so its absence is reported as
|
|
296
|
-
* informational (`n/a`), never a failure.
|
|
297
|
-
*/
|
|
298
|
-
function resolveFrom(name: string, cwd: string): { ok: true; path: string } | { ok: false; error: string } {
|
|
299
|
-
try {
|
|
300
|
-
const path = require.resolve(name, { paths: [cwd] });
|
|
301
|
-
return { ok: true, path };
|
|
302
|
-
} catch (err) {
|
|
303
|
-
return { ok: false, error: (err as Error).message };
|
|
304
|
-
}
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
function checkNativeModules(cwd: string): CheckResult[] {
|
|
308
|
-
const native = resolveFrom('@pixelkit-labs/native', cwd);
|
|
309
|
-
const mlkit = resolveFrom('@pixelkit-labs/mlkit', cwd);
|
|
310
|
-
const results: CheckResult[] = [];
|
|
311
|
-
|
|
312
|
-
if (native.ok) {
|
|
313
|
-
results.push({
|
|
314
|
-
name: '@pixelkit-labs/native resolvable',
|
|
315
|
-
status: 'pass',
|
|
316
|
-
reason: `resolved from ${native.path}. Silicon (SoC, CPU, memory, thermal, GPU), display, torch and haptics hooks are available.`,
|
|
317
|
-
});
|
|
318
|
-
} else {
|
|
319
|
-
results.push({
|
|
320
|
-
name: '@pixelkit-labs/native resolvable',
|
|
321
|
-
status: 'fail',
|
|
322
|
-
reason: `not resolvable from ${cwd}. Silicon, display, torch and haptics hooks will report source: "unavailable".`,
|
|
323
|
-
fix: 'npm install @pixelkit-labs/native (or install `@pixelkit-labs/sdk`, which depends on it directly).',
|
|
324
|
-
});
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
if (mlkit.ok) {
|
|
328
|
-
results.push({
|
|
329
|
-
name: '@pixelkit-labs/mlkit resolvable',
|
|
330
|
-
status: 'pass',
|
|
331
|
-
reason: `resolved from ${mlkit.path}. Gemini Nano / on-device ML Kit hooks are available.`,
|
|
332
|
-
});
|
|
333
|
-
} else {
|
|
334
|
-
results.push({
|
|
335
|
-
name: '@pixelkit-labs/mlkit resolvable',
|
|
336
|
-
status: 'n/a',
|
|
337
|
-
reason: `not resolvable from ${cwd}. This is opt-in, not a failure — Gemini Nano / on-device ML Kit hooks report source: "unavailable" without it.`,
|
|
338
|
-
fix: 'npm install @pixelkit-labs/mlkit to enable on-device ML Kit hooks.',
|
|
339
|
-
});
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
return results;
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
/**
|
|
346
|
-
* Check 5: whether AICore is present (`adb shell pm list packages`, matched for "aicore"), which
|
|
347
|
-
* Gemini Nano needs. Not-applicable on a device that Check 2 already found is not a Pixel.
|
|
348
|
-
*/
|
|
349
|
-
async function checkAiCore(serial: string | null, manufacturer: string | null): Promise<CheckResult> {
|
|
350
|
-
if (manufacturer && manufacturer.toLowerCase() !== 'google') {
|
|
351
|
-
return { name: 'AICore', status: 'n/a', reason: `device manufacturer is ${manufacturer}, not Google; AICore is a Pixel component.` };
|
|
352
|
-
}
|
|
353
|
-
if (!serial) {
|
|
354
|
-
return { name: 'AICore', status: 'unknown', reason: 'skipped: no single adb device selected.' };
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
const res = await runAdb(['-s', serial, 'shell', 'pm', 'list', 'packages']);
|
|
358
|
-
if (!res.ok) {
|
|
359
|
-
return { name: 'AICore', status: 'unknown', reason: `could not list packages: ${res.error}` };
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
const present = res.stdout.toLowerCase().includes('aicore');
|
|
363
|
-
if (present) {
|
|
364
|
-
return { name: 'AICore', status: 'pass', reason: 'a package matching "aicore" is installed. Gemini Nano can run through @pixelkit-labs/mlkit.' };
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
return {
|
|
368
|
-
name: 'AICore',
|
|
369
|
-
status: 'fail',
|
|
370
|
-
reason: 'no package matching "aicore" is installed. useGeminiNano and other on-device Gemini hooks will report source: "unavailable".',
|
|
371
|
-
fix: 'AICore ships through Google Play services on supported Pixels; update Play services and the Play Store, then reboot.',
|
|
372
|
-
};
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
/**
|
|
376
|
-
* Check 6: whether `adb reverse tcp:8081 tcp:8081` is set (`adb reverse --list`), so a
|
|
377
|
-
* development client can reach Metro on localhost.
|
|
378
|
-
*/
|
|
379
|
-
async function checkAdbReverse(serial: string | null): Promise<CheckResult> {
|
|
380
|
-
if (!serial) {
|
|
381
|
-
return { name: 'adb reverse tcp:8081', status: 'unknown', reason: 'skipped: no single adb device selected.' };
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
const res = await runAdb(['-s', serial, 'reverse', '--list']);
|
|
385
|
-
if (!res.ok) {
|
|
386
|
-
return { name: 'adb reverse tcp:8081', status: 'unknown', reason: `could not list adb reverse rules: ${res.error}` };
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
const set = res.stdout.split(/\r?\n/).some((l) => /tcp:8081\s+tcp:8081/.test(l));
|
|
390
|
-
if (set) {
|
|
391
|
-
return { name: 'adb reverse tcp:8081', status: 'pass', reason: 'set: the dev client can reach Metro at localhost:8081.' };
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
return {
|
|
395
|
-
name: 'adb reverse tcp:8081',
|
|
396
|
-
status: 'fail',
|
|
397
|
-
reason: 'not set. A development build looking for Metro on localhost:8081 will not find it.',
|
|
398
|
-
fix: `adb -s ${serial} reverse tcp:8081 tcp:8081`,
|
|
399
|
-
};
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
interface Flags {
|
|
403
|
-
packageId: string;
|
|
404
|
-
serial?: string;
|
|
405
|
-
help: boolean;
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
}
|
|
444
|
-
|
|
445
|
-
function
|
|
446
|
-
console.log(
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
const
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
const
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
})
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* @file index.ts
|
|
4
|
+
* @description `pixelkit doctor`: the first and only command of `@pixelkit-labs/cli`. PixelKit hooks
|
|
5
|
+
* report `source: 'unavailable'` and render an em dash when a reading cannot be taken on real
|
|
6
|
+
* hardware; on the wrong device, without a development build, or without the native packages
|
|
7
|
+
* installed, every hook looks that way at once. `doctor` runs the checks a maintainer would run
|
|
8
|
+
* by hand — adb on PATH, the connected device's identity, the installed development build,
|
|
9
|
+
* whether `@pixelkit-labs/native` and `@pixelkit-labs/mlkit` resolve from the current project, AICore, and
|
|
10
|
+
* the Metro port forward — and reports each as pass, fail, not-applicable, or "could not
|
|
11
|
+
* determine" when the check itself could not be run. No dependency other than Node's
|
|
12
|
+
* `child_process` and `util`; nothing here is guessed.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { execFile } from 'child_process';
|
|
16
|
+
import { promisify } from 'util';
|
|
17
|
+
|
|
18
|
+
const execFileAsync = promisify(execFile);
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* `pass` / `fail` / `n/a` are the three outcomes a check can reach. `unknown` is a fourth,
|
|
22
|
+
* deliberate state for when the check itself could not be run (adb missing, device offline,
|
|
23
|
+
* a transient adb error) — the same discipline as a hook's `source: 'unavailable'`: a result
|
|
24
|
+
* that cannot be verified is reported as such, never guessed into a pass.
|
|
25
|
+
*/
|
|
26
|
+
type Status = 'pass' | 'fail' | 'n/a' | 'unknown';
|
|
27
|
+
|
|
28
|
+
interface CheckResult {
|
|
29
|
+
name: string;
|
|
30
|
+
status: Status;
|
|
31
|
+
reason: string;
|
|
32
|
+
fix?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const ANSI = {
|
|
36
|
+
reset: '\x1b[0m',
|
|
37
|
+
bold: '\x1b[1m',
|
|
38
|
+
dim: '\x1b[2m',
|
|
39
|
+
green: '\x1b[32m',
|
|
40
|
+
red: '\x1b[31m',
|
|
41
|
+
yellow: '\x1b[33m',
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const STATUS_LABEL: Record<Status, string> = {
|
|
45
|
+
pass: `${ANSI.green}PASS${ANSI.reset}`,
|
|
46
|
+
fail: `${ANSI.red}FAIL${ANSI.reset}`,
|
|
47
|
+
'n/a': `${ANSI.dim}N/A ${ANSI.reset}`,
|
|
48
|
+
unknown: `${ANSI.yellow}UNKN${ANSI.reset}`,
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
type AdbError = NodeJS.ErrnoException & {
|
|
52
|
+
stdout?: string;
|
|
53
|
+
stderr?: string;
|
|
54
|
+
killed?: boolean;
|
|
55
|
+
signal?: NodeJS.Signals | null;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Runs `adb <args>` via Node's `child_process.execFile` (promisified with `util.promisify`) and
|
|
60
|
+
* returns its stdout, or a description of why the command could not be run. Never throws: every
|
|
61
|
+
* failure mode (adb absent, device offline, timeout) becomes a value the caller reports.
|
|
62
|
+
*/
|
|
63
|
+
async function runAdb(
|
|
64
|
+
args: string[],
|
|
65
|
+
timeoutMs = 8000
|
|
66
|
+
): Promise<{ ok: true; stdout: string } | { ok: false; error: string }> {
|
|
67
|
+
try {
|
|
68
|
+
const { stdout } = await execFileAsync('adb', args, {
|
|
69
|
+
encoding: 'utf8',
|
|
70
|
+
timeout: timeoutMs,
|
|
71
|
+
windowsHide: true,
|
|
72
|
+
});
|
|
73
|
+
return { ok: true, stdout };
|
|
74
|
+
} catch (err) {
|
|
75
|
+
const e = err as AdbError;
|
|
76
|
+
if (e.code === 'ENOENT') return { ok: false, error: 'adb is not on PATH' };
|
|
77
|
+
if (e.killed || e.signal) {
|
|
78
|
+
return { ok: false, error: `adb ${args.join(' ')} timed out after ${timeoutMs}ms` };
|
|
79
|
+
}
|
|
80
|
+
const stderr = (e.stderr || '').toString().trim();
|
|
81
|
+
return { ok: false, error: stderr || e.message || String(err) };
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
interface DeviceLine {
|
|
86
|
+
serial: string;
|
|
87
|
+
state: string;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Parses `adb devices` output: a header line, then `<serial>\t<state>` per connected device. */
|
|
91
|
+
function parseAdbDevices(stdout: string): DeviceLine[] {
|
|
92
|
+
return stdout
|
|
93
|
+
.split(/\r?\n/)
|
|
94
|
+
.slice(1)
|
|
95
|
+
.map((l) => l.trim())
|
|
96
|
+
.filter(Boolean)
|
|
97
|
+
.map((l) => {
|
|
98
|
+
const [serial, state] = l.split(/\s+/);
|
|
99
|
+
return { serial, state };
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Check 1: adb is on PATH and exactly one device is connected (`adb devices`). When a `--serial`
|
|
105
|
+
* flag was given it is used to disambiguate instead of requiring exactly one device.
|
|
106
|
+
*/
|
|
107
|
+
async function checkAdbAndDevice(
|
|
108
|
+
explicitSerial: string | undefined
|
|
109
|
+
): Promise<{ result: CheckResult; serial: string | null }> {
|
|
110
|
+
const version = await runAdb(['version']);
|
|
111
|
+
if (!version.ok) {
|
|
112
|
+
return {
|
|
113
|
+
result: {
|
|
114
|
+
name: 'adb on PATH',
|
|
115
|
+
status: 'fail',
|
|
116
|
+
reason: version.error,
|
|
117
|
+
fix: 'Install Android platform-tools and add it to PATH: https://developer.android.com/tools/releases/platform-tools',
|
|
118
|
+
},
|
|
119
|
+
serial: null,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const devicesRes = await runAdb(['devices']);
|
|
124
|
+
if (!devicesRes.ok) {
|
|
125
|
+
return {
|
|
126
|
+
result: {
|
|
127
|
+
name: 'adb device',
|
|
128
|
+
status: 'unknown',
|
|
129
|
+
reason: `adb is on PATH but "adb devices" could not be run: ${devicesRes.error}`,
|
|
130
|
+
},
|
|
131
|
+
serial: null,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const devices = parseAdbDevices(devicesRes.stdout);
|
|
136
|
+
const ready = devices.filter((d) => d.state === 'device');
|
|
137
|
+
|
|
138
|
+
if (explicitSerial) {
|
|
139
|
+
const match = ready.find((d) => d.serial === explicitSerial);
|
|
140
|
+
if (!match) {
|
|
141
|
+
const seen = devices.length ? devices.map((d) => `${d.serial} (${d.state})`).join(', ') : 'none';
|
|
142
|
+
return {
|
|
143
|
+
result: {
|
|
144
|
+
name: 'adb device',
|
|
145
|
+
status: 'fail',
|
|
146
|
+
reason: `--serial ${explicitSerial} is not connected in "device" state. Connected: ${seen}.`,
|
|
147
|
+
fix: 'Run `adb devices` and pass one of the listed serials.',
|
|
148
|
+
},
|
|
149
|
+
serial: null,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
return {
|
|
153
|
+
result: { name: 'adb device', status: 'pass', reason: `targeting ${explicitSerial} (--serial)` },
|
|
154
|
+
serial: explicitSerial,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (devices.length === 0) {
|
|
159
|
+
return {
|
|
160
|
+
result: {
|
|
161
|
+
name: 'adb device',
|
|
162
|
+
status: 'fail',
|
|
163
|
+
reason: 'no device connected.',
|
|
164
|
+
fix: 'Connect the Pixel over USB (accept the RSA prompt) or `adb connect <ip>:<port>` for wireless adb, then re-run.',
|
|
165
|
+
},
|
|
166
|
+
serial: null,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (ready.length === 0) {
|
|
171
|
+
return {
|
|
172
|
+
result: {
|
|
173
|
+
name: 'adb device',
|
|
174
|
+
status: 'fail',
|
|
175
|
+
reason: `${devices.length} device(s) present, none in "device" state: ${devices
|
|
176
|
+
.map((d) => `${d.serial} (${d.state})`)
|
|
177
|
+
.join(', ')}.`,
|
|
178
|
+
fix: 'unauthorized: accept the USB debugging prompt on the device screen. offline: reconnect the cable or re-run `adb connect`.',
|
|
179
|
+
},
|
|
180
|
+
serial: null,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (ready.length > 1) {
|
|
185
|
+
return {
|
|
186
|
+
result: {
|
|
187
|
+
name: 'adb device',
|
|
188
|
+
status: 'fail',
|
|
189
|
+
reason: `${ready.length} devices connected: ${ready.map((d) => d.serial).join(', ')}.`,
|
|
190
|
+
fix: 'Re-run with --serial <serial> to target one of them.',
|
|
191
|
+
},
|
|
192
|
+
serial: null,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return {
|
|
197
|
+
result: { name: 'adb device', status: 'pass', reason: `${ready[0].serial} connected` },
|
|
198
|
+
serial: ready[0].serial,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Check 2: the connected device's manufacturer, model, Android release and SDK int, read with
|
|
204
|
+
* `adb shell getprop`. Most PixelKit hooks target Pixel-only APIs, so this states plainly whether
|
|
205
|
+
* the device is a Pixel rather than leaving that to be inferred from a wall of em dashes.
|
|
206
|
+
*/
|
|
207
|
+
async function checkDeviceIdentity(
|
|
208
|
+
serial: string | null
|
|
209
|
+
): Promise<{ result: CheckResult; manufacturer: string | null }> {
|
|
210
|
+
if (!serial) {
|
|
211
|
+
return {
|
|
212
|
+
result: {
|
|
213
|
+
name: 'Device identity',
|
|
214
|
+
status: 'unknown',
|
|
215
|
+
reason: 'skipped: no single adb device selected (see "adb device" above).',
|
|
216
|
+
},
|
|
217
|
+
manufacturer: null,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const props = [
|
|
222
|
+
'ro.product.manufacturer',
|
|
223
|
+
'ro.product.model',
|
|
224
|
+
'ro.build.version.release',
|
|
225
|
+
'ro.build.version.sdk',
|
|
226
|
+
];
|
|
227
|
+
const values: Record<string, string> = {};
|
|
228
|
+
for (const prop of props) {
|
|
229
|
+
const res = await runAdb(['-s', serial, 'shell', 'getprop', prop]);
|
|
230
|
+
if (!res.ok) {
|
|
231
|
+
return {
|
|
232
|
+
result: { name: 'Device identity', status: 'unknown', reason: `could not read ${prop}: ${res.error}` },
|
|
233
|
+
manufacturer: null,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
values[prop] = res.stdout.trim();
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const manufacturer = values['ro.product.manufacturer'];
|
|
240
|
+
const model = values['ro.product.model'];
|
|
241
|
+
const release = values['ro.build.version.release'];
|
|
242
|
+
const sdk = values['ro.build.version.sdk'];
|
|
243
|
+
const summary = `${manufacturer} ${model}, Android ${release} (SDK ${sdk})`;
|
|
244
|
+
const isPixel = manufacturer.toLowerCase() === 'google';
|
|
245
|
+
|
|
246
|
+
if (!isPixel) {
|
|
247
|
+
return {
|
|
248
|
+
result: {
|
|
249
|
+
name: 'Device identity',
|
|
250
|
+
status: 'fail',
|
|
251
|
+
reason: `${summary} — not a Pixel. Most PixelKit hooks are written against Pixel-only APIs and will report source: "unavailable" here.`,
|
|
252
|
+
},
|
|
253
|
+
manufacturer,
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return { result: { name: 'Device identity', status: 'pass', reason: summary }, manufacturer };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Check 3: whether a PixelKit-based development build is installed (`adb shell pm path
|
|
262
|
+
* <package>`), keyed off `--package` (default `com.pixelkit.sdk`). Expo Go can never satisfy this
|
|
263
|
+
* check: `@pixelkit-labs/native` and `@pixelkit-labs/mlkit` are Kotlin Expo Modules that must be compiled
|
|
264
|
+
* into a development build, so that limitation is stated regardless of the result.
|
|
265
|
+
*/
|
|
266
|
+
async function checkDevBuild(serial: string | null, packageId: string): Promise<CheckResult> {
|
|
267
|
+
if (!serial) {
|
|
268
|
+
return { name: 'PixelKit development build', status: 'unknown', reason: 'skipped: no single adb device selected.' };
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const res = await runAdb(['-s', serial, 'shell', 'pm', 'path', packageId]);
|
|
272
|
+
const installed = res.ok && /^package:/m.test(res.stdout.trim());
|
|
273
|
+
const goNote =
|
|
274
|
+
'Expo Go can never run PixelKit: @pixelkit-labs/native and @pixelkit-labs/mlkit are compiled Kotlin Expo Modules, so a development build is required.';
|
|
275
|
+
|
|
276
|
+
if (installed) {
|
|
277
|
+
return {
|
|
278
|
+
name: 'PixelKit development build',
|
|
279
|
+
status: 'pass',
|
|
280
|
+
reason: `${packageId} is installed on ${serial}. ${goNote}`,
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
return {
|
|
285
|
+
name: 'PixelKit development build',
|
|
286
|
+
status: 'fail',
|
|
287
|
+
reason: `${packageId} is not installed on ${serial}. ${goNote}`,
|
|
288
|
+
fix: `Build a development client and install it — npx expo run:android (or eas build --profile development), then adb -s ${serial} install -r -g <path-to-apk>. Pass --package <id> if your app id differs.`,
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Check 4: whether `@pixelkit-labs/native` and `@pixelkit-labs/mlkit` resolve from the current project
|
|
294
|
+
* (`require.resolve` from `process.cwd()`, walking the real node_modules chain — nothing is
|
|
295
|
+
* assumed from package.json alone). `@pixelkit-labs/mlkit` is opt-in, so its absence is reported as
|
|
296
|
+
* informational (`n/a`), never a failure.
|
|
297
|
+
*/
|
|
298
|
+
function resolveFrom(name: string, cwd: string): { ok: true; path: string } | { ok: false; error: string } {
|
|
299
|
+
try {
|
|
300
|
+
const path = require.resolve(name, { paths: [cwd] });
|
|
301
|
+
return { ok: true, path };
|
|
302
|
+
} catch (err) {
|
|
303
|
+
return { ok: false, error: (err as Error).message };
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function checkNativeModules(cwd: string): CheckResult[] {
|
|
308
|
+
const native = resolveFrom('@pixelkit-labs/native', cwd);
|
|
309
|
+
const mlkit = resolveFrom('@pixelkit-labs/mlkit', cwd);
|
|
310
|
+
const results: CheckResult[] = [];
|
|
311
|
+
|
|
312
|
+
if (native.ok) {
|
|
313
|
+
results.push({
|
|
314
|
+
name: '@pixelkit-labs/native resolvable',
|
|
315
|
+
status: 'pass',
|
|
316
|
+
reason: `resolved from ${native.path}. Silicon (SoC, CPU, memory, thermal, GPU), display, torch and haptics hooks are available.`,
|
|
317
|
+
});
|
|
318
|
+
} else {
|
|
319
|
+
results.push({
|
|
320
|
+
name: '@pixelkit-labs/native resolvable',
|
|
321
|
+
status: 'fail',
|
|
322
|
+
reason: `not resolvable from ${cwd}. Silicon, display, torch and haptics hooks will report source: "unavailable".`,
|
|
323
|
+
fix: 'npm install @pixelkit-labs/native (or install `@pixelkit-labs/sdk`, which depends on it directly).',
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
if (mlkit.ok) {
|
|
328
|
+
results.push({
|
|
329
|
+
name: '@pixelkit-labs/mlkit resolvable',
|
|
330
|
+
status: 'pass',
|
|
331
|
+
reason: `resolved from ${mlkit.path}. Gemini Nano / on-device ML Kit hooks are available.`,
|
|
332
|
+
});
|
|
333
|
+
} else {
|
|
334
|
+
results.push({
|
|
335
|
+
name: '@pixelkit-labs/mlkit resolvable',
|
|
336
|
+
status: 'n/a',
|
|
337
|
+
reason: `not resolvable from ${cwd}. This is opt-in, not a failure — Gemini Nano / on-device ML Kit hooks report source: "unavailable" without it.`,
|
|
338
|
+
fix: 'npm install @pixelkit-labs/mlkit to enable on-device ML Kit hooks.',
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
return results;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Check 5: whether AICore is present (`adb shell pm list packages`, matched for "aicore"), which
|
|
347
|
+
* Gemini Nano needs. Not-applicable on a device that Check 2 already found is not a Pixel.
|
|
348
|
+
*/
|
|
349
|
+
async function checkAiCore(serial: string | null, manufacturer: string | null): Promise<CheckResult> {
|
|
350
|
+
if (manufacturer && manufacturer.toLowerCase() !== 'google') {
|
|
351
|
+
return { name: 'AICore', status: 'n/a', reason: `device manufacturer is ${manufacturer}, not Google; AICore is a Pixel component.` };
|
|
352
|
+
}
|
|
353
|
+
if (!serial) {
|
|
354
|
+
return { name: 'AICore', status: 'unknown', reason: 'skipped: no single adb device selected.' };
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const res = await runAdb(['-s', serial, 'shell', 'pm', 'list', 'packages']);
|
|
358
|
+
if (!res.ok) {
|
|
359
|
+
return { name: 'AICore', status: 'unknown', reason: `could not list packages: ${res.error}` };
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const present = res.stdout.toLowerCase().includes('aicore');
|
|
363
|
+
if (present) {
|
|
364
|
+
return { name: 'AICore', status: 'pass', reason: 'a package matching "aicore" is installed. Gemini Nano can run through @pixelkit-labs/mlkit.' };
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
return {
|
|
368
|
+
name: 'AICore',
|
|
369
|
+
status: 'fail',
|
|
370
|
+
reason: 'no package matching "aicore" is installed. useGeminiNano and other on-device Gemini hooks will report source: "unavailable".',
|
|
371
|
+
fix: 'AICore ships through Google Play services on supported Pixels; update Play services and the Play Store, then reboot.',
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Check 6: whether `adb reverse tcp:8081 tcp:8081` is set (`adb reverse --list`), so a
|
|
377
|
+
* development client can reach Metro on localhost.
|
|
378
|
+
*/
|
|
379
|
+
async function checkAdbReverse(serial: string | null): Promise<CheckResult> {
|
|
380
|
+
if (!serial) {
|
|
381
|
+
return { name: 'adb reverse tcp:8081', status: 'unknown', reason: 'skipped: no single adb device selected.' };
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const res = await runAdb(['-s', serial, 'reverse', '--list']);
|
|
385
|
+
if (!res.ok) {
|
|
386
|
+
return { name: 'adb reverse tcp:8081', status: 'unknown', reason: `could not list adb reverse rules: ${res.error}` };
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const set = res.stdout.split(/\r?\n/).some((l) => /tcp:8081\s+tcp:8081/.test(l));
|
|
390
|
+
if (set) {
|
|
391
|
+
return { name: 'adb reverse tcp:8081', status: 'pass', reason: 'set: the dev client can reach Metro at localhost:8081.' };
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
return {
|
|
395
|
+
name: 'adb reverse tcp:8081',
|
|
396
|
+
status: 'fail',
|
|
397
|
+
reason: 'not set. A development build looking for Metro on localhost:8081 will not find it.',
|
|
398
|
+
fix: `adb -s ${serial} reverse tcp:8081 tcp:8081`,
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
interface Flags {
|
|
403
|
+
packageId: string;
|
|
404
|
+
serial?: string;
|
|
405
|
+
help: boolean;
|
|
406
|
+
diagnose?: boolean;
|
|
407
|
+
query?: string;
|
|
408
|
+
model?: string;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function parseArgs(argv: string[]): { command: string | undefined; flags: Flags } {
|
|
412
|
+
const flags: Flags = { packageId: 'com.pixelkit.sdk', help: false };
|
|
413
|
+
let command: string | undefined;
|
|
414
|
+
|
|
415
|
+
for (let i = 0; i < argv.length; i++) {
|
|
416
|
+
const arg = argv[i];
|
|
417
|
+
if (arg === '--package') {
|
|
418
|
+
flags.packageId = argv[++i] ?? flags.packageId;
|
|
419
|
+
} else if (arg.startsWith('--package=')) {
|
|
420
|
+
flags.packageId = arg.slice('--package='.length);
|
|
421
|
+
} else if (arg === '--serial') {
|
|
422
|
+
flags.serial = argv[++i];
|
|
423
|
+
} else if (arg.startsWith('--serial=')) {
|
|
424
|
+
flags.serial = arg.slice('--serial='.length);
|
|
425
|
+
} else if (arg === '--diagnose') {
|
|
426
|
+
flags.diagnose = true;
|
|
427
|
+
} else if (arg === '--query' || arg === '-q') {
|
|
428
|
+
flags.query = argv[++i];
|
|
429
|
+
} else if (arg.startsWith('--query=')) {
|
|
430
|
+
flags.query = arg.slice('--query='.length);
|
|
431
|
+
} else if (arg === '--model' || arg === '-m') {
|
|
432
|
+
flags.model = argv[++i];
|
|
433
|
+
} else if (arg.startsWith('--model=')) {
|
|
434
|
+
flags.model = arg.slice('--model='.length);
|
|
435
|
+
} else if (arg === '--help' || arg === '-h') {
|
|
436
|
+
flags.help = true;
|
|
437
|
+
} else if (command === undefined) {
|
|
438
|
+
command = arg;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
return { command, flags };
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function printUsage(): void {
|
|
446
|
+
console.log(`${ANSI.bold}pixelkit${ANSI.reset} - developer tooling and diagnostics for Google Pixel hardware
|
|
447
|
+
|
|
448
|
+
Usage:
|
|
449
|
+
pixelkit doctor [--package <id>] [--serial <serial>]
|
|
450
|
+
pixelkit agent [--diagnose] [--query <prompt>] [--model <model>] [--serial <serial>]
|
|
451
|
+
|
|
452
|
+
Commands:
|
|
453
|
+
doctor Diagnose why PixelKit hooks might report source: "unavailable"
|
|
454
|
+
agent Run autonomous hardware diagnostics and query the device agent
|
|
455
|
+
|
|
456
|
+
Options:
|
|
457
|
+
--diagnose Run complete hardware diagnostic triage (CPU, battery, thermals, AICore)
|
|
458
|
+
--query, -q Query the hardware agent with a specific diagnostic question
|
|
459
|
+
--model, -m Gemini model for cloud reasoning (default: gemini-2.5-flash)
|
|
460
|
+
--package <id> Application id of the installed PixelKit development build (default: com.pixelkit.sdk)
|
|
461
|
+
--serial <id> adb serial to target when more than one device is connected
|
|
462
|
+
-h, --help Show this help
|
|
463
|
+
`);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function printResult(r: CheckResult): void {
|
|
467
|
+
console.log(`[${STATUS_LABEL[r.status]}] ${ANSI.bold}${r.name}${ANSI.reset} - ${r.reason}`);
|
|
468
|
+
if (r.fix) console.log(`${ANSI.dim} fix: ${r.fix}${ANSI.reset}`);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* Runs the autonomous hardware agent diagnostic command.
|
|
473
|
+
*/
|
|
474
|
+
async function runHardwareAgent(flags: Flags): Promise<number> {
|
|
475
|
+
console.log(`${ANSI.bold}PixelKit Hardware Diagnostics Agent${ANSI.reset}\n`);
|
|
476
|
+
|
|
477
|
+
const step1 = await checkAdbAndDevice(flags.serial);
|
|
478
|
+
if (step1.result.status === 'fail' || !step1.serial) {
|
|
479
|
+
printResult(step1.result);
|
|
480
|
+
return 1;
|
|
481
|
+
}
|
|
482
|
+
const serial = step1.serial;
|
|
483
|
+
|
|
484
|
+
console.log(`${ANSI.dim}Collecting hardware telemetry from ${serial}...${ANSI.reset}`);
|
|
485
|
+
|
|
486
|
+
// 1. Device identity & SoC
|
|
487
|
+
const modelRes = await runAdb(['-s', serial, 'shell', 'getprop', 'ro.product.model']);
|
|
488
|
+
const manufacturerRes = await runAdb(['-s', serial, 'shell', 'getprop', 'ro.product.manufacturer']);
|
|
489
|
+
const releaseRes = await runAdb(['-s', serial, 'shell', 'getprop', 'ro.build.version.release']);
|
|
490
|
+
const sdkRes = await runAdb(['-s', serial, 'shell', 'getprop', 'ro.build.version.sdk']);
|
|
491
|
+
const socRes = await runAdb(['-s', serial, 'shell', 'getprop', 'ro.soc.model']);
|
|
492
|
+
const platformRes = await runAdb(['-s', serial, 'shell', 'getprop', 'ro.board.platform']);
|
|
493
|
+
|
|
494
|
+
const model = modelRes.ok ? modelRes.stdout.trim() : 'Unknown';
|
|
495
|
+
const manufacturer = manufacturerRes.ok ? manufacturerRes.stdout.trim() : 'Unknown';
|
|
496
|
+
const release = releaseRes.ok ? releaseRes.stdout.trim() : 'Unknown';
|
|
497
|
+
const sdk = sdkRes.ok ? sdkRes.stdout.trim() : 'Unknown';
|
|
498
|
+
const soc = socRes.ok && socRes.stdout.trim() ? socRes.stdout.trim() : (platformRes.ok ? platformRes.stdout.trim() : 'Google Tensor');
|
|
499
|
+
|
|
500
|
+
// 2. Battery telemetry
|
|
501
|
+
const batteryRes = await runAdb(['-s', serial, 'shell', 'dumpsys', 'battery']);
|
|
502
|
+
let batteryLevel = 'N/A';
|
|
503
|
+
let batteryTemp = 'N/A';
|
|
504
|
+
let batteryVoltage = 'N/A';
|
|
505
|
+
let batteryStatus = 'N/A';
|
|
506
|
+
let batteryHealth = 'N/A';
|
|
507
|
+
if (batteryRes.ok) {
|
|
508
|
+
const lines = batteryRes.stdout.split(/\r?\n/);
|
|
509
|
+
for (const line of lines) {
|
|
510
|
+
const trimmed = line.trim();
|
|
511
|
+
if (trimmed.startsWith('level:')) batteryLevel = trimmed.split(':')[1].trim() + '%';
|
|
512
|
+
else if (trimmed.startsWith('temperature:')) {
|
|
513
|
+
const raw = parseFloat(trimmed.split(':')[1].trim());
|
|
514
|
+
batteryTemp = (raw / 10).toFixed(1) + '°C';
|
|
515
|
+
} else if (trimmed.startsWith('voltage:')) {
|
|
516
|
+
const raw = parseFloat(trimmed.split(':')[1].trim());
|
|
517
|
+
batteryVoltage = (raw / 1000).toFixed(2) + 'V';
|
|
518
|
+
} else if (trimmed.startsWith('status:')) {
|
|
519
|
+
const code = trimmed.split(':')[1].trim();
|
|
520
|
+
batteryStatus = code === '2' ? 'Charging' : code === '3' ? 'Discharging' : code === '5' ? 'Full' : 'Not Charging';
|
|
521
|
+
} else if (trimmed.startsWith('health:')) {
|
|
522
|
+
const code = trimmed.split(':')[1].trim();
|
|
523
|
+
batteryHealth = code === '2' ? 'Good' : 'Degraded';
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// 3. Thermal telemetry
|
|
529
|
+
const thermalRes = await runAdb(['-s', serial, 'shell', 'dumpsys', 'thermalservice']);
|
|
530
|
+
let thermalStatus = 'Normal (0)';
|
|
531
|
+
if (thermalRes.ok) {
|
|
532
|
+
const match = thermalRes.stdout.match(/mStatus=(\d+)/i) || thermalRes.stdout.match(/Current thermal status: (\d+)/i);
|
|
533
|
+
if (match) {
|
|
534
|
+
const code = parseInt(match[1], 10);
|
|
535
|
+
const labels = ['None', 'Light', 'Moderate', 'Severe', 'Critical', 'Emergency', 'Shutdown'];
|
|
536
|
+
thermalStatus = `${labels[code] ?? 'Unknown'} (${code})`;
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// 4. Memory telemetry
|
|
541
|
+
const memRes = await runAdb(['-s', serial, 'shell', 'cat', '/proc/meminfo']);
|
|
542
|
+
let memTotal = 'N/A';
|
|
543
|
+
let memAvail = 'N/A';
|
|
544
|
+
let memPct = 'N/A';
|
|
545
|
+
if (memRes.ok) {
|
|
546
|
+
const totalMatch = memRes.stdout.match(/MemTotal:\s+(\d+)\s+kB/);
|
|
547
|
+
const availMatch = memRes.stdout.match(/MemAvailable:\s+(\d+)\s+kB/);
|
|
548
|
+
if (totalMatch && availMatch) {
|
|
549
|
+
const totalMB = Math.round(parseInt(totalMatch[1], 10) / 1024);
|
|
550
|
+
const availMB = Math.round(parseInt(availMatch[1], 10) / 1024);
|
|
551
|
+
memTotal = `${totalMB} MB`;
|
|
552
|
+
memAvail = `${availMB} MB`;
|
|
553
|
+
memPct = `${Math.round((availMB / totalMB) * 100)}% available`;
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// 5. AICore status
|
|
558
|
+
const aicoreRes = await runAdb(['-s', serial, 'shell', 'pm', 'list', 'packages', 'com.google.android.aicore']);
|
|
559
|
+
const aicoreInstalled = aicoreRes.ok && aicoreRes.stdout.includes('com.google.android.aicore');
|
|
560
|
+
|
|
561
|
+
// Print Telemetry Matrix
|
|
562
|
+
console.log(`\n${ANSI.bold}Hardware Telemetry Snapshot:${ANSI.reset}`);
|
|
563
|
+
console.log(` Device: ${manufacturer} ${model} (Android ${release}, API ${sdk})`);
|
|
564
|
+
console.log(` Silicon: ${soc}`);
|
|
565
|
+
console.log(` Battery: ${batteryLevel} · ${batteryTemp} · ${batteryVoltage} · ${batteryStatus} (${batteryHealth})`);
|
|
566
|
+
console.log(` Thermals: ${thermalStatus}`);
|
|
567
|
+
console.log(` Memory: ${memAvail} / ${memTotal} (${memPct})`);
|
|
568
|
+
console.log(` AICore: ${aicoreInstalled ? `${ANSI.green}Active${ANSI.reset}` : `${ANSI.yellow}Not Installed${ANSI.reset}`}\n`);
|
|
569
|
+
|
|
570
|
+
const apiKey = process.env.GEMINI_API_KEY || process.env.GOOGLE_GENAI_API_KEY;
|
|
571
|
+
const userQuery = flags.query || 'Perform a comprehensive hardware diagnostic check on this Pixel device.';
|
|
572
|
+
|
|
573
|
+
if (!apiKey) {
|
|
574
|
+
console.log(`${ANSI.bold}Autonomous Diagnostic Assessment (Local Heuristics):${ANSI.reset}`);
|
|
575
|
+
const issues: string[] = [];
|
|
576
|
+
if (batteryHealth !== 'Good' && batteryHealth !== 'N/A') issues.push('Battery reports degraded health.');
|
|
577
|
+
if (thermalStatus.includes('Severe') || thermalStatus.includes('Critical')) issues.push('Thermal throttling active.');
|
|
578
|
+
if (!aicoreInstalled) issues.push('AICore is not installed; Gemini Nano hardware acceleration disabled.');
|
|
579
|
+
|
|
580
|
+
if (issues.length === 0) {
|
|
581
|
+
console.log(` ${ANSI.green}✔ System hardware health nominal. All silicon, battery, and thermal parameters within optimal operational thresholds.${ANSI.reset}`);
|
|
582
|
+
} else {
|
|
583
|
+
for (const iss of issues) {
|
|
584
|
+
console.log(` ${ANSI.yellow}⚠ ${iss}${ANSI.reset}`);
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
console.log(`\n${ANSI.dim}💡 Tip: Export GEMINI_API_KEY to activate cloud agent multi-turn LLM reasoning and custom hardware triage queries.${ANSI.reset}`);
|
|
588
|
+
return 0;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// Cloud Agent Reasoning
|
|
592
|
+
const modelName = flags.model || 'gemini-2.5-flash';
|
|
593
|
+
console.log(`${ANSI.bold}Cloud Hardware Agent (${modelName}):${ANSI.reset}`);
|
|
594
|
+
console.log(`${ANSI.dim}Reasoning over hardware telemetry with Google Gen AI...${ANSI.reset}\n`);
|
|
595
|
+
|
|
596
|
+
const prompt = `Target Device Telemetry:
|
|
597
|
+
- Manufacturer: ${manufacturer}
|
|
598
|
+
- Model: ${model}
|
|
599
|
+
- Android Release: ${release} (SDK ${sdk})
|
|
600
|
+
- SoC Silicon: ${soc}
|
|
601
|
+
- Battery: Level=${batteryLevel}, Temp=${batteryTemp}, Voltage=${batteryVoltage}, Status=${batteryStatus}, Health=${batteryHealth}
|
|
602
|
+
- Thermal Headroom: ${thermalStatus}
|
|
603
|
+
- Memory: Available=${memAvail}, Total=${memTotal} (${memPct})
|
|
604
|
+
- AICore: ${aicoreInstalled ? 'Installed and available' : 'Not installed'}
|
|
605
|
+
|
|
606
|
+
User Diagnostic Query: "${userQuery}"
|
|
607
|
+
|
|
608
|
+
Provide a concise, professional hardware engineering assessment. Evaluate thermal headroom, battery wear/charging, memory constraints, and AICore readiness. Give specific actionable recommendations.`;
|
|
609
|
+
|
|
610
|
+
try {
|
|
611
|
+
const url = `https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(modelName)}:generateContent?key=${encodeURIComponent(apiKey)}`;
|
|
612
|
+
const res = await fetch(url, {
|
|
613
|
+
method: 'POST',
|
|
614
|
+
headers: { 'Content-Type': 'application/json' },
|
|
615
|
+
body: JSON.stringify({
|
|
616
|
+
contents: [{ role: 'user', parts: [{ text: prompt }] }],
|
|
617
|
+
systemInstruction: {
|
|
618
|
+
parts: [{ text: 'You are the PixelKit Hardware Diagnostics Agent, an expert embedded and systems engineer specializing in Google Pixel hardware (Tensor G-series, Android, AICore).' }]
|
|
619
|
+
}
|
|
620
|
+
})
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
if (!res.ok) {
|
|
624
|
+
const errText = await res.text();
|
|
625
|
+
console.error(`Agent API error (${res.status}): ${errText}`);
|
|
626
|
+
return 1;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
const data: any = await res.json();
|
|
630
|
+
const replyText = data?.candidates?.[0]?.content?.parts?.[0]?.text;
|
|
631
|
+
if (replyText) {
|
|
632
|
+
console.log(replyText.trim());
|
|
633
|
+
console.log(`\n${ANSI.green}✔ Hardware diagnostic triage complete.${ANSI.reset}`);
|
|
634
|
+
return 0;
|
|
635
|
+
} else {
|
|
636
|
+
console.error('No response text received from agent.');
|
|
637
|
+
return 1;
|
|
638
|
+
}
|
|
639
|
+
} catch (err: any) {
|
|
640
|
+
console.error(`Agent reasoning failed: ${err.message || String(err)}`);
|
|
641
|
+
return 1;
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/**
|
|
646
|
+
* Runs all six checks in order and prints them. Returns the process exit code: 0 only when every
|
|
647
|
+
* check passed or was not applicable; 1 when any check failed or — per the "never fabricate a
|
|
648
|
+
* result" rule — could not be determined at all.
|
|
649
|
+
*/
|
|
650
|
+
async function doctor(flags: Flags): Promise<number> {
|
|
651
|
+
console.log(`${ANSI.bold}PixelKit doctor${ANSI.reset}\n`);
|
|
652
|
+
|
|
653
|
+
const results: CheckResult[] = [];
|
|
654
|
+
|
|
655
|
+
const step1 = await checkAdbAndDevice(flags.serial);
|
|
656
|
+
results.push(step1.result);
|
|
657
|
+
const serial = step1.serial;
|
|
658
|
+
|
|
659
|
+
const step2 = await checkDeviceIdentity(serial);
|
|
660
|
+
results.push(step2.result);
|
|
661
|
+
const manufacturer = step2.manufacturer;
|
|
662
|
+
|
|
663
|
+
results.push(await checkDevBuild(serial, flags.packageId));
|
|
664
|
+
results.push(...checkNativeModules(process.cwd()));
|
|
665
|
+
results.push(await checkAiCore(serial, manufacturer));
|
|
666
|
+
results.push(await checkAdbReverse(serial));
|
|
667
|
+
|
|
668
|
+
for (const r of results) printResult(r);
|
|
669
|
+
|
|
670
|
+
const failed = results.filter((r) => r.status === 'fail');
|
|
671
|
+
const unresolved = results.filter((r) => r.status === 'unknown');
|
|
672
|
+
const passed = results.filter((r) => r.status === 'pass');
|
|
673
|
+
const notApplicable = results.filter((r) => r.status === 'n/a');
|
|
674
|
+
|
|
675
|
+
console.log(
|
|
676
|
+
`\n${passed.length} passed, ${failed.length} failed, ${unresolved.length} could not be determined, ${notApplicable.length} not applicable.`
|
|
677
|
+
);
|
|
678
|
+
|
|
679
|
+
return failed.length > 0 || unresolved.length > 0 ? 1 : 0;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
async function main(): Promise<void> {
|
|
683
|
+
const { command, flags } = parseArgs(process.argv.slice(2));
|
|
684
|
+
|
|
685
|
+
if (flags.help) {
|
|
686
|
+
printUsage();
|
|
687
|
+
process.exitCode = 0;
|
|
688
|
+
return;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
if (!command) {
|
|
692
|
+
printUsage();
|
|
693
|
+
process.exitCode = 1;
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
if (command === 'doctor') {
|
|
698
|
+
process.exitCode = await doctor(flags);
|
|
699
|
+
return;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
if (command === 'agent') {
|
|
703
|
+
process.exitCode = await runHardwareAgent(flags);
|
|
704
|
+
return;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
console.error(`Unknown command "${command}". Available commands: "doctor", "agent".\n`);
|
|
708
|
+
printUsage();
|
|
709
|
+
process.exitCode = 1;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
main().catch((err) => {
|
|
713
|
+
console.error(`pixelkit crashed: ${(err as Error).stack || String(err)}`);
|
|
714
|
+
process.exitCode = 1;
|
|
715
|
+
});
|