@detergent-software/atk 9.0.0-dev.2 → 9.0.0-dev.3
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/CHANGELOG.md +11 -0
- package/build/command-manifest.json +10 -0
- package/build/commands/doctor.d.ts +3 -1
- package/build/commands/doctor.d.ts.map +1 -1
- package/build/commands/doctor.js +121 -4
- package/build/commands/doctor.js.map +1 -1
- package/build/components/DiagnosticList.d.ts +4 -0
- package/build/components/DiagnosticList.d.ts.map +1 -1
- package/build/components/DiagnosticList.js +1 -1
- package/build/components/DiagnosticList.js.map +1 -1
- package/build/lib/diagnostics.d.ts +34 -0
- package/build/lib/diagnostics.d.ts.map +1 -1
- package/build/lib/diagnostics.js +433 -50
- package/build/lib/diagnostics.js.map +1 -1
- package/build/lib/doctor-fix.d.ts +33 -0
- package/build/lib/doctor-fix.d.ts.map +1 -0
- package/build/lib/doctor-fix.js +153 -0
- package/build/lib/doctor-fix.js.map +1 -0
- package/build/lib/registry.d.ts +5 -0
- package/build/lib/registry.d.ts.map +1 -1
- package/build/lib/registry.js +11 -0
- package/build/lib/registry.js.map +1 -1
- package/build/lib/update-check.d.ts +17 -0
- package/build/lib/update-check.d.ts.map +1 -1
- package/build/lib/update-check.js +36 -4
- package/build/lib/update-check.js.map +1 -1
- package/package.json +1 -1
package/build/lib/diagnostics.js
CHANGED
|
@@ -1,20 +1,81 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs';
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
2
|
+
import { gt as semverGt, valid as semverValid } from 'semver';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import { adapterAsPlaced, adapterForScope, loadAdapter, resolveInstalledPaths } from './adapter.js';
|
|
5
|
+
import { getAtkForToken, resolveApiUrl } from './api-client.js';
|
|
6
|
+
import { declarationAllows, isExcludedAtVersion, isExcludedForTool, isExcludedForTools, isUndeclaredForTool, } from './asset-tools.js';
|
|
7
|
+
import { isAtkError } from './atk-api/index.js';
|
|
8
|
+
import { getCredential, getLastEntraSession, getTokenExpiry } from './auth.js';
|
|
6
9
|
import { isFrozenInConfig, loadConfig } from './config.js';
|
|
7
10
|
import { crossScopeInstall, describeClashes } from './cross-scope.js';
|
|
8
11
|
import { detectTools, getAdapterDiagnostics } from './detector.js';
|
|
9
12
|
import { getEntraCachePath } from './entra.js';
|
|
10
|
-
import { errorMessage } from './errors.js';
|
|
13
|
+
import { errorGuidance, errorMessage } from './errors.js';
|
|
14
|
+
import { formatAge } from './format-age.js';
|
|
11
15
|
import { hookScriptPath, lacksExecBit } from './hook-script.js';
|
|
12
16
|
import { identityKey, lockfileExists, readLockfile } from './lockfile.js';
|
|
13
17
|
import { isOffline } from './offline.js';
|
|
14
18
|
import { formatOrgName } from './org.js';
|
|
15
|
-
import { fetchRegistry, versionTools } from './registry.js';
|
|
19
|
+
import { fetchRegistry, getCacheStatus, isCacheReadable, isDeprecated, versionTools } from './registry.js';
|
|
16
20
|
import { fromRoot } from './root-path.js';
|
|
17
21
|
import { loadScopeConfig, resolveScope, resolveUserRoot, scopedCommand } from './scope.js';
|
|
22
|
+
import { getVersionStatus } from './update-check.js';
|
|
23
|
+
import { CLI_VERSION, isPreRelease, upgradeCommand } from './version.js';
|
|
24
|
+
/** How long the `/health` probe waits for the ATK API before it fails the check. */
|
|
25
|
+
export const HEALTH_TIMEOUT_MS = 5_000;
|
|
26
|
+
const HealthBodySchema = z.object({
|
|
27
|
+
environment: z.string().optional(),
|
|
28
|
+
status: z.string(),
|
|
29
|
+
version: z.string().optional(),
|
|
30
|
+
});
|
|
31
|
+
/**
|
|
32
|
+
* Call the ATK API's anonymous `GET /health` once, with no bearer and no retries, so a down API
|
|
33
|
+
* fails the check in {@link HEALTH_TIMEOUT_MS} at most. It doesn't go through the process-wide
|
|
34
|
+
* client, whose connection-failure latch would then fail the registry check too.
|
|
35
|
+
*/
|
|
36
|
+
export async function checkApiHealth(apiUrl, source) {
|
|
37
|
+
const id = 'api';
|
|
38
|
+
const name = 'ATK API';
|
|
39
|
+
const url = `${apiUrl.replace(/\/+$/, '')}/health`;
|
|
40
|
+
const advice = source ? `${source}. Check that URL and your network connection.` : 'Check your network connection.';
|
|
41
|
+
let response;
|
|
42
|
+
try {
|
|
43
|
+
response = await globalThis.fetch(url, {
|
|
44
|
+
headers: { accept: 'application/json', 'X-Atk-Cli-Version': CLI_VERSION },
|
|
45
|
+
signal: AbortSignal.timeout(HEALTH_TIMEOUT_MS),
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
catch (err) {
|
|
49
|
+
const timedOut = err instanceof Error && err.name === 'TimeoutError';
|
|
50
|
+
return {
|
|
51
|
+
detail: advice,
|
|
52
|
+
id,
|
|
53
|
+
message: timedOut
|
|
54
|
+
? `${url} did not answer within ${HEALTH_TIMEOUT_MS / 1000}s`
|
|
55
|
+
: `Couldn't reach ${url}: ${describeFetchFailure(err)}`,
|
|
56
|
+
name,
|
|
57
|
+
status: 'fail',
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
const body = HealthBodySchema.safeParse(await response.json().catch(() => undefined));
|
|
61
|
+
if (!response.ok || !body.success || body.data.status !== 'ok') {
|
|
62
|
+
return {
|
|
63
|
+
detail: advice,
|
|
64
|
+
id,
|
|
65
|
+
message: `${url} answered HTTP ${response.status}${body.success ? ` (status: ${body.data.status})` : ''}`,
|
|
66
|
+
name,
|
|
67
|
+
status: 'fail',
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
const about = [body.data.environment, body.data.version].filter(Boolean).join(', ');
|
|
71
|
+
return {
|
|
72
|
+
...(source ? { detail: source } : {}),
|
|
73
|
+
id,
|
|
74
|
+
message: about ? `${apiUrl} (${about})` : apiUrl,
|
|
75
|
+
name,
|
|
76
|
+
status: 'pass',
|
|
77
|
+
};
|
|
78
|
+
}
|
|
18
79
|
/**
|
|
19
80
|
* Check for assets installed both in the project and at the user scope, with the version at each and
|
|
20
81
|
* which copy each tool that has both uses. A copy uninstalled for every tool of its scope doesn't
|
|
@@ -39,11 +100,79 @@ export async function checkBothScopes(project, user) {
|
|
|
39
100
|
const one = lines.length === 1;
|
|
40
101
|
return {
|
|
41
102
|
detail: `${lines.join('; ')}. Run \`atk uninstall <name> --type <type>\` (with \`--org <org>\` for an org asset), adding \`--user\` for the user copy, to keep one copy.`,
|
|
103
|
+
id: 'both-scopes',
|
|
42
104
|
message: `${lines.length} asset${one ? ' is' : 's are'} installed at both scopes`,
|
|
43
105
|
name: 'Both scopes',
|
|
44
106
|
status: 'warn',
|
|
45
107
|
};
|
|
46
108
|
}
|
|
109
|
+
/**
|
|
110
|
+
* Compare this CLI with its release channel on npm: `dev` for a prerelease, `latest` otherwise.
|
|
111
|
+
* A CLI ahead of npm (a local build) passes. `offline` (`--offline`): npm isn't asked, so only
|
|
112
|
+
* a cached version is compared.
|
|
113
|
+
*/
|
|
114
|
+
export function checkCliVersion(status, offline = false) {
|
|
115
|
+
const { channel, currentVersion, enabled, latestVersion } = status;
|
|
116
|
+
const id = 'cli-version';
|
|
117
|
+
const name = 'CLI version';
|
|
118
|
+
if (!enabled) {
|
|
119
|
+
return {
|
|
120
|
+
detail: 'Not compared with npm: checkUpdates is false in the config.',
|
|
121
|
+
id,
|
|
122
|
+
message: `v${currentVersion}`,
|
|
123
|
+
name,
|
|
124
|
+
status: 'pass',
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
if (!latestVersion || !semverValid(latestVersion) || !semverValid(currentVersion)) {
|
|
128
|
+
return {
|
|
129
|
+
...(offline ? {} : { detail: 'Check your network connection and run `atk doctor` again.' }),
|
|
130
|
+
id,
|
|
131
|
+
message: offline
|
|
132
|
+
? `v${currentVersion} (not compared with npm with --offline)`
|
|
133
|
+
: `v${currentVersion} (couldn't read the npm ${channel} version)`,
|
|
134
|
+
name,
|
|
135
|
+
status: 'warn',
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
if (semverGt(latestVersion, currentVersion)) {
|
|
139
|
+
const command = upgradeCommand(channel);
|
|
140
|
+
return {
|
|
141
|
+
command,
|
|
142
|
+
detail: `Run \`${command}\` to update.`,
|
|
143
|
+
id,
|
|
144
|
+
message: `v${currentVersion} (v${latestVersion} is on npm ${channel})`,
|
|
145
|
+
name,
|
|
146
|
+
status: 'warn',
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
return { id, message: `v${currentVersion} (current on npm ${channel})`, name, status: 'pass' };
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Check for installed assets the registry deprecates, with each one's reason and replacement.
|
|
153
|
+
* Returns undefined when there are none.
|
|
154
|
+
*/
|
|
155
|
+
export function checkDeprecated(registry, assets) {
|
|
156
|
+
const lines = [];
|
|
157
|
+
for (const asset of assets) {
|
|
158
|
+
const deprecation = isDeprecated(registry, asset.name, asset.type, asset.org);
|
|
159
|
+
if (!deprecation.deprecated)
|
|
160
|
+
continue;
|
|
161
|
+
const reason = deprecation.reason ? `: ${deprecation.reason}` : '';
|
|
162
|
+
const replacement = deprecation.replacement ? ` (use ${deprecation.replacement})` : '';
|
|
163
|
+
lines.push(`${formatOrgName(asset.org, asset.name)} (${asset.type})${reason}${replacement}`);
|
|
164
|
+
}
|
|
165
|
+
if (lines.length === 0)
|
|
166
|
+
return undefined;
|
|
167
|
+
const one = lines.length === 1;
|
|
168
|
+
return {
|
|
169
|
+
detail: `${lines.join('; ')}. Install the replacement, then remove the deprecated asset with \`atk uninstall <name> --type <type>\`.`,
|
|
170
|
+
id: 'deprecated',
|
|
171
|
+
message: `${lines.length} installed asset${one ? ' is' : 's are'} deprecated`,
|
|
172
|
+
name: 'Deprecated assets',
|
|
173
|
+
status: 'warn',
|
|
174
|
+
};
|
|
175
|
+
}
|
|
47
176
|
/**
|
|
48
177
|
* Check that the hook scripts a configured tool runs directly are executable. Returns
|
|
49
178
|
* undefined when there is nothing to check: on Windows, which has no exec bit, or when
|
|
@@ -60,7 +189,7 @@ export async function checkHookScripts(projectDir, tools, assets, platform = pro
|
|
|
60
189
|
adapter = adapterForScope(await loadAdapter(tool), scope ?? 'project', projectDir);
|
|
61
190
|
}
|
|
62
191
|
catch {
|
|
63
|
-
// The
|
|
192
|
+
// The tool's adapter check already reports adapters that fail to load.
|
|
64
193
|
continue;
|
|
65
194
|
}
|
|
66
195
|
for (const asset of assets) {
|
|
@@ -82,13 +211,17 @@ export async function checkHookScripts(projectDir, tools, assets, platform = pro
|
|
|
82
211
|
}
|
|
83
212
|
if (notExecutable.length === 0) {
|
|
84
213
|
return {
|
|
214
|
+
id: 'hook-scripts',
|
|
85
215
|
message: `${scripts.size} hook script${scripts.size === 1 ? '' : 's'} executable`,
|
|
86
216
|
name: 'Hook scripts',
|
|
87
217
|
status: 'pass',
|
|
88
218
|
};
|
|
89
219
|
}
|
|
220
|
+
const sync = scopedCommand('atk sync', scope);
|
|
90
221
|
return {
|
|
91
|
-
|
|
222
|
+
command: sync,
|
|
223
|
+
detail: `${notExecutable.join(', ')}. Run \`${sync}\` to reinstall them, or \`chmod +x\` the script of a frozen asset.`,
|
|
224
|
+
id: 'hook-scripts',
|
|
92
225
|
message: `${notExecutable.length} hook script${notExecutable.length === 1 ? ' is' : 's are'} not executable`,
|
|
93
226
|
name: 'Hook scripts',
|
|
94
227
|
status: 'warn',
|
|
@@ -115,13 +248,73 @@ export function checkLiftedHooks(projectConfig, assets, registry, scope) {
|
|
|
115
248
|
if (lifted.length === 0)
|
|
116
249
|
return undefined;
|
|
117
250
|
const one = lifted.length === 1;
|
|
251
|
+
const sync = scopedCommand('atk sync', scope);
|
|
118
252
|
return {
|
|
119
|
-
|
|
253
|
+
command: sync,
|
|
254
|
+
detail: `${lifted.join(', ')}. ${one ? 'Its locked version now declares that tool' : 'Their locked versions now declare those tools'}. Run \`${sync}\` to place ${one ? 'it' : 'them'}.`,
|
|
255
|
+
id: 'hooks-to-place',
|
|
120
256
|
message: `${lifted.length} hook${one ? ' is' : 's are'} not placed yet`,
|
|
121
257
|
name: 'Hooks to place',
|
|
122
258
|
status: 'warn',
|
|
123
259
|
};
|
|
124
260
|
}
|
|
261
|
+
/**
|
|
262
|
+
* Check that each installed asset's placed files are on disk for every configured tool, as the
|
|
263
|
+
* scope's adapter places them. Frozen assets, those uninstalled for a tool and hooks whose locked
|
|
264
|
+
* version doesn't declare the tool (with `registry`) are left out, as `atk audit` and `atk sync`
|
|
265
|
+
* leave them. Only presence is checked; `atk audit` verifies content. Returns undefined when there
|
|
266
|
+
* is nothing to check. The sync command it suggests runs in `scope`.
|
|
267
|
+
*/
|
|
268
|
+
export async function checkMissingFiles(root, tools, assets, projectConfig, scope, registry) {
|
|
269
|
+
const missing = [];
|
|
270
|
+
let checked = 0;
|
|
271
|
+
for (const tool of tools) {
|
|
272
|
+
let adapter;
|
|
273
|
+
try {
|
|
274
|
+
adapter = adapterForScope(await loadAdapter(tool), scope ?? 'project', root);
|
|
275
|
+
}
|
|
276
|
+
catch {
|
|
277
|
+
// The tool's adapter check reports it.
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
for (const asset of assets) {
|
|
281
|
+
if (isExcludedForTool(asset, tool))
|
|
282
|
+
continue;
|
|
283
|
+
if (isFrozenInConfig(projectConfig, asset.name, asset.type, asset.org))
|
|
284
|
+
continue;
|
|
285
|
+
const lockedTools = registry && asset.type === 'hook' ? versionTools(registry, asset, asset.version) : null;
|
|
286
|
+
if (isExcludedAtVersion(asset, tool, lockedTools) ||
|
|
287
|
+
!declarationAllows(asset.type, lockedTools ?? undefined, tool)) {
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
const { files } = resolveInstalledPaths(asset, adapterAsPlaced(adapter, asset), root);
|
|
291
|
+
checked += 1;
|
|
292
|
+
if (files.some((f) => !existsSync(fromRoot(root, f.installedPath)))) {
|
|
293
|
+
missing.push(`${formatOrgName(asset.org, asset.name)} (${asset.type}, ${tool})`);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
if (checked === 0)
|
|
298
|
+
return undefined;
|
|
299
|
+
if (missing.length === 0) {
|
|
300
|
+
return {
|
|
301
|
+
id: 'missing-files',
|
|
302
|
+
message: `Every installed asset's files are in place`,
|
|
303
|
+
name: 'Placed files',
|
|
304
|
+
status: 'pass',
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
const one = missing.length === 1;
|
|
308
|
+
const sync = scopedCommand('atk sync', scope);
|
|
309
|
+
return {
|
|
310
|
+
command: sync,
|
|
311
|
+
detail: `${missing.join(', ')}. Run \`${sync}\` to place ${one ? 'it' : 'them'} again.`,
|
|
312
|
+
id: 'missing-files',
|
|
313
|
+
message: `${missing.length} installed asset${one ? ' is' : 's are'} missing files`,
|
|
314
|
+
name: 'Placed files',
|
|
315
|
+
status: 'warn',
|
|
316
|
+
};
|
|
317
|
+
}
|
|
125
318
|
/**
|
|
126
319
|
* Check whether the given Node.js version string meets the project requirements.
|
|
127
320
|
*
|
|
@@ -132,6 +325,7 @@ export function checkNodeVersion(version) {
|
|
|
132
325
|
const nodeMajor = parseInt(version.slice(1), 10);
|
|
133
326
|
if (nodeMajor >= 24) {
|
|
134
327
|
return {
|
|
328
|
+
id: 'node',
|
|
135
329
|
message: `${version} (recommended)`,
|
|
136
330
|
name: 'Node.js',
|
|
137
331
|
status: 'pass',
|
|
@@ -139,6 +333,7 @@ export function checkNodeVersion(version) {
|
|
|
139
333
|
}
|
|
140
334
|
else if (nodeMajor >= 20) {
|
|
141
335
|
return {
|
|
336
|
+
id: 'node',
|
|
142
337
|
message: `${version} (Node 24+ recommended)`,
|
|
143
338
|
name: 'Node.js',
|
|
144
339
|
status: 'warn',
|
|
@@ -146,12 +341,49 @@ export function checkNodeVersion(version) {
|
|
|
146
341
|
}
|
|
147
342
|
else {
|
|
148
343
|
return {
|
|
344
|
+
id: 'node',
|
|
149
345
|
message: `${version} (Node 20+ required, 24+ recommended)`,
|
|
150
346
|
name: 'Node.js',
|
|
151
347
|
status: 'fail',
|
|
152
348
|
};
|
|
153
349
|
}
|
|
154
350
|
}
|
|
351
|
+
/**
|
|
352
|
+
* Check the registry cache file: its age against the TTL, and whether it parses. An expired cache
|
|
353
|
+
* passes (the next read refreshes it); one that doesn't parse fails, with `atk cache clear` to
|
|
354
|
+
* remove it. Run after the registry check, which replaces an unreadable cache when it can fetch.
|
|
355
|
+
*/
|
|
356
|
+
export async function checkRegistryCache() {
|
|
357
|
+
const id = 'cache';
|
|
358
|
+
const name = 'Registry cache';
|
|
359
|
+
try {
|
|
360
|
+
const status = await getCacheStatus();
|
|
361
|
+
if (!status.exists) {
|
|
362
|
+
return { detail: status.path, id, message: 'No cached registry yet', name, status: 'pass' };
|
|
363
|
+
}
|
|
364
|
+
if ((await isCacheReadable()) === false) {
|
|
365
|
+
return {
|
|
366
|
+
command: 'atk cache clear',
|
|
367
|
+
detail: `${status.path}. Run \`atk cache clear\` to remove it; the next command fetches a fresh copy.`,
|
|
368
|
+
id,
|
|
369
|
+
message: 'The cached registry does not parse',
|
|
370
|
+
name,
|
|
371
|
+
status: 'fail',
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
const expired = status.expired ? ', expired: the next read refreshes it' : '';
|
|
375
|
+
return {
|
|
376
|
+
detail: status.path,
|
|
377
|
+
id,
|
|
378
|
+
message: `${formatAge(status.ageMs)} old (TTL ${status.ttlMinutes}m${expired})`,
|
|
379
|
+
name,
|
|
380
|
+
status: 'pass',
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
catch (err) {
|
|
384
|
+
return { id, message: errorMessage(err), name, status: 'warn' };
|
|
385
|
+
}
|
|
386
|
+
}
|
|
155
387
|
/**
|
|
156
388
|
* Run all environment diagnostic checks and return the results.
|
|
157
389
|
* Extracted from the `doctor` command so it can be tested independently.
|
|
@@ -172,6 +404,7 @@ export async function runDiagnostics(progress, { projectRoot, user } = {}) {
|
|
|
172
404
|
try {
|
|
173
405
|
config = await loadConfig();
|
|
174
406
|
checks.push({
|
|
407
|
+
id: 'config',
|
|
175
408
|
message: 'Loaded',
|
|
176
409
|
name: 'Config',
|
|
177
410
|
status: 'pass',
|
|
@@ -179,49 +412,69 @@ export async function runDiagnostics(progress, { projectRoot, user } = {}) {
|
|
|
179
412
|
}
|
|
180
413
|
catch (err) {
|
|
181
414
|
checks.push({
|
|
415
|
+
id: 'config',
|
|
182
416
|
message: errorMessage(err),
|
|
183
417
|
name: 'Config',
|
|
184
418
|
status: 'fail',
|
|
185
419
|
});
|
|
186
420
|
}
|
|
187
|
-
//
|
|
188
|
-
progress('
|
|
421
|
+
// Call the ATK API the CLI will talk to (not offline: that is the one thing --offline rules out)
|
|
422
|
+
progress('Checking the ATK API...');
|
|
189
423
|
const apiUrl = resolveApiUrl(config);
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
424
|
+
const apiSource = process.env['ATK_API_URL']
|
|
425
|
+
? 'From ATK_API_URL'
|
|
426
|
+
: config?.apiUrl
|
|
427
|
+
? 'From apiUrl in config'
|
|
428
|
+
: undefined;
|
|
429
|
+
const apiCheck = isOffline()
|
|
430
|
+
? {
|
|
431
|
+
...(apiSource ? { detail: apiSource } : {}),
|
|
432
|
+
id: 'api',
|
|
433
|
+
message: `${apiUrl} (not checked with --offline)`,
|
|
434
|
+
name: 'ATK API',
|
|
435
|
+
status: 'warn',
|
|
436
|
+
}
|
|
437
|
+
: await checkApiHealth(apiUrl, apiSource);
|
|
438
|
+
checks.push(apiCheck);
|
|
439
|
+
progress('Checking the CLI version...');
|
|
440
|
+
checks.push(checkCliVersion(await getVersionStatus(), isOffline()));
|
|
441
|
+
// Check authentication against the ATK API (not offline: validating the credential is an API call).
|
|
442
|
+
// With the API down, a validation would read as a rejected credential and send --fix to sign in again.
|
|
197
443
|
progress('Checking authentication...');
|
|
198
444
|
checks.push(isOffline()
|
|
199
|
-
? { message: 'Not checked with --offline', name: 'Authentication', status: 'warn' }
|
|
200
|
-
: await checkAuthentication());
|
|
201
|
-
// Check registry access
|
|
445
|
+
? { id: 'auth', message: 'Not checked with --offline', name: 'Authentication', status: 'warn' }
|
|
446
|
+
: await checkAuthentication(apiUrl, apiCheck.status !== 'fail'));
|
|
447
|
+
// Check registry access, through the cache as every command reads it (offline, the cache of any age)
|
|
202
448
|
progress('Checking registry access...');
|
|
449
|
+
const servedFromCache = isOffline() || (await cacheWillServe());
|
|
203
450
|
let registry;
|
|
204
451
|
try {
|
|
205
|
-
registry = await fetchRegistry({
|
|
452
|
+
registry = isOffline() ? await fetchRegistry({ stale: true }) : await fetchRegistry();
|
|
206
453
|
checks.push({
|
|
207
|
-
|
|
454
|
+
id: 'registry',
|
|
455
|
+
message: `Accessible (${registry.assets.length} assets, ${(registry.bundles ?? []).length} bundles; ${servedFromCache ? 'from the cache' : 'fetched from the ATK API'})`,
|
|
208
456
|
name: 'Registry',
|
|
209
457
|
status: 'pass',
|
|
210
458
|
});
|
|
211
459
|
}
|
|
212
460
|
catch (err) {
|
|
213
461
|
checks.push({
|
|
462
|
+
id: 'registry',
|
|
214
463
|
message: errorMessage(err),
|
|
215
464
|
name: 'Registry',
|
|
216
465
|
status: 'fail',
|
|
217
466
|
});
|
|
218
467
|
}
|
|
468
|
+
progress('Checking the registry cache...');
|
|
469
|
+
checks.push(await checkRegistryCache());
|
|
219
470
|
// Check tool detection
|
|
220
471
|
progress('Detecting tools...');
|
|
221
472
|
const detectedTools = await detectTools(projectDir);
|
|
473
|
+
const setup = scopedCommand('atk setup', scope);
|
|
222
474
|
if (detectedTools.length > 0) {
|
|
223
475
|
const toolNames = detectedTools.map((t) => `${t.displayName} (${t.confidence})`).join(', ');
|
|
224
476
|
checks.push({
|
|
477
|
+
id: 'tools',
|
|
225
478
|
message: toolNames,
|
|
226
479
|
name: 'Tool detection',
|
|
227
480
|
status: 'pass',
|
|
@@ -229,8 +482,9 @@ export async function runDiagnostics(progress, { projectRoot, user } = {}) {
|
|
|
229
482
|
}
|
|
230
483
|
else {
|
|
231
484
|
checks.push({
|
|
232
|
-
detail:
|
|
233
|
-
|
|
485
|
+
detail: `Run atk from a project that uses a supported tool, or run \`${setup}\` to choose one.`,
|
|
486
|
+
id: 'tools',
|
|
487
|
+
message: `No agentic tool detected in ${scope === 'user' ? 'the user root' : 'the project'}`,
|
|
234
488
|
name: 'Tool detection',
|
|
235
489
|
status: 'warn',
|
|
236
490
|
});
|
|
@@ -240,6 +494,7 @@ export async function runDiagnostics(progress, { projectRoot, user } = {}) {
|
|
|
240
494
|
const adapterDiag = await getAdapterDiagnostics();
|
|
241
495
|
if (!adapterDiag.directoryExists) {
|
|
242
496
|
checks.push({
|
|
497
|
+
id: 'adapters',
|
|
243
498
|
message: `Adapters directory not found at: ${adapterDiag.adaptersDir}`,
|
|
244
499
|
name: 'Adapters',
|
|
245
500
|
status: 'warn',
|
|
@@ -249,6 +504,7 @@ export async function runDiagnostics(progress, { projectRoot, user } = {}) {
|
|
|
249
504
|
const failDetails = adapterDiag.failures.map((f) => `${f.filename}: ${f.reason}`).join('; ');
|
|
250
505
|
checks.push({
|
|
251
506
|
detail: failDetails,
|
|
507
|
+
id: 'adapters',
|
|
252
508
|
message: `${adapterDiag.failures.length} adapter(s) failed to load`,
|
|
253
509
|
name: 'Adapters',
|
|
254
510
|
status: 'warn',
|
|
@@ -256,6 +512,7 @@ export async function runDiagnostics(progress, { projectRoot, user } = {}) {
|
|
|
256
512
|
}
|
|
257
513
|
else {
|
|
258
514
|
checks.push({
|
|
515
|
+
id: 'adapters',
|
|
259
516
|
message: `${adapterDiag.adaptersLoaded} adapter(s) loaded`,
|
|
260
517
|
name: 'Adapters',
|
|
261
518
|
status: 'pass',
|
|
@@ -269,7 +526,7 @@ export async function runDiagnostics(progress, { projectRoot, user } = {}) {
|
|
|
269
526
|
}
|
|
270
527
|
// Check the scope's config: the project's .atkrc.json, or the user scope's tools in the user config
|
|
271
528
|
const configName = scope === 'user' ? 'User scope config' : 'Project config';
|
|
272
|
-
const
|
|
529
|
+
const configId = scope === 'user' ? 'user-scope-config' : 'project-config';
|
|
273
530
|
const setupDetail = `Run \`${setup}\` to initialize ${configName.toLowerCase()}.`;
|
|
274
531
|
progress(`Checking ${configName.toLowerCase()}...`);
|
|
275
532
|
let configuredTools = [];
|
|
@@ -279,6 +536,7 @@ export async function runDiagnostics(progress, { projectRoot, user } = {}) {
|
|
|
279
536
|
if (projectConfig) {
|
|
280
537
|
configuredTools = projectConfig.tools;
|
|
281
538
|
checks.push({
|
|
539
|
+
id: configId,
|
|
282
540
|
message: projectConfig.tools.length === 0
|
|
283
541
|
? 'Loaded (no tools configured)'
|
|
284
542
|
: `Loaded (tools: ${projectConfig.tools.join(', ')})`,
|
|
@@ -288,7 +546,9 @@ export async function runDiagnostics(progress, { projectRoot, user } = {}) {
|
|
|
288
546
|
}
|
|
289
547
|
else {
|
|
290
548
|
checks.push({
|
|
549
|
+
command: setup,
|
|
291
550
|
detail: setupDetail,
|
|
551
|
+
id: configId,
|
|
292
552
|
message: `No ${configName.toLowerCase()} found`,
|
|
293
553
|
name: configName,
|
|
294
554
|
status: 'warn',
|
|
@@ -297,18 +557,27 @@ export async function runDiagnostics(progress, { projectRoot, user } = {}) {
|
|
|
297
557
|
}
|
|
298
558
|
catch (err) {
|
|
299
559
|
checks.push({
|
|
560
|
+
command: setup,
|
|
300
561
|
detail: setupDetail,
|
|
562
|
+
id: configId,
|
|
301
563
|
message: errorMessage(err),
|
|
302
564
|
name: configName,
|
|
303
565
|
status: 'warn',
|
|
304
566
|
});
|
|
305
567
|
}
|
|
568
|
+
// Each configured tool's adapter: the one the scope's commands place with
|
|
569
|
+
progress('Checking configured tools...');
|
|
570
|
+
for (const tool of configuredTools) {
|
|
571
|
+
checks.push(await checkToolAdapter(tool, projectDir));
|
|
572
|
+
}
|
|
306
573
|
// Check lockfile
|
|
307
574
|
progress('Checking lockfile...');
|
|
308
575
|
let installedAssets = [];
|
|
309
576
|
if (!lockfileExists(projectDir)) {
|
|
310
577
|
checks.push({
|
|
578
|
+
command: setup,
|
|
311
579
|
detail: `Run \`${setup}\` to initialize`,
|
|
580
|
+
id: 'lockfile',
|
|
312
581
|
message: 'No lockfile found',
|
|
313
582
|
name: 'Lockfile',
|
|
314
583
|
status: 'warn',
|
|
@@ -320,6 +589,7 @@ export async function runDiagnostics(progress, { projectRoot, user } = {}) {
|
|
|
320
589
|
installedAssets = lockfile.assets ?? [];
|
|
321
590
|
const assetCount = installedAssets.length;
|
|
322
591
|
checks.push({
|
|
592
|
+
id: 'lockfile',
|
|
323
593
|
message: `Found .atk-lock.json (${assetCount} asset${assetCount === 1 ? '' : 's'})`,
|
|
324
594
|
name: 'Lockfile',
|
|
325
595
|
status: 'pass',
|
|
@@ -327,21 +597,25 @@ export async function runDiagnostics(progress, { projectRoot, user } = {}) {
|
|
|
327
597
|
}
|
|
328
598
|
catch (err) {
|
|
329
599
|
checks.push({
|
|
600
|
+
command: setup,
|
|
330
601
|
detail: `Run \`${setup}\` to re-initialize`,
|
|
602
|
+
id: 'lockfile',
|
|
331
603
|
message: errorMessage(err),
|
|
332
604
|
name: 'Lockfile',
|
|
333
605
|
status: 'warn',
|
|
334
606
|
});
|
|
335
607
|
}
|
|
336
608
|
}
|
|
609
|
+
progress('Checking placed files...');
|
|
610
|
+
const missingFiles = await checkMissingFiles(projectDir, configuredTools, installedAssets, projectConfig, scope, registry);
|
|
611
|
+
const deprecated = registry && checkDeprecated(registry, installedAssets);
|
|
337
612
|
progress('Checking hook scripts...');
|
|
338
613
|
const hookScripts = await checkHookScripts(projectDir, configuredTools, installedAssets, process.platform, scope);
|
|
339
|
-
if (hookScripts) {
|
|
340
|
-
checks.push(hookScripts);
|
|
341
|
-
}
|
|
342
614
|
const liftedHooks = registry && projectConfig && checkLiftedHooks(projectConfig, installedAssets, registry, scope);
|
|
343
|
-
|
|
344
|
-
|
|
615
|
+
for (const check of [missingFiles, deprecated, hookScripts, liftedHooks]) {
|
|
616
|
+
if (check) {
|
|
617
|
+
checks.push(check);
|
|
618
|
+
}
|
|
345
619
|
}
|
|
346
620
|
// In a project, the user scope's installs sit beside the project's, so check them too
|
|
347
621
|
if (scope === 'project') {
|
|
@@ -353,6 +627,29 @@ export async function runDiagnostics(progress, { projectRoot, user } = {}) {
|
|
|
353
627
|
}
|
|
354
628
|
return checks;
|
|
355
629
|
}
|
|
630
|
+
/** Whether the next registry read is served from the cache: a cache within its TTL that parses. */
|
|
631
|
+
async function cacheWillServe() {
|
|
632
|
+
try {
|
|
633
|
+
const status = await getCacheStatus();
|
|
634
|
+
return status.exists && !status.expired && (await isCacheReadable()) === true;
|
|
635
|
+
}
|
|
636
|
+
catch {
|
|
637
|
+
return false;
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
/** One configured tool's adapter: whether it loads, with setup's command when it doesn't. */
|
|
641
|
+
async function checkToolAdapter(tool, root) {
|
|
642
|
+
const id = `adapter-${tool}`;
|
|
643
|
+
const name = `Adapter: ${tool}`;
|
|
644
|
+
try {
|
|
645
|
+
const adapter = await loadAdapter(tool, root);
|
|
646
|
+
return { id, message: `Loaded (${adapter.displayName})`, name, status: 'pass' };
|
|
647
|
+
}
|
|
648
|
+
catch (err) {
|
|
649
|
+
const { command } = errorGuidance(err);
|
|
650
|
+
return { ...(command ? { command } : {}), id, message: errorMessage(err), name, status: 'fail' };
|
|
651
|
+
}
|
|
652
|
+
}
|
|
356
653
|
/**
|
|
357
654
|
* Report the user root: its path, where it comes from (`ATK_USER_ROOT`, the `userRoot` config key
|
|
358
655
|
* or the home directory), and a warning when it doesn't exist. `root` is set when it can be checked.
|
|
@@ -363,7 +660,7 @@ async function checkUserRoot(config) {
|
|
|
363
660
|
root = await resolveUserRoot();
|
|
364
661
|
}
|
|
365
662
|
catch (err) {
|
|
366
|
-
return { check: { message: errorMessage(err), name: 'User root', status: 'warn' } };
|
|
663
|
+
return { check: { id: 'user-root', message: errorMessage(err), name: 'User root', status: 'warn' } };
|
|
367
664
|
}
|
|
368
665
|
const source = process.env['ATK_USER_ROOT']
|
|
369
666
|
? 'From ATK_USER_ROOT'
|
|
@@ -374,13 +671,14 @@ async function checkUserRoot(config) {
|
|
|
374
671
|
return {
|
|
375
672
|
check: {
|
|
376
673
|
detail: `${source}. Create it, or point ATK_USER_ROOT or the userRoot config key at an existing directory.`,
|
|
674
|
+
id: 'user-root',
|
|
377
675
|
message: `${root} does not exist`,
|
|
378
676
|
name: 'User root',
|
|
379
677
|
status: 'warn',
|
|
380
678
|
},
|
|
381
679
|
};
|
|
382
680
|
}
|
|
383
|
-
return { check: { detail: source, message: root, name: 'User root', status: 'pass' }, root };
|
|
681
|
+
return { check: { detail: source, id: 'user-root', message: root, name: 'User root', status: 'pass' }, root };
|
|
384
682
|
}
|
|
385
683
|
/**
|
|
386
684
|
* The user scope's checks for a project's doctor run: its config (tools), its lockfile, its hooks,
|
|
@@ -394,6 +692,7 @@ async function checkUserScope(userRoot, registry, project) {
|
|
|
394
692
|
userConfig = await loadScopeConfig(userRoot);
|
|
395
693
|
checks.push(userConfig
|
|
396
694
|
? {
|
|
695
|
+
id: 'user-scope-config',
|
|
397
696
|
message: userConfig.tools.length === 0
|
|
398
697
|
? 'Loaded (no tools configured)'
|
|
399
698
|
: `Loaded (tools: ${userConfig.tools.join(', ')})`,
|
|
@@ -402,6 +701,7 @@ async function checkUserScope(userRoot, registry, project) {
|
|
|
402
701
|
}
|
|
403
702
|
: {
|
|
404
703
|
detail: 'Run `atk setup --user` to install assets at the user scope.',
|
|
704
|
+
id: 'user-scope-config',
|
|
405
705
|
message: 'Not set up',
|
|
406
706
|
name: 'User scope config',
|
|
407
707
|
status: 'pass',
|
|
@@ -409,7 +709,9 @@ async function checkUserScope(userRoot, registry, project) {
|
|
|
409
709
|
}
|
|
410
710
|
catch (err) {
|
|
411
711
|
checks.push({
|
|
712
|
+
command: 'atk setup --user',
|
|
412
713
|
detail: 'Run `atk setup --user` to initialize user scope config.',
|
|
714
|
+
id: 'user-scope-config',
|
|
413
715
|
message: errorMessage(err),
|
|
414
716
|
name: 'User scope config',
|
|
415
717
|
status: 'warn',
|
|
@@ -417,12 +719,13 @@ async function checkUserScope(userRoot, registry, project) {
|
|
|
417
719
|
}
|
|
418
720
|
let userAssets = [];
|
|
419
721
|
if (!lockfileExists(userRoot)) {
|
|
420
|
-
checks.push({ message: 'None (no user installs)', name: 'User lockfile', status: 'pass' });
|
|
722
|
+
checks.push({ id: 'user-lockfile', message: 'None (no user installs)', name: 'User lockfile', status: 'pass' });
|
|
421
723
|
}
|
|
422
724
|
else {
|
|
423
725
|
try {
|
|
424
726
|
userAssets = (await readLockfile(userRoot)).assets ?? [];
|
|
425
727
|
checks.push({
|
|
728
|
+
id: 'user-lockfile',
|
|
426
729
|
message: `Found .atk-lock.json (${userAssets.length} asset${userAssets.length === 1 ? '' : 's'})`,
|
|
427
730
|
name: 'User lockfile',
|
|
428
731
|
status: 'pass',
|
|
@@ -430,7 +733,9 @@ async function checkUserScope(userRoot, registry, project) {
|
|
|
430
733
|
}
|
|
431
734
|
catch (err) {
|
|
432
735
|
checks.push({
|
|
736
|
+
command: 'atk setup --user',
|
|
433
737
|
detail: 'Run `atk setup --user` to re-initialize',
|
|
738
|
+
id: 'user-lockfile',
|
|
434
739
|
message: errorMessage(err),
|
|
435
740
|
name: 'User lockfile',
|
|
436
741
|
status: 'warn',
|
|
@@ -442,7 +747,7 @@ async function checkUserScope(userRoot, registry, project) {
|
|
|
442
747
|
const liftedHooks = registry && userConfig && checkLiftedHooks(userConfig, userAssets, registry, 'user');
|
|
443
748
|
for (const check of [hookScripts, liftedHooks]) {
|
|
444
749
|
if (check) {
|
|
445
|
-
checks.push({ ...check, name: `User ${check.name.toLowerCase()}` });
|
|
750
|
+
checks.push({ ...check, id: `user-${check.id}`, name: `User ${check.name.toLowerCase()}` });
|
|
446
751
|
}
|
|
447
752
|
}
|
|
448
753
|
const bothScopes = await checkBothScopes(project, { assets: userAssets, root: userRoot, tools: userTools });
|
|
@@ -456,45 +761,123 @@ const SOURCE_LABELS = {
|
|
|
456
761
|
entra: 'atk login session',
|
|
457
762
|
};
|
|
458
763
|
/**
|
|
459
|
-
* Resolve the credential chain and validate the result against `GET /me
|
|
460
|
-
*
|
|
461
|
-
*
|
|
764
|
+
* Resolve the credential chain and validate the result against `GET /me` (with exactly that
|
|
765
|
+
* token, never the process-cached one), with the token's
|
|
766
|
+
* expiry from its `exp` claim. When the chain is empty but an Entra session file exists, the
|
|
767
|
+
* detail says why the file yielded nothing, since that usually means `atk login` again. When the
|
|
768
|
+
* API didn't answer `/health`, the credential is described but not validated, and warns.
|
|
462
769
|
*/
|
|
463
|
-
async function checkAuthentication() {
|
|
770
|
+
async function checkAuthentication(apiUrl, apiAnswered) {
|
|
771
|
+
const id = 'auth';
|
|
464
772
|
const name = 'Authentication';
|
|
465
773
|
let credential;
|
|
466
774
|
try {
|
|
467
775
|
credential = await getCredential();
|
|
468
776
|
}
|
|
469
777
|
catch (err) {
|
|
778
|
+
const { command } = errorGuidance(err);
|
|
779
|
+
const session = getLastEntraSession();
|
|
780
|
+
if (!apiAnswered && session && !session.ok && session.reason === 'error') {
|
|
781
|
+
return {
|
|
782
|
+
detail: 'The ATK API check failed too, so this looks like the network rather than the session.',
|
|
783
|
+
id,
|
|
784
|
+
message: errorMessage(err),
|
|
785
|
+
name,
|
|
786
|
+
status: 'warn',
|
|
787
|
+
};
|
|
788
|
+
}
|
|
470
789
|
return {
|
|
790
|
+
command: command ?? 'atk login',
|
|
471
791
|
detail: staleEntraSessionNote(),
|
|
792
|
+
id,
|
|
472
793
|
message: errorMessage(err),
|
|
473
794
|
name,
|
|
474
795
|
status: 'fail',
|
|
475
796
|
};
|
|
476
797
|
}
|
|
477
|
-
const validation = await validateToken(credential.token);
|
|
478
798
|
const source = SOURCE_LABELS[credential.source];
|
|
479
|
-
|
|
799
|
+
const expiry = describeExpiry(getTokenExpiry(credential.token));
|
|
800
|
+
if (!apiAnswered) {
|
|
480
801
|
return {
|
|
481
|
-
detail: credential
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
message: `Credential from ${source} was rejected by the ATK API`,
|
|
802
|
+
detail: 'The ATK API check failed, so the credential was not validated.',
|
|
803
|
+
id,
|
|
804
|
+
message: `Not validated (from ${source}${expiry ? `; token ${expiry}` : ''})`,
|
|
485
805
|
name,
|
|
486
|
-
status: '
|
|
806
|
+
status: 'warn',
|
|
487
807
|
};
|
|
488
808
|
}
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
809
|
+
let principal;
|
|
810
|
+
try {
|
|
811
|
+
principal = await getAtkForToken(credential.token, apiUrl).me();
|
|
812
|
+
}
|
|
813
|
+
catch (err) {
|
|
814
|
+
return describeRefusal(err, credential.source, `${source}${expiry ? ` (${expiry})` : ''}`);
|
|
815
|
+
}
|
|
492
816
|
return {
|
|
493
|
-
|
|
817
|
+
...(credential.source === 'entra' && expiry ? { detail: 'The session renews the token on its own.' } : {}),
|
|
818
|
+
id,
|
|
819
|
+
message: `Signed in as ${principal.login} via ${principal.scheme} (from ${source}${expiry ? `; token ${expiry}` : ''})`,
|
|
494
820
|
name,
|
|
495
821
|
status: 'pass',
|
|
496
822
|
};
|
|
497
823
|
}
|
|
824
|
+
/** "expires in 52m" or "expired 3h ago"; undefined when the token carries no expiry. */
|
|
825
|
+
function describeExpiry(expiry, now = Date.now()) {
|
|
826
|
+
if (!expiry)
|
|
827
|
+
return undefined;
|
|
828
|
+
const ms = expiry.getTime() - now;
|
|
829
|
+
return ms > 0 ? `expires in ${formatAge(ms)}` : `expired ${formatAge(-ms)} ago`;
|
|
830
|
+
}
|
|
831
|
+
/** Why a fetch never got a response, by its cause's code (`ECONNREFUSED`, `ENOTFOUND`) when it has one. */
|
|
832
|
+
function describeFetchFailure(err) {
|
|
833
|
+
const cause = err instanceof Error ? err.cause : undefined;
|
|
834
|
+
const code = typeof cause === 'object' && cause !== null && 'code' in cause ? cause.code : undefined;
|
|
835
|
+
return typeof code === 'string' ? code : errorMessage(err);
|
|
836
|
+
}
|
|
837
|
+
/**
|
|
838
|
+
* Why `GET /me` refused the credential. Only a 401 means the credential itself is bad, so only
|
|
839
|
+
* then does an Entra session get `atk login --force`: an outdated CLI, a forbidden account or a
|
|
840
|
+
* server error isn't fixed by signing in again, and `--fix` acts on the command.
|
|
841
|
+
*/
|
|
842
|
+
function describeRefusal(err, credentialSource, from) {
|
|
843
|
+
const id = 'auth';
|
|
844
|
+
const name = 'Authentication';
|
|
845
|
+
if (isAtkError(err, 'unauthenticated')) {
|
|
846
|
+
return credentialSource === 'entra'
|
|
847
|
+
? {
|
|
848
|
+
command: 'atk login --force',
|
|
849
|
+
detail: 'Run `atk login --force` to sign in again.',
|
|
850
|
+
id,
|
|
851
|
+
message: `Credential from ${from} was rejected by the ATK API`,
|
|
852
|
+
name,
|
|
853
|
+
status: 'fail',
|
|
854
|
+
}
|
|
855
|
+
: {
|
|
856
|
+
detail: 'Check ATK_TOKEN: it must be a current Entra access token for the ATK API. Unset it to use your `atk login` session.',
|
|
857
|
+
id,
|
|
858
|
+
message: `Credential from ${from} was rejected by the ATK API`,
|
|
859
|
+
name,
|
|
860
|
+
status: 'fail',
|
|
861
|
+
};
|
|
862
|
+
}
|
|
863
|
+
if (isAtkError(err, 'client_outdated')) {
|
|
864
|
+
const command = upgradeCommand(isPreRelease() ? 'dev' : 'latest');
|
|
865
|
+
return {
|
|
866
|
+
command,
|
|
867
|
+
detail: `Run \`${command}\` to update.`,
|
|
868
|
+
id,
|
|
869
|
+
message: err.problem.message,
|
|
870
|
+
name,
|
|
871
|
+
status: 'fail',
|
|
872
|
+
};
|
|
873
|
+
}
|
|
874
|
+
return {
|
|
875
|
+
id,
|
|
876
|
+
message: `Couldn't validate the credential from ${from}: ${errorMessage(err)}`,
|
|
877
|
+
name,
|
|
878
|
+
status: 'fail',
|
|
879
|
+
};
|
|
880
|
+
}
|
|
498
881
|
/** A note when `~/.atk/auth/msal-cache.json` exists but yielded no session. */
|
|
499
882
|
function staleEntraSessionNote() {
|
|
500
883
|
if (!existsSync(getEntraCachePath())) {
|