@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.
@@ -1,20 +1,81 @@
1
1
  import { existsSync } from 'node:fs';
2
- import { adapterForScope, loadAdapter } from './adapter.js';
3
- import { resolveApiUrl } from './api-client.js';
4
- import { isExcludedAtVersion, isExcludedForTool, isExcludedForTools, isUndeclaredForTool } from './asset-tools.js';
5
- import { getCredential, getLastEntraSession, validateToken } from './auth.js';
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 Adapters check already reports adapters that fail to load.
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
- detail: `${notExecutable.join(', ')}. Run \`${scopedCommand('atk sync', scope)}\` to reinstall them, or \`chmod +x\` the script of a frozen asset.`,
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
- detail: `${lifted.join(', ')}. ${one ? 'Its locked version now declares that tool' : 'Their locked versions now declare those tools'}. Run \`${scopedCommand('atk sync', scope)}\` to place ${one ? 'it' : 'them'}.`,
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
- // Report the ATK API the CLI will talk to
188
- progress('Resolving ATK API...');
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
- checks.push({
191
- detail: process.env['ATK_API_URL'] ? 'From ATK_API_URL' : config?.apiUrl ? 'From apiUrl in config' : undefined,
192
- message: apiUrl,
193
- name: 'ATK API',
194
- status: 'pass',
195
- });
196
- // Check authentication against the ATK API (not offline: validating the credential is an API call)
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({ force: true });
452
+ registry = isOffline() ? await fetchRegistry({ stale: true }) : await fetchRegistry();
206
453
  checks.push({
207
- message: `Accessible (${registry.assets.length} assets, ${(registry.bundles ?? []).length} bundles)`,
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: 'Use --tool flag or run from a project with a supported tool.',
233
- message: 'No agentic tool detected in current directory',
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 setup = scopedCommand('atk setup', scope);
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
- if (liftedHooks) {
344
- checks.push(liftedHooks);
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
- * When the chain is empty but an Entra session file exists, the detail says
461
- * why the file yielded nothing, since that usually means `atk login` again.
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
- if (!validation.valid) {
799
+ const expiry = describeExpiry(getTokenExpiry(credential.token));
800
+ if (!apiAnswered) {
480
801
  return {
481
- detail: credential.source === 'entra'
482
- ? 'Run `atk login --force` to sign in again.'
483
- : 'Check ATK_TOKEN: it must be a current Entra access token for the ATK API. Unset it to use your `atk login` session.',
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: 'fail',
806
+ status: 'warn',
487
807
  };
488
808
  }
489
- const principal = validation.principal;
490
- const who = principal?.login ?? validation.username ?? 'unknown';
491
- const scheme = principal?.scheme ?? 'unknown';
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
- message: `Signed in as ${who} via ${scheme} (from ${source})`,
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())) {