Skip to content

Commit 4f34d14

Browse files
mason: fix cli doctor audit findings
Co-authored-by: Alfonso [Magic Context] <288211368+alfonso-magic-context@users.noreply.github.com>
1 parent 664d147 commit 4f34d14

17 files changed

Lines changed: 661 additions & 102 deletions

packages/cli/src/commands/doctor-opencode-cache.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ export interface PluginCacheResult {
88
action: "cleared" | "up_to_date" | "not_found" | "check_unavailable" | "error";
99
path: string;
1010
paths?: string[];
11+
clearedPaths?: string[];
12+
failedPaths?: string[];
1113
cached?: string;
1214
latest?: string;
1315
error?: string;
@@ -94,6 +96,8 @@ export async function clearPluginCache(
9496
action: "error",
9597
path: firstFailure?.path ?? clearTargets[0]?.path ?? existingRoots[0] ?? "",
9698
paths: failed.map((entry) => entry.path),
99+
clearedPaths: cleared.map((entry) => entry.path),
100+
failedPaths: failed.map((entry) => entry.path),
97101
error: firstFailure?.error,
98102
};
99103
}

packages/cli/src/commands/doctor-opencode.test.ts

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,12 @@ import {
1414
OPENCODE_PLUGIN_NAME,
1515
} from "../lib/opencode-plugin-cache";
1616
import { runV22BackfillCommands } from "../lib/v22-backfill-commands";
17-
import { migrateLegacyAgentEnabledConfigForDoctor } from "./doctor-opencode";
17+
import {
18+
collectNpmReleaseAgeWarnings,
19+
getUserNpmrcPath,
20+
isPinnedOpenCodePluginSpecifier,
21+
migrateLegacyAgentEnabledConfigForDoctor,
22+
} from "./doctor-opencode";
1823
import { clearPluginCache } from "./doctor-opencode-cache";
1924

