@oxy.so/doctor 1.2.0 → 1.4.0
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 +27 -0
- package/package.json +4 -4
- package/src/checks.mjs +86 -3
- package/src/cli.mjs +4 -0
- package/src/emulator-preflight.mjs +50 -0
package/README.md
CHANGED
|
@@ -32,7 +32,34 @@ gated on it, red at once; each app still has to upgrade inside the window.
|
|
|
32
32
|
Duplicate locked versions and a missing lockfile are always errors.
|
|
33
33
|
Operational failures use status 2. Default mode is informational.
|
|
34
34
|
|
|
35
|
+
It also checks that every app (a manifest on `@oxy.so/services` and on `expo`
|
|
36
|
+
or `react-native`) declares each REQUIRED native peer of the services it has
|
|
37
|
+
installed, an **error** otherwise (`undeclared-native-peer`, with the
|
|
38
|
+
`npx expo install …` line). Expo's autolinking skips optional peers and an
|
|
39
|
+
undeclared native module installs at whatever version the package manager
|
|
40
|
+
picks: an Android release without `expo-crypto` built, then had no CSPRNG and
|
|
41
|
+
no sign-in. The check reads the installed `@oxy.so/services` manifest, so run it
|
|
42
|
+
after `bun install`; a manifest whose services is not installed is listed as
|
|
43
|
+
unverified (`nativePeersUnverified` in `--json`), never passed.
|
|
44
|
+
|
|
35
45
|
Use Renovate with a shared organisation preset for grouped, reviewable updates.
|
|
36
46
|
Require normal tests and typecheck before merging; never use `latest` ranges or
|
|
37
47
|
mutate dependencies during application startup or CI. Renovate proposes
|
|
38
48
|
changes; Doctor detects drift and duplicate installations.
|
|
49
|
+
|
|
50
|
+
## Android emulator preflight
|
|
51
|
+
|
|
52
|
+
`oxy-emulator-preflight --serial emulator-5554` checks that the explicitly
|
|
53
|
+
selected serial occurs exactly once as an online device in `adb devices -l`,
|
|
54
|
+
then asks its emulator console for the AVD name. It prints only the serial on
|
|
55
|
+
success and exits nonzero on invalid input, offline or ambiguous devices, an
|
|
56
|
+
unavailable ADB, or a missing AVD response. It never starts an emulator or runs
|
|
57
|
+
shell, installation, clearing, or uninstallation commands on a device.
|
|
58
|
+
|
|
59
|
+
The caller must carry the returned serial into every subsequent command, for
|
|
60
|
+
example using `adb -s "$serial" ...`; never rely on ADB's default selection.
|
|
61
|
+
Run the preflight immediately before the operation and fail on errors. This is a
|
|
62
|
+
read-only selection check, not an atomic device lock: devices can disconnect or
|
|
63
|
+
change between commands. It does not prove the AVD is disposable, has no user
|
|
64
|
+
data, or is isolated from production networks. Those are separate prerequisites
|
|
65
|
+
for an isolated mobile test.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oxy.so/doctor",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
4
|
"description": "Read-only health checks for Oxy ecosystem repositories",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
},
|
|
10
10
|
"type": "module",
|
|
11
11
|
"bin": {
|
|
12
|
-
"oxy-doctor": "./src/cli.mjs"
|
|
12
|
+
"oxy-doctor": "./src/cli.mjs",
|
|
13
|
+
"oxy-emulator-preflight": "./src/emulator-preflight.mjs"
|
|
13
14
|
},
|
|
14
15
|
"exports": {
|
|
15
16
|
".": "./src/checks.mjs"
|
|
@@ -19,8 +20,7 @@
|
|
|
19
20
|
"README.md"
|
|
20
21
|
],
|
|
21
22
|
"scripts": {
|
|
22
|
-
"test": "bun test tests"
|
|
23
|
-
"prepublishOnly": "bun run test"
|
|
23
|
+
"test": "bun test tests"
|
|
24
24
|
},
|
|
25
25
|
"publishConfig": {
|
|
26
26
|
"access": "public"
|
package/src/checks.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { readFile, readdir } from 'node:fs/promises';
|
|
2
|
-
import { existsSync } from 'node:fs';
|
|
2
|
+
import { existsSync, readdirSync } from 'node:fs';
|
|
3
3
|
import { dirname, join, resolve } from 'node:path';
|
|
4
4
|
|
|
5
5
|
export const FIRST_PARTY_SCOPES = ['@oxy.so/'];
|
|
@@ -155,6 +155,76 @@ function catalogEntries(rootManifest) {
|
|
|
155
155
|
return rows;
|
|
156
156
|
}
|
|
157
157
|
|
|
158
|
+
/** The SDK whose native peers an app has to declare itself. */
|
|
159
|
+
export const NATIVE_PEER_HOST = '@oxy.so/services';
|
|
160
|
+
|
|
161
|
+
/** The installed directory of `name`, found the way Node and Metro walk node_modules. */
|
|
162
|
+
function installedPackageDir(name, from) {
|
|
163
|
+
let directory = from;
|
|
164
|
+
while (true) {
|
|
165
|
+
const candidate = join(directory, 'node_modules', name);
|
|
166
|
+
if (existsSync(join(candidate, 'package.json'))) return candidate;
|
|
167
|
+
const parent = dirname(directory);
|
|
168
|
+
if (parent === directory) return null;
|
|
169
|
+
directory = parent;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Ships native code autolinking must link: the markers Expo and the React Native CLI read. */
|
|
174
|
+
export function isNativeModulePackage(directory) {
|
|
175
|
+
return (
|
|
176
|
+
existsSync(join(directory, 'expo-module.config.json')) ||
|
|
177
|
+
existsSync(join(directory, 'react-native.config.js')) ||
|
|
178
|
+
existsSync(join(directory, 'android')) ||
|
|
179
|
+
existsSync(join(directory, 'ios')) ||
|
|
180
|
+
readdirSync(directory).some((entry) => entry.endsWith('.podspec'))
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Required native peers of `@oxy.so/services` that an app (a manifest on
|
|
186
|
+
* services and on `expo` or `react-native`) does not declare. Expo's autolinking skips OPTIONAL peers, and an undeclared native
|
|
187
|
+
* module is installed at whatever version the package manager picks rather than
|
|
188
|
+
* the build `expo install` pins for the app's SDK; `expo-crypto` missing from
|
|
189
|
+
* Allo's release APK broke the CSPRNG and sign-in. Reads the INSTALLED services
|
|
190
|
+
* manifest, so it needs `bun install` first; without one the manifest is
|
|
191
|
+
* reported as unverified rather than passed.
|
|
192
|
+
*
|
|
193
|
+
* @returns {Promise<{ finding?: object, unverified?: string }>}
|
|
194
|
+
*/
|
|
195
|
+
export async function nativePeerCheck(root, manifestPath, manifest) {
|
|
196
|
+
const declared = { ...manifest.dependencies, ...manifest.devDependencies };
|
|
197
|
+
// Only an APP is autolinked: a manifest on services that is no Expo / React
|
|
198
|
+
// Native project (a monorepo root holding it for hoisting, a library) is not.
|
|
199
|
+
if (!(NATIVE_PEER_HOST in declared) || !('expo' in declared || 'react-native' in declared))
|
|
200
|
+
return {};
|
|
201
|
+
const relative = manifestPath.slice(root.length + 1);
|
|
202
|
+
const servicesDir = installedPackageDir(NATIVE_PEER_HOST, dirname(manifestPath));
|
|
203
|
+
if (!servicesDir) return { unverified: relative };
|
|
204
|
+
const services = JSON.parse(await readFile(join(servicesDir, 'package.json'), 'utf8'));
|
|
205
|
+
const meta = services.peerDependenciesMeta ?? {};
|
|
206
|
+
const missing = Object.keys(services.peerDependencies ?? {})
|
|
207
|
+
.filter((name) => meta[name]?.optional !== true && !(name in declared))
|
|
208
|
+
.filter((name) => {
|
|
209
|
+
const directory = installedPackageDir(name, servicesDir);
|
|
210
|
+
// Not installed at all: a required peer is missing outright.
|
|
211
|
+
return directory === null || isNativeModulePackage(directory);
|
|
212
|
+
})
|
|
213
|
+
.sort();
|
|
214
|
+
if (missing.length === 0) return {};
|
|
215
|
+
return {
|
|
216
|
+
finding: {
|
|
217
|
+
severity: 'error',
|
|
218
|
+
code: 'undeclared-native-peer',
|
|
219
|
+
package: NATIVE_PEER_HOST,
|
|
220
|
+
version: services.version,
|
|
221
|
+
manifest: relative,
|
|
222
|
+
missing,
|
|
223
|
+
message: `${relative} uses ${NATIVE_PEER_HOST} ${services.version} but does not declare its required native peer${missing.length === 1 ? '' : 's'} ${missing.join(', ')}; run \`npx expo install ${missing.join(' ')}\` in ${dirname(relative) || '.'}`,
|
|
224
|
+
},
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
158
228
|
/**
|
|
159
229
|
* @param {string} root
|
|
160
230
|
* @param {(name: string) => Promise<{ version: string, publishedAt: string }>} fetchLatest
|
|
@@ -168,6 +238,7 @@ export async function inspectRepository(
|
|
|
168
238
|
const rootManifest = JSON.parse(await readFile(join(root, 'package.json'), 'utf8'));
|
|
169
239
|
const manifestPaths = await collectManifests(root, rootManifest);
|
|
170
240
|
const usages = new Map();
|
|
241
|
+
const findings = [];
|
|
171
242
|
for (const { section, name, range } of catalogEntries(rootManifest)) {
|
|
172
243
|
if (!isFirstPartyPackage(name)) continue;
|
|
173
244
|
const rows = usages.get(name) ?? [];
|
|
@@ -195,9 +266,15 @@ export async function inspectRepository(
|
|
|
195
266
|
}
|
|
196
267
|
}
|
|
197
268
|
}
|
|
269
|
+
const unverified = [];
|
|
270
|
+
for (const path of manifestPaths) {
|
|
271
|
+
const manifest = JSON.parse(await readFile(path, 'utf8'));
|
|
272
|
+
const result = await nativePeerCheck(root, path, manifest);
|
|
273
|
+
if (result.finding) findings.push(result.finding);
|
|
274
|
+
if (result.unverified) unverified.push(result.unverified);
|
|
275
|
+
}
|
|
198
276
|
const lockPath = join(root, 'bun.lock');
|
|
199
277
|
const lockfile = existsSync(lockPath) ? await readFile(lockPath, 'utf8') : null;
|
|
200
|
-
const findings = [];
|
|
201
278
|
if (!lockfile)
|
|
202
279
|
findings.push({ severity: 'error', code: 'missing-lockfile', message: 'bun.lock is missing' });
|
|
203
280
|
await Promise.all(
|
|
@@ -244,7 +321,13 @@ export async function inspectRepository(
|
|
|
244
321
|
}
|
|
245
322
|
}),
|
|
246
323
|
);
|
|
247
|
-
return {
|
|
324
|
+
return {
|
|
325
|
+
root,
|
|
326
|
+
manifests: manifestPaths.length,
|
|
327
|
+
packages: usages.size,
|
|
328
|
+
findings,
|
|
329
|
+
nativePeersUnverified: unverified,
|
|
330
|
+
};
|
|
248
331
|
}
|
|
249
332
|
|
|
250
333
|
/** The `latest` dist-tag of `name` and when that version was published. */
|
package/src/cli.mjs
CHANGED
|
@@ -31,6 +31,10 @@ try {
|
|
|
31
31
|
`Oxy Doctor: ${report.packages} first-party packages across ${report.manifests} manifests`,
|
|
32
32
|
);
|
|
33
33
|
if (report.findings.length === 0) console.log('✓ No dependency-health findings');
|
|
34
|
+
for (const manifest of report.nativePeersUnverified)
|
|
35
|
+
console.log(
|
|
36
|
+
`? ${manifest}: @oxy.so/services is not installed, so its native peers were not checked (run bun install first)`,
|
|
37
|
+
);
|
|
34
38
|
for (const finding of report.findings)
|
|
35
39
|
console.log(`${finding.severity === 'error' ? '✗' : '!'} ${finding.message}`);
|
|
36
40
|
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
|
|
4
|
+
const usage = 'Usage: oxy-emulator-preflight --serial emulator-<port>';
|
|
5
|
+
const args = process.argv.slice(2);
|
|
6
|
+
if (args.length === 1 && (args[0] === '--help' || args[0] === '-h')) {
|
|
7
|
+
console.log(usage);
|
|
8
|
+
process.exit(0);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function fail(message) {
|
|
12
|
+
console.error(`Emulator preflight failed: ${message}`);
|
|
13
|
+
process.exit(1);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
if (args.length !== 2 || args[0] !== '--serial' || !/^emulator-\d{4,5}$/.test(args[1] ?? '')) {
|
|
17
|
+
fail(`an explicit emulator serial is required. ${usage}`);
|
|
18
|
+
}
|
|
19
|
+
const serial = args[1];
|
|
20
|
+
|
|
21
|
+
function adb(...arguments_) {
|
|
22
|
+
const result = spawnSync('adb', arguments_, {
|
|
23
|
+
encoding: 'utf8',
|
|
24
|
+
timeout: 10_000,
|
|
25
|
+
maxBuffer: 1_000_000,
|
|
26
|
+
});
|
|
27
|
+
if (result.error || result.status !== 0) {
|
|
28
|
+
fail(`adb ${arguments_[0]} failed or timed out`);
|
|
29
|
+
}
|
|
30
|
+
return result.stdout;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const lines = adb('devices', '-l').split(/\r?\n/);
|
|
34
|
+
if (!lines.some((line) => line.trim() === 'List of devices attached')) {
|
|
35
|
+
fail('unrecognized adb devices output');
|
|
36
|
+
}
|
|
37
|
+
const matches = lines
|
|
38
|
+
.map((line) => line.trim().split(/\s+/))
|
|
39
|
+
.filter(([candidate]) => candidate === serial);
|
|
40
|
+
if (matches.length !== 1 || matches[0][1] !== 'device') {
|
|
41
|
+
fail('the selected emulator must appear exactly once and be online');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// A serial-shaped name alone is not proof that adb is talking to an emulator.
|
|
45
|
+
// The console command is read-only and must return one AVD name followed by OK.
|
|
46
|
+
const reply = adb('-s', serial, 'emu', 'avd', 'name').trim().split(/\r?\n/);
|
|
47
|
+
if (reply.length !== 2 || !/^[A-Za-z0-9_.-]+$/.test(reply[0]) || reply[1] !== 'OK') {
|
|
48
|
+
fail('the selected device did not identify itself as an Android AVD');
|
|
49
|
+
}
|
|
50
|
+
console.log(serial);
|