@oxy.so/doctor 1.1.1 → 1.3.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 +18 -3
- package/package.json +2 -3
- package/src/checks.mjs +178 -16
- package/src/cli.mjs +4 -0
package/README.md
CHANGED
|
@@ -15,9 +15,14 @@ bunx @oxy.so/doctor --ci --grace-days=7
|
|
|
15
15
|
```
|
|
16
16
|
|
|
17
17
|
It checks direct dependencies under the canonical `@oxy.so/*` scope against
|
|
18
|
-
npm, reports duplicate
|
|
19
|
-
|
|
20
|
-
|
|
18
|
+
npm, reports duplicate versions in `bun.lock`, and fails clearly when the
|
|
19
|
+
lockfile is missing.
|
|
20
|
+
|
|
21
|
+
It reads the manifests Bun installs from: the root `package.json` and the
|
|
22
|
+
workspaces its `workspaces` patterns match. A `catalog:` dependency is checked
|
|
23
|
+
where its range lives, the root's `workspaces.catalog` (or `catalogs.<name>`).
|
|
24
|
+
A `package.json` outside the workspaces, such as a frozen copy kept as audit
|
|
25
|
+
evidence, is not this repository's dependency state and is not read.
|
|
21
26
|
|
|
22
27
|
A range that doesn't include the newest published `@oxy.so/*` release is a
|
|
23
28
|
**warning** for 14 days after that release was published (`--grace-days=N`
|
|
@@ -27,6 +32,16 @@ gated on it, red at once; each app still has to upgrade inside the window.
|
|
|
27
32
|
Duplicate locked versions and a missing lockfile are always errors.
|
|
28
33
|
Operational failures use status 2. Default mode is informational.
|
|
29
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
|
+
|
|
30
45
|
Use Renovate with a shared organisation preset for grouped, reviewable updates.
|
|
31
46
|
Require normal tests and typecheck before merging; never use `latest` ranges or
|
|
32
47
|
mutate dependencies during application startup or CI. Renovate proposes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oxy.so/doctor",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"description": "Read-only health checks for Oxy ecosystem repositories",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -19,8 +19,7 @@
|
|
|
19
19
|
"README.md"
|
|
20
20
|
],
|
|
21
21
|
"scripts": {
|
|
22
|
-
"test": "bun test tests"
|
|
23
|
-
"prepublishOnly": "bun run test"
|
|
22
|
+
"test": "bun test tests"
|
|
24
23
|
},
|
|
25
24
|
"publishConfig": {
|
|
26
25
|
"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/'];
|
|
@@ -74,18 +74,155 @@ export async function findRepositoryRoot(start = process.cwd()) {
|
|
|
74
74
|
}
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
77
|
+
const IGNORED_DIRECTORIES = new Set([
|
|
78
|
+
'.git',
|
|
79
|
+
'.expo',
|
|
80
|
+
'.turbo',
|
|
81
|
+
'.worktrees',
|
|
82
|
+
'dist',
|
|
83
|
+
'lib',
|
|
84
|
+
'node_modules',
|
|
85
|
+
]);
|
|
86
|
+
|
|
87
|
+
async function childDirectories(directory) {
|
|
88
|
+
const entries = await readdir(directory, { withFileTypes: true }).catch(() => []);
|
|
89
|
+
return entries
|
|
90
|
+
.filter((entry) => entry.isDirectory() && !IGNORED_DIRECTORIES.has(entry.name))
|
|
91
|
+
.map((entry) => join(directory, entry.name));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Directories matching one Bun workspace pattern (`dir`, `dir/*`, `dir/**`). */
|
|
95
|
+
async function expandWorkspacePattern(directory, segments) {
|
|
96
|
+
if (segments.length === 0) return [directory];
|
|
97
|
+
const [segment, ...rest] = segments;
|
|
98
|
+
if (segment === '**') {
|
|
99
|
+
const here = await expandWorkspacePattern(directory, rest);
|
|
100
|
+
const deeper = await Promise.all(
|
|
101
|
+
(await childDirectories(directory)).map((child) => expandWorkspacePattern(child, segments)),
|
|
102
|
+
);
|
|
103
|
+
return [...here, ...deeper.flat()];
|
|
104
|
+
}
|
|
105
|
+
if (segment.includes('*')) {
|
|
106
|
+
const matcher = new RegExp(
|
|
107
|
+
`^${segment.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*')}$`,
|
|
108
|
+
);
|
|
109
|
+
const children = (await childDirectories(directory)).filter((child) =>
|
|
110
|
+
matcher.test(child.slice(directory.length + 1)),
|
|
111
|
+
);
|
|
112
|
+
return (await Promise.all(children.map((child) => expandWorkspacePattern(child, rest)))).flat();
|
|
87
113
|
}
|
|
88
|
-
return
|
|
114
|
+
return expandWorkspacePattern(join(directory, segment), rest);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The manifests Bun installs from: the root and its workspaces. A package.json
|
|
119
|
+
* outside them (a frozen copy kept as audit evidence, a fixture) is not
|
|
120
|
+
* installed from this lockfile, so it is not this repository's dependency state.
|
|
121
|
+
*/
|
|
122
|
+
async function collectManifests(root, rootManifest) {
|
|
123
|
+
const workspaces = rootManifest.workspaces;
|
|
124
|
+
const patterns = Array.isArray(workspaces) ? workspaces : (workspaces?.packages ?? []);
|
|
125
|
+
const directories = new Set([root]);
|
|
126
|
+
const excluded = new Set();
|
|
127
|
+
for (const pattern of patterns) {
|
|
128
|
+
const negated = pattern.startsWith('!');
|
|
129
|
+
const segments = (negated ? pattern.slice(1) : pattern)
|
|
130
|
+
.replace(/^\.\//, '')
|
|
131
|
+
.split('/')
|
|
132
|
+
.filter(Boolean);
|
|
133
|
+
for (const directory of await expandWorkspacePattern(root, segments))
|
|
134
|
+
(negated ? excluded : directories).add(directory);
|
|
135
|
+
}
|
|
136
|
+
return [...directories]
|
|
137
|
+
.filter((directory) => !excluded.has(directory))
|
|
138
|
+
.map((directory) => join(directory, 'package.json'))
|
|
139
|
+
.filter((path) => existsSync(path))
|
|
140
|
+
.sort();
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** `catalog:` / `catalog:<name>` resolved against the root manifest's catalogs. */
|
|
144
|
+
function catalogEntries(rootManifest) {
|
|
145
|
+
const workspaces = Array.isArray(rootManifest.workspaces) ? {} : (rootManifest.workspaces ?? {});
|
|
146
|
+
const rows = [];
|
|
147
|
+
const add = (section, catalog) => {
|
|
148
|
+
for (const [name, range] of Object.entries(catalog ?? {})) rows.push({ section, name, range });
|
|
149
|
+
};
|
|
150
|
+
add('catalog', workspaces.catalog ?? rootManifest.catalog);
|
|
151
|
+
for (const [catalogName, catalog] of Object.entries(
|
|
152
|
+
workspaces.catalogs ?? rootManifest.catalogs ?? {},
|
|
153
|
+
))
|
|
154
|
+
add(`catalogs.${catalogName}`, catalog);
|
|
155
|
+
return rows;
|
|
156
|
+
}
|
|
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
|
+
};
|
|
89
226
|
}
|
|
90
227
|
|
|
91
228
|
/**
|
|
@@ -98,8 +235,16 @@ export async function inspectRepository(
|
|
|
98
235
|
fetchLatest,
|
|
99
236
|
{ graceDays = DEFAULT_GRACE_DAYS, now = new Date() } = {},
|
|
100
237
|
) {
|
|
101
|
-
const
|
|
238
|
+
const rootManifest = JSON.parse(await readFile(join(root, 'package.json'), 'utf8'));
|
|
239
|
+
const manifestPaths = await collectManifests(root, rootManifest);
|
|
102
240
|
const usages = new Map();
|
|
241
|
+
const findings = [];
|
|
242
|
+
for (const { section, name, range } of catalogEntries(rootManifest)) {
|
|
243
|
+
if (!isFirstPartyPackage(name)) continue;
|
|
244
|
+
const rows = usages.get(name) ?? [];
|
|
245
|
+
rows.push({ manifest: 'package.json', section, range });
|
|
246
|
+
usages.set(name, rows);
|
|
247
|
+
}
|
|
103
248
|
for (const path of manifestPaths) {
|
|
104
249
|
const manifest = JSON.parse(await readFile(path, 'utf8'));
|
|
105
250
|
for (const section of [
|
|
@@ -109,16 +254,27 @@ export async function inspectRepository(
|
|
|
109
254
|
'optionalDependencies',
|
|
110
255
|
]) {
|
|
111
256
|
for (const [name, range] of Object.entries(manifest[section] ?? {})) {
|
|
112
|
-
if (
|
|
257
|
+
if (
|
|
258
|
+
!isFirstPartyPackage(name) ||
|
|
259
|
+
range.startsWith('workspace:') ||
|
|
260
|
+
range.startsWith('catalog:')
|
|
261
|
+
)
|
|
262
|
+
continue;
|
|
113
263
|
const rows = usages.get(name) ?? [];
|
|
114
264
|
rows.push({ manifest: path.slice(root.length + 1), section, range });
|
|
115
265
|
usages.set(name, rows);
|
|
116
266
|
}
|
|
117
267
|
}
|
|
118
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
|
+
}
|
|
119
276
|
const lockPath = join(root, 'bun.lock');
|
|
120
277
|
const lockfile = existsSync(lockPath) ? await readFile(lockPath, 'utf8') : null;
|
|
121
|
-
const findings = [];
|
|
122
278
|
if (!lockfile)
|
|
123
279
|
findings.push({ severity: 'error', code: 'missing-lockfile', message: 'bun.lock is missing' });
|
|
124
280
|
await Promise.all(
|
|
@@ -165,7 +321,13 @@ export async function inspectRepository(
|
|
|
165
321
|
}
|
|
166
322
|
}),
|
|
167
323
|
);
|
|
168
|
-
return {
|
|
324
|
+
return {
|
|
325
|
+
root,
|
|
326
|
+
manifests: manifestPaths.length,
|
|
327
|
+
packages: usages.size,
|
|
328
|
+
findings,
|
|
329
|
+
nativePeersUnverified: unverified,
|
|
330
|
+
};
|
|
169
331
|
}
|
|
170
332
|
|
|
171
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
|
}
|