2025
function migrate(input: Record<string, unknown>) {
@@ -77,6 +82,8 @@ describe("doctor OpenCode legacy agent enabled migration", () => {
7782
const tempDirs: string[] = [];
7883
const dbs: Database[] = [];
7984
let originalXdgCacheHome: string | undefined;
85+
let originalHome: string | undefined;
86+
let originalNpmUserConfig: string | undefined;
8087

8188
function makeTempDir(prefix = "mc-v22-doctor-"): string {
8289
const dir = mkdtempSync(join(tmpdir(), prefix));
@@ -132,7 +139,19 @@ afterEach(() => {
132139
} else {
133140
process.env.XDG_CACHE_HOME = originalXdgCacheHome;
134141
}
142+
if (originalHome === undefined) {
143+
delete process.env.HOME;
144+
} else {
145+
process.env.HOME = originalHome;
146+
}
147+
if (originalNpmUserConfig === undefined) {
148+
delete process.env.NPM_CONFIG_USERCONFIG;
149+
} else {
150+
process.env.NPM_CONFIG_USERCONFIG = originalNpmUserConfig;
151+
}
135152
originalXdgCacheHome = undefined;
153+
originalHome = undefined;
154+
originalNpmUserConfig = undefined;
136155
for (const db of dbs.splice(0)) {
137156
db.close();
138157
}
@@ -285,13 +304,49 @@ describe("doctor OpenCode plugin cache", () => {
285304
action: "error",
286305
path: versionlessCachePath,
287306
paths: [versionlessCachePath],
307+
clearedPaths: [latestCachePath],
308+
failedPaths: [versionlessCachePath],
288309
error: "EACCES: permission denied",
289310
});
290311
expect(removed).toEqual([latestCachePath]);
291312
expect(existsSync(latestCachePath)).toBe(false);
292313
});
293314
});
294315

316+
describe("doctor OpenCode helper logic", () => {
317+
it("treats dist-tags like @next and @beta as pinned plugin entries", () => {
318+
expect(isPinnedOpenCodePluginSpecifier("@cortexkit/opencode-magic-context@next")).toBe(
319+
true,
320+
);
321+
expect(isPinnedOpenCodePluginSpecifier("@cortexkit/opencode-magic-context@beta")).toBe(
322+
true,
323+
);
324+
expect(isPinnedOpenCodePluginSpecifier("@cortexkit/opencode-magic-context@0.29.1")).toBe(
325+
true,
326+
);
327+
expect(isPinnedOpenCodePluginSpecifier("@cortexkit/opencode-magic-context")).toBe(false);
328+
expect(isPinnedOpenCodePluginSpecifier("@cortexkit/opencode-magic-context@latest")).toBe(
329+
false,
330+
);
331+
});
332+
333+
it("honors NPM_CONFIG_USERCONFIG before HOME for npmrc release-age warnings", () => {
334+
const root = makeTempDir("mc-npmrc-");
335+
const home = join(root, "home");
336+
const customNpmrc = join(root, "custom.npmrc");
337+
originalHome = process.env.HOME;
338+
originalNpmUserConfig = process.env.NPM_CONFIG_USERCONFIG;
339+
process.env.HOME = home;
340+
process.env.NPM_CONFIG_USERCONFIG = customNpmrc;
341+
mkdirSync(home, { recursive: true });
342+
writeFileSync(join(home, ".npmrc"), "min-release-age=9999\n");
343+
writeFileSync(customNpmrc, "before=2026-01-01\n");
344+
345+
expect(getUserNpmrcPath()).toBe(customNpmrc);
346+
expect(collectNpmReleaseAgeWarnings()).toEqual([`${customNpmrc} has 'before=2026-01-01'`]);
347+
});
348+
});
349+
295350
describe("doctor v22 backfill commands", () => {
296351
it("--check-v22-backfill reports status", async () => {
297352
const database = makeDb();

packages/cli/src/commands/doctor-opencode.ts

Lines changed: 64 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import { homedir } from "node:os";
55
import { join } from "node:path";
66
import { loadPluginConfig } from "@magic-context/core/config";
77
import { substituteConfigVariables } from "@magic-context/core/config/variable";
8-
98
import {
109
type EmbeddingProbeOutcome,
1110
probeEmbeddingEndpoint,
@@ -17,6 +16,7 @@ import { getMagicContextStorageDir } from "@magic-context/core/shared/data-path"
1716
import { Database } from "@magic-context/core/shared/sqlite";
1817
import { ensureTuiPluginEntry } from "@magic-context/core/shared/tui-config";
1918
import { parse, stringify } from "comment-json";
19+
2020
import { isDevPathPluginEntry, matchesPluginEntry } from "../adapters/opencode";
2121
import { writeFileAtomic } from "../lib/atomic-write";
2222
import { migrateConfigLocationsForCli } from "../lib/config-location-migration";
@@ -37,6 +37,11 @@ import {
3737
} from "../lib/opencode-plugin-cache";
3838
import { detectConfigPaths, getMagicContextLogPath } from "../lib/paths";
3939
import { confirm, intro, log, outro, selectOne, spinner, text } from "../lib/prompts";
40+
import {
41+
sanitizeDiagnosticEndpoint,
42+
sanitizeDiagnosticText,
43+
sanitizePathString,
44+
} from "../lib/redaction";
4045
import { runV22BackfillCommands, type V22BackfillCommandArgs } from "../lib/v22-backfill-commands";
4146
import { clearPluginCache } from "./doctor-opencode-cache";
4247

@@ -140,6 +145,40 @@ function getSelfVersion(): string {
140145
return "0.0.0";
141146
}
142147

148+
export function isPinnedOpenCodePluginSpecifier(specifier: string): boolean {
149+
if (specifier === PLUGIN_NAME || specifier === PLUGIN_ENTRY_WITH_VERSION) return false;
150+
return specifier.startsWith(`${PLUGIN_NAME}@`);
151+
}
152+
153+
export function getUserNpmrcPath(): string {
154+
const custom = process.env.NPM_CONFIG_USERCONFIG?.trim();
155+
if (custom) return custom;
156+
const home = process.env.HOME?.trim();
157+
return join(home || homedir(), ".npmrc");
158+
}
159+
160+
export function collectNpmReleaseAgeWarnings(): string[] {
161+
const ageWarnings: string[] = [];
162+
const npmrcPath = getUserNpmrcPath();
163+
if (!existsSync(npmrcPath)) return ageWarnings;
164+
try {
165+
const npmrc = readFileSync(npmrcPath, "utf-8");
166+
for (const line of npmrc.split("\n")) {
167+
const trimmed = line.trim();
168+
if (trimmed.startsWith("#") || trimmed.startsWith(";")) continue;
169+
const [key] = trimmed.split("=").map((s) => s.trim());
170+
if (key === "min-release-age" || key === "before") {
171+
ageWarnings.push(
172+
`${sanitizePathString(npmrcPath)} has '${sanitizeDiagnosticText(trimmed)}'`,
173+
);
174+
}
175+
}
176+
} catch {
177+
// Can't read .npmrc — skip.
178+
}
179+
return ageWarnings;
180+
}
181+
143182
/** Compare semver-like strings. Returns -1 if a<b, 0 if equal, 1 if a>b. */
144183
function compareVersions(a: string, b: string): number {
145184
const pa = a.split(/[.-]/).map((s) => Number.parseInt(s, 10));
@@ -417,7 +456,9 @@ async function checkEmbeddingConfig(
417456

418457
// Run the live probe.
419458
const probeSpinner = spinner();
420-
probeSpinner.start(`Testing embedding endpoint ${endpoint} (model: ${model})`);
459+
probeSpinner.start(
460+
`Testing embedding endpoint ${sanitizeDiagnosticEndpoint(endpoint)} (model: ${sanitizeDiagnosticText(model)})`,
461+
);
421462

422463
let outcome: EmbeddingProbeOutcome;
423464
try {
@@ -431,7 +472,9 @@ async function checkEmbeddingConfig(
431472
});
432473
} catch (error) {
433474
probeSpinner.stop("Embedding probe failed unexpectedly");
434-
log.error(`Probe threw: ${error instanceof Error ? error.message : String(error)}`);
475+
log.error(
476+
`Probe threw: ${sanitizeDiagnosticText(error instanceof Error ? error.message : String(error))}`,
477+
);
435478
return { issues: localIssues + 1 };
436479
}
437480

@@ -447,11 +490,11 @@ async function checkEmbeddingConfig(
447490
log.error(
448491
`Embedding endpoint rejected credentials (${outcome.status}) — check api_key / env var`,
449492
);
450-
if (outcome.preview) log.info(` ${outcome.preview}`);
493+
if (outcome.preview) log.info(` ${sanitizeDiagnosticText(outcome.preview)}`);
451494
return { issues: localIssues + 1 };
452495
case "endpoint_unsupported":
453496
log.error(`Embedding endpoint does not support embeddings (${outcome.status})`);
454-
if (outcome.preview) log.info(` ${outcome.preview}`);
497+
if (outcome.preview) log.info(` ${sanitizeDiagnosticText(outcome.preview)}`);
455498
log.info(
456499
" Common causes: endpoint points at a chat-completion route (should be the provider base, e.g. '.../v1'), or the provider doesn't offer an embeddings API",
457500
);
@@ -461,19 +504,21 @@ async function checkEmbeddingConfig(
461504
return { issues: localIssues + 1 };
462505
case "http_error":
463506
log.error(`Embedding endpoint returned ${outcome.status}`);
464-
if (outcome.preview) log.info(` ${outcome.preview}`);
507+
if (outcome.preview) log.info(` ${sanitizeDiagnosticText(outcome.preview)}`);
465508
return { issues: localIssues + 1 };
466509
case "timeout":
467510
log.warn(
468511
`Embedding endpoint did not respond within ${outcome.timeoutMs}ms — check endpoint URL and network`,
469512
);
470513
return { issues: localIssues + 1 };
471514
case "network_error":
472-
log.error(`Could not reach embedding endpoint: ${outcome.message}`);
515+
log.error(
516+
`Could not reach embedding endpoint: ${sanitizeDiagnosticText(outcome.message)}`,
517+
);
473518
return { issues: localIssues + 1 };
474519
case "invalid_scheme":
475520
log.error(
476-
`Embedding endpoint must start with http:// or https://: ${outcome.endpoint}`,
521+
`Embedding endpoint must start with http:// or https://: ${sanitizeDiagnosticEndpoint(outcome.endpoint)}`,
477522
);
478523
return { issues: localIssues + 1 };
479524
}
@@ -873,10 +918,7 @@ export async function runDoctor(
873918
if (isDevPathPluginEntry(oldEntry)) {
874919
pass(`Plugin registered in ${configName} (dev path: ${oldEntryStr})`);
875920
} else {
876-
const isPinned =
877-
oldEntryStr !== PLUGIN_NAME &&
878-
oldEntryStr !== PLUGIN_ENTRY_WITH_VERSION &&
879-
/^@cortexkit\/opencode-magic-context@\d/.test(oldEntryStr);
921+
const isPinned = isPinnedOpenCodePluginSpecifier(oldEntryStr);
880922

881923
if (isPinned && !options.force) {
882924
// Warn but don't change — user intentionally pinned
@@ -967,10 +1009,7 @@ export async function runDoctor(
9671009
if (isDevPathPluginEntry(tuiEntry)) {
9681010
pass(`TUI sidebar plugin configured (dev path: ${tuiEntryStr})`);
9691011
} else {
970-
const tuiPinned =
971-
tuiEntryStr !== PLUGIN_NAME &&
972-
tuiEntryStr !== PLUGIN_ENTRY_WITH_VERSION &&
973-
/^@cortexkit\/opencode-magic-context@\d/.test(tuiEntryStr);
1012+
const tuiPinned = isPinnedOpenCodePluginSpecifier(tuiEntryStr);
9741013
if (tuiPinned && !options.force) {
9751014
warn(
9761015
`TUI plugin pinned to ${tuiEntryStr} — use 'doctor --force' to upgrade`,
@@ -1117,7 +1156,14 @@ export async function runDoctor(
11171156
);
11181157
} else if (cacheResult.action === "error") {
11191158
warn(`Could not clear plugin cache: ${cacheResult.error}`);
1120-
log.info(` Manually delete: ${cacheResult.path}`);
1159+
if (cacheResult.clearedPaths && cacheResult.clearedPaths.length > 0) {
1160+
log.info(` Cleared roots: ${cacheResult.clearedPaths.join(", ")}`);
1161+
}
1162+
if (cacheResult.failedPaths && cacheResult.failedPaths.length > 0) {
1163+
log.info(` Failed roots: ${cacheResult.failedPaths.join(", ")}`);
1164+
} else {
1165+
log.info(` Manually delete: ${cacheResult.path}`);
1166+
}
11211167
issues++;
11221168
} else {
11231169
pass("Plugin cache clean (no cached version found)");
@@ -1129,23 +1175,7 @@ export async function runDoctor(
11291175
// npx and the auto-update checker uses npm install, neither of which read
11301176
// bunfig.
11311177
{
1132-
const ageWarnings: string[] = [];
1133-
const npmrcPath = join(homedir(), ".npmrc");
1134-
if (existsSync(npmrcPath)) {
1135-
try {
1136-
const npmrc = readFileSync(npmrcPath, "utf-8");
1137-
for (const line of npmrc.split("\n")) {
1138-
const trimmed = line.trim();
1139-
if (trimmed.startsWith("#") || trimmed.startsWith(";")) continue;
1140-
const [key] = trimmed.split("=").map((s) => s.trim());
1141-
if (key === "min-release-age" || key === "before") {
1142-
ageWarnings.push(`~/.npmrc has '${trimmed}'`);
1143-
}
1144-
}
1145-
} catch {
1146-
// Can't read .npmrc — skip
1147-
}
1148-
}
1178+
const ageWarnings = collectNpmReleaseAgeWarnings();
11491179

11501180
if (ageWarnings.length > 0) {
11511181
log.warn(

0 commit comments

Comments
 (0)