@golba98/codexa 1.0.5 → 1.0.6
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 +6 -10
- package/package.json +1 -1
- package/src/app.tsx +60 -74
- package/src/commands/handler.ts +13 -11
- package/src/config/appVersion.ts +65 -0
- package/src/config/buildInfo.ts +2 -2
- package/src/config/settings.ts +5 -1
- package/src/config/updateCheckCache.ts +18 -8
- package/src/core/models/providerModelCache.ts +9 -25
- package/src/core/providerRuntime/antigravity.ts +12 -1
- package/src/core/providerRuntime/registry.ts +10 -6
- package/src/core/version/packageManager.ts +119 -0
- package/src/core/version/updateCheck.ts +15 -9
- package/src/test/runtimeTestUtils.ts +0 -90
- package/src/ui/chrome/TopHeader.tsx +4 -2
- package/src/ui/chrome/UpdateAvailableCard.tsx +3 -2
- package/src/ui/input/slashCommands.ts +1 -0
- package/src/ui/panels/UpdatePromptPanel.tsx +53 -43
package/README.md
CHANGED
|
@@ -577,7 +577,7 @@ npm install -g @golba98/codexa@latest
|
|
|
577
577
|
|
|
578
578
|
### Update notice does not appear
|
|
579
579
|
|
|
580
|
-
Codexa checks for updates on
|
|
580
|
+
Codexa checks npm for updates on every interactive startup. The local cache at `~/.codexa-update-check.json` is only used as a best-effort fallback if npm is temporarily unavailable. The update notice appears when the npm registry `latest` version is newer than the running version.
|
|
581
581
|
|
|
582
582
|
Published npm versions are immutable. Versions before the fixed update checker may not show update notices even when a newer package exists. If in doubt, update directly:
|
|
583
583
|
|
|
@@ -588,9 +588,8 @@ npm install -g @golba98/codexa@latest
|
|
|
588
588
|
If you expect a notice but don't see one:
|
|
589
589
|
|
|
590
590
|
1. Check the current registry state: `npm view @golba98/codexa dist-tags --json`
|
|
591
|
-
2.
|
|
592
|
-
3.
|
|
593
|
-
4. Update checks are disabled for local dev builds (`codexa-dev` / `cxd`)
|
|
591
|
+
2. Force an explicit check in-app: `/update check`
|
|
592
|
+
3. Update checks are disabled for local dev builds (`codexa-dev` / `cxd`)
|
|
594
593
|
|
|
595
594
|
| Symptom | Fix |
|
|
596
595
|
|---------|-----|
|
|
@@ -606,11 +605,8 @@ If you expect a notice but don't see one:
|
|
|
606
605
|
|
|
607
606
|
See [CHANGELOG.md](CHANGELOG.md) for the full release history.
|
|
608
607
|
|
|
609
|
-
**Current release: v1.0.
|
|
608
|
+
**Current release: v1.0.6**
|
|
610
609
|
|
|
611
|
-
v1.0.
|
|
612
|
-
- Older installed versions can detect when npm `latest` is newer
|
|
613
|
-
- `/update check` bypasses stale cache and reports a useful status
|
|
614
|
-
- Failed registry lookups do not get cached as “up to date”
|
|
610
|
+
v1.0.6 checks npm on every interactive startup and shows an update prompt as soon as Codexa is idle. The prompt offers the correct update command for the package manager that installed Codexa.
|
|
615
611
|
|
|
616
|
-
> Versions before
|
|
612
|
+
> Versions before v1.0.6 can wait for a cached result before discovering a new npm release. Update directly with `npm install -g @golba98/codexa@latest` if needed.
|
package/package.json
CHANGED
package/src/app.tsx
CHANGED
|
@@ -270,9 +270,10 @@ import { AppShell } from "./ui/chrome/AppShell.js";
|
|
|
270
270
|
import { TranscriptShell } from "./ui/timeline/TranscriptShell.js";
|
|
271
271
|
import type { RuntimeAvailability } from "./ui/chrome/RuntimeStatusBar.js";
|
|
272
272
|
import { checkForUpdates, formatLocalDevUpdateStatus, formatUpdateInstructions, shouldRunStartupUpdateCheck, type UpdateCheckResult } from "./core/version/updateCheck.js";
|
|
273
|
+
import { detectGlobalPackageManager, getUpdateCommand } from "./core/version/packageManager.js";
|
|
273
274
|
import { isLocalDevChannel } from "./core/version/channel.js";
|
|
274
275
|
import {
|
|
275
|
-
|
|
276
|
+
isCacheForRunningVersion,
|
|
276
277
|
loadUpdateCheckCache,
|
|
277
278
|
saveUpdateCheckCache,
|
|
278
279
|
} from "./config/updateCheckCache.js";
|
|
@@ -394,9 +395,6 @@ export function App({ launchArgs }: AppProps) {
|
|
|
394
395
|
? projectInstructionsLoad.instructions
|
|
395
396
|
: null;
|
|
396
397
|
const initialSettings = useRef(loadSettings());
|
|
397
|
-
const skippedUpdateVersionRef = useRef<string | null>(
|
|
398
|
-
initialSettings.current.updateCheck.skippedUpdateVersion ?? null,
|
|
399
|
-
);
|
|
400
398
|
const initialProviderWorkspaceConfig = useRef<ProviderWorkspaceConfig>(loadProviderWorkspaceConfig(workspaceRoot));
|
|
401
399
|
const initialLayeredConfig = useRef<LayeredConfigResult | null>(null);
|
|
402
400
|
if (initialLayeredConfig.current === null) {
|
|
@@ -550,6 +548,9 @@ export function App({ launchArgs }: AppProps) {
|
|
|
550
548
|
const [planFlow, setPlanFlow] = useState<PlanFlowState>(createInitialPlanFlowState);
|
|
551
549
|
const [initialRevisionText, setInitialRevisionText] = useState("");
|
|
552
550
|
const [updateCheckResult, setUpdateCheckResult] = useState<UpdateCheckResult | null>(null);
|
|
551
|
+
const [startupUpdateDismissed, setStartupUpdateDismissed] = useState(false);
|
|
552
|
+
// Launcher path is fixed for the process lifetime, so detect once.
|
|
553
|
+
const globalPackageManager = useMemo(() => detectGlobalPackageManager(), []);
|
|
553
554
|
// Transcript mode leaves mouse reporting off so wheel/trackpad input scrolls
|
|
554
555
|
// the terminal emulator's native scrollback instead of an in-app viewport.
|
|
555
556
|
const mouseCapture = (mouseOverride ?? (terminalMouseMode === "wheel")) && !isMouseIdle;
|
|
@@ -1625,54 +1626,53 @@ export function App({ launchArgs }: AppProps) {
|
|
|
1625
1626
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1626
1627
|
}, [workspaceRoot]);
|
|
1627
1628
|
|
|
1628
|
-
// Non-blocking background update check —
|
|
1629
|
+
// Non-blocking background update check — fetches npm on every interactive startup.
|
|
1629
1630
|
useEffect(() => {
|
|
1630
1631
|
const ucSettings = initialSettings.current.updateCheck ?? DEFAULT_UPDATE_CHECK_SETTINGS;
|
|
1631
1632
|
if (!shouldRunStartupUpdateCheck(process.env, ucSettings.enabled)) return;
|
|
1632
1633
|
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
}
|
|
1664
|
-
}
|
|
1665
|
-
}
|
|
1666
|
-
} catch {
|
|
1667
|
-
// Never crash the TUI on a failed update check.
|
|
1668
|
-
}
|
|
1669
|
-
})();
|
|
1670
|
-
}, 2000);
|
|
1671
|
-
|
|
1672
|
-
return () => clearTimeout(timer);
|
|
1634
|
+
void (async () => {
|
|
1635
|
+
const cache = loadUpdateCheckCache();
|
|
1636
|
+
try {
|
|
1637
|
+
const result = await checkForUpdates({ enabled: true });
|
|
1638
|
+
if (result.status === "error") {
|
|
1639
|
+
// A previously confirmed update is still useful when npm is briefly
|
|
1640
|
+
// unreachable, but it must never suppress the next fresh startup check.
|
|
1641
|
+
if (cache?.updateAvailable && cache.latestVersion && isCacheForRunningVersion(cache, APP_VERSION)) {
|
|
1642
|
+
setUpdateCheckResult({
|
|
1643
|
+
status: "update-available",
|
|
1644
|
+
currentVersion: cache.currentVersion,
|
|
1645
|
+
latestVersion: cache.latestVersion,
|
|
1646
|
+
checkedAt: cache.lastChecked,
|
|
1647
|
+
source: "cache",
|
|
1648
|
+
});
|
|
1649
|
+
}
|
|
1650
|
+
return;
|
|
1651
|
+
}
|
|
1652
|
+
|
|
1653
|
+
setUpdateCheckResult(result);
|
|
1654
|
+
saveUpdateCheckCache({
|
|
1655
|
+
lastChecked: result.checkedAt,
|
|
1656
|
+
currentVersion: result.currentVersion,
|
|
1657
|
+
latestVersion: result.latestVersion,
|
|
1658
|
+
updateAvailable: result.status === "update-available",
|
|
1659
|
+
});
|
|
1660
|
+
} catch {
|
|
1661
|
+
// Never crash the TUI on a failed update check.
|
|
1662
|
+
}
|
|
1663
|
+
})();
|
|
1673
1664
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1674
1665
|
}, []);
|
|
1675
1666
|
|
|
1667
|
+
// A startup update prompt must not interrupt an active run or another panel.
|
|
1668
|
+
// Keep the result until the user returns to the idle main screen instead.
|
|
1669
|
+
useEffect(() => {
|
|
1670
|
+
if (startupUpdateDismissed || busy || screen !== "main") return;
|
|
1671
|
+
if (updateCheckResult?.status === "update-available" && updateCheckResult.latestVersion) {
|
|
1672
|
+
setScreen("update-prompt");
|
|
1673
|
+
}
|
|
1674
|
+
}, [busy, screen, startupUpdateDismissed, updateCheckResult]);
|
|
1675
|
+
|
|
1676
1676
|
// Track clear epoch to suppress stale command result events
|
|
1677
1677
|
useEffect(() => {
|
|
1678
1678
|
clearEpochRef.current = sessionState.clearEpoch;
|
|
@@ -2160,32 +2160,10 @@ export function App({ launchArgs }: AppProps) {
|
|
|
2160
2160
|
}, [applyWorkspaceDisplayMode, showBusyLoader, terminalMouseMode, terminalTitleMode, workspaceDisplayMode]);
|
|
2161
2161
|
|
|
2162
2162
|
const handleSkipUpdateForSession = useCallback(() => {
|
|
2163
|
+
setStartupUpdateDismissed(true);
|
|
2163
2164
|
setScreen("main");
|
|
2164
2165
|
}, []);
|
|
2165
2166
|
|
|
2166
|
-
const handleSkipUpdateVersion = useCallback((version: string) => {
|
|
2167
|
-
initialSettings.current.updateCheck = {
|
|
2168
|
-
...initialSettings.current.updateCheck,
|
|
2169
|
-
skippedUpdateVersion: version,
|
|
2170
|
-
};
|
|
2171
|
-
saveSettings({
|
|
2172
|
-
ui: {
|
|
2173
|
-
layoutStyle: initialSettings.current.ui.layoutStyle,
|
|
2174
|
-
theme: themeSelection.committedTheme,
|
|
2175
|
-
workspaceDisplayMode,
|
|
2176
|
-
terminalTitleMode,
|
|
2177
|
-
showBusyLoader,
|
|
2178
|
-
terminalMouseMode,
|
|
2179
|
-
customTheme,
|
|
2180
|
-
},
|
|
2181
|
-
auth: { preference: authPreference },
|
|
2182
|
-
header: headerConfig,
|
|
2183
|
-
updateCheck: initialSettings.current.updateCheck,
|
|
2184
|
-
});
|
|
2185
|
-
skippedUpdateVersionRef.current = version;
|
|
2186
|
-
setScreen("main");
|
|
2187
|
-
}, [authPreference, customTheme, headerConfig, showBusyLoader, terminalMouseMode, terminalTitleMode, themeSelection.committedTheme, workspaceDisplayMode]);
|
|
2188
|
-
|
|
2189
2167
|
const setApprovalPolicyWithNotice = useCallback((nextValue: RuntimeApprovalPolicy) => {
|
|
2190
2168
|
const gate = guardConfigMutation("mode", busy);
|
|
2191
2169
|
if (!gate.allowed) {
|
|
@@ -4435,7 +4413,10 @@ export function App({ launchArgs }: AppProps) {
|
|
|
4435
4413
|
if (freshResult?.status === "update-available" && freshResult.latestVersion) {
|
|
4436
4414
|
setScreen("update-prompt");
|
|
4437
4415
|
} else {
|
|
4438
|
-
appendSystemEvent(
|
|
4416
|
+
appendSystemEvent(
|
|
4417
|
+
"Update",
|
|
4418
|
+
formatUpdateInstructions(freshResult, getUpdateCommand(globalPackageManager).displayCommand),
|
|
4419
|
+
);
|
|
4439
4420
|
}
|
|
4440
4421
|
})();
|
|
4441
4422
|
return;
|
|
@@ -4527,6 +4508,7 @@ export function App({ launchArgs }: AppProps) {
|
|
|
4527
4508
|
dispatchSession,
|
|
4528
4509
|
findUserPromptForTurn,
|
|
4529
4510
|
focusManager,
|
|
4511
|
+
globalPackageManager,
|
|
4530
4512
|
handleCopy,
|
|
4531
4513
|
handleClear,
|
|
4532
4514
|
handleQuit,
|
|
@@ -4751,8 +4733,12 @@ export function App({ launchArgs }: AppProps) {
|
|
|
4751
4733
|
clearCount={sessionState.clearCount}
|
|
4752
4734
|
headerConfig={effectiveHeaderConfig}
|
|
4753
4735
|
updateAvailable={
|
|
4754
|
-
updateCheckResult?.status === "update-available" && updateCheckResult.latestVersion
|
|
4755
|
-
? {
|
|
4736
|
+
screen !== "update-prompt" && updateCheckResult?.status === "update-available" && updateCheckResult.latestVersion
|
|
4737
|
+
? {
|
|
4738
|
+
latestVersion: updateCheckResult.latestVersion,
|
|
4739
|
+
currentVersion: updateCheckResult.currentVersion,
|
|
4740
|
+
updateCommand: getUpdateCommand(globalPackageManager).displayCommand,
|
|
4741
|
+
}
|
|
4756
4742
|
: null
|
|
4757
4743
|
}
|
|
4758
4744
|
panel={
|
|
@@ -5010,9 +4996,9 @@ export function App({ launchArgs }: AppProps) {
|
|
|
5010
4996
|
focusId={FOCUS_IDS.updatePrompt}
|
|
5011
4997
|
currentVersion={updateCheckResult.currentVersion}
|
|
5012
4998
|
latestVersion={updateCheckResult.latestVersion}
|
|
5013
|
-
|
|
5014
|
-
|
|
5015
|
-
/>
|
|
4999
|
+
packageManager={globalPackageManager}
|
|
5000
|
+
onSkip={handleSkipUpdateForSession}
|
|
5001
|
+
/>
|
|
5016
5002
|
)}
|
|
5017
5003
|
</>
|
|
5018
5004
|
}
|
package/src/commands/handler.ts
CHANGED
|
@@ -391,12 +391,12 @@ function buildHelpMessage(context: CommandContext): string {
|
|
|
391
391
|
` Current reasoning: ${formatReasoningLabel(context.runtime.reasoningLevel)}`,
|
|
392
392
|
` Current plan mode: ${context.runtime.planMode ? "Enabled" : "Disabled"}`,
|
|
393
393
|
" /copy Copy last response to clipboard",
|
|
394
|
-
" /update [
|
|
394
|
+
" /update [status] Check for updates and install the latest Codexa (status: cached result only)",
|
|
395
395
|
" /help Show this help",
|
|
396
|
-
"",
|
|
397
|
-
"Local development:",
|
|
398
|
-
" npm run install:dev-bin Install the codexa-dev command",
|
|
399
|
-
" codexa-dev Run this repo without replacing codexa",
|
|
396
|
+
"",
|
|
397
|
+
"Local development:",
|
|
398
|
+
" npm run install:dev-bin Install the codexa-dev command",
|
|
399
|
+
" codexa-dev Run this repo without replacing codexa",
|
|
400
400
|
"",
|
|
401
401
|
"Shortcuts:",
|
|
402
402
|
" Ctrl+B Open backend picker",
|
|
@@ -515,11 +515,11 @@ export function handleCommand(text: string, context: CommandContext): CommandRes
|
|
|
515
515
|
message: `Unknown reasoning level for ${context.runtime.model}: ${arg}. Valid: ${valid}`,
|
|
516
516
|
};
|
|
517
517
|
}
|
|
518
|
-
return {
|
|
519
|
-
action: "reasoning",
|
|
520
|
-
value: normalized,
|
|
521
|
-
message: `Reasoning level switched to ${formatReasoningLabel(normalized)} (runtime metadata unavailable; unverified until runtime)`,
|
|
522
|
-
};
|
|
518
|
+
return {
|
|
519
|
+
action: "reasoning",
|
|
520
|
+
value: normalized,
|
|
521
|
+
message: `Reasoning level switched to ${formatReasoningLabel(normalized)} (runtime metadata unavailable; unverified until runtime)`,
|
|
522
|
+
};
|
|
523
523
|
}
|
|
524
524
|
|
|
525
525
|
case "plan": {
|
|
@@ -846,7 +846,9 @@ export function handleCommand(text: string, context: CommandContext): CommandRes
|
|
|
846
846
|
return { action: "help", message: buildHelpMessage(context) };
|
|
847
847
|
|
|
848
848
|
case "update":
|
|
849
|
-
|
|
849
|
+
// Bare /update forces a fresh registry check; /update status is the
|
|
850
|
+
// explicit no-network path that reuses the cached/in-memory result.
|
|
851
|
+
return { action: "update", value: normalizedArg || "check" };
|
|
850
852
|
|
|
851
853
|
default:
|
|
852
854
|
return {
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { readFileSync } from "fs";
|
|
2
|
+
import { dirname, join } from "path";
|
|
3
|
+
import { fileURLToPath } from "url";
|
|
4
|
+
import { APP_VERSION as BUILD_INFO_VERSION } from "./buildInfo.js";
|
|
5
|
+
|
|
6
|
+
// Leaf module: must not import settings.ts or anything under src/core/version
|
|
7
|
+
// (settings.ts re-exports APP_VERSION from here, so that would be a cycle).
|
|
8
|
+
|
|
9
|
+
const CODEXA_PACKAGE_NAME = "@golba98/codexa";
|
|
10
|
+
const SEMVER_RE = /^\d+\.\d+\.\d+(-[\w.]+)?$/;
|
|
11
|
+
|
|
12
|
+
function readPackageVersion(packageJsonPath: string, requireName?: string): string | null {
|
|
13
|
+
try {
|
|
14
|
+
const parsed = JSON.parse(readFileSync(packageJsonPath, "utf8")) as {
|
|
15
|
+
name?: unknown;
|
|
16
|
+
version?: unknown;
|
|
17
|
+
};
|
|
18
|
+
if (requireName !== undefined && parsed.name !== requireName) return null;
|
|
19
|
+
if (typeof parsed.version !== "string") return null;
|
|
20
|
+
const version = parsed.version.trim();
|
|
21
|
+
return SEMVER_RE.test(version) ? version : null;
|
|
22
|
+
} catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Resolves the version of the running Codexa install:
|
|
29
|
+
* 1. `CODEXA_PACKAGE_ROOT` (set by bin/codexa.js) → that package.json's version
|
|
30
|
+
* 2. Walk up from this module for a package.json named "@golba98/codexa" (local dev)
|
|
31
|
+
* 3. The committed buildInfo.ts APP_VERSION as the last resort
|
|
32
|
+
*
|
|
33
|
+
* `startDir` overrides the walk-up starting directory (test seam).
|
|
34
|
+
*/
|
|
35
|
+
export function resolveAppVersion(
|
|
36
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
37
|
+
startDir?: string,
|
|
38
|
+
): string {
|
|
39
|
+
const packageRoot = env.CODEXA_PACKAGE_ROOT?.trim();
|
|
40
|
+
if (packageRoot) {
|
|
41
|
+
const version = readPackageVersion(join(packageRoot, "package.json"));
|
|
42
|
+
if (version) return version;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
let dir = startDir ?? dirname(fileURLToPath(import.meta.url));
|
|
46
|
+
for (;;) {
|
|
47
|
+
const version = readPackageVersion(join(dir, "package.json"), CODEXA_PACKAGE_NAME);
|
|
48
|
+
if (version) return version;
|
|
49
|
+
const parent = dirname(dir);
|
|
50
|
+
if (parent === dir) break;
|
|
51
|
+
dir = parent;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return BUILD_INFO_VERSION;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
let cachedVersion: string | null = null;
|
|
58
|
+
|
|
59
|
+
/** Cached form of resolveAppVersion() — the installed version cannot change mid-process. */
|
|
60
|
+
export function getAppVersion(): string {
|
|
61
|
+
if (cachedVersion === null) {
|
|
62
|
+
cachedVersion = resolveAppVersion();
|
|
63
|
+
}
|
|
64
|
+
return cachedVersion;
|
|
65
|
+
}
|
package/src/config/buildInfo.ts
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
// Generated by scripts/gen-build-info.mjs — do not edit manually.
|
|
2
|
-
export const BUILD_COMMIT = "
|
|
3
|
-
export const APP_VERSION = "1.0.
|
|
2
|
+
export const BUILD_COMMIT = "8a275f0a19434d0cf59ff59cebe1aae734559bde" as const;
|
|
3
|
+
export const APP_VERSION = "1.0.6" as const;
|
package/src/config/settings.ts
CHANGED
|
@@ -9,7 +9,11 @@ function smartJoin(base: string, ...parts: string[]): string {
|
|
|
9
9
|
return isWindowsStylePath(base) ? win32.join(base, ...parts) : join(base, ...parts);
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
import { getAppVersion } from "./appVersion.js";
|
|
13
|
+
|
|
14
|
+
// Authoritative runtime version: resolved from the installed package.json at
|
|
15
|
+
// startup (buildInfo.ts is only the committed fallback and can drift).
|
|
16
|
+
export const APP_VERSION: string = getAppVersion();
|
|
13
17
|
export const APP_NAME = "Codexa";
|
|
14
18
|
export const DEFAULT_BACKEND = "codex-subprocess";
|
|
15
19
|
export const DEFAULT_MODEL = "gpt-5.4";
|
|
@@ -9,11 +9,16 @@ export interface UpdateCheckCache {
|
|
|
9
9
|
updateAvailable: boolean;
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
// Resolved per call (not at module load) so HOME changes — e.g. test isolation —
|
|
13
|
+
// are honored. See the same pattern in src/core/models/providerModelCache.ts.
|
|
14
|
+
export function getUpdateCheckCacheFilePath(): string {
|
|
15
|
+
const home = process.env.USERPROFILE ?? process.env.HOME ?? homedir();
|
|
16
|
+
return join(home, ".codexa-update-check.json");
|
|
17
|
+
}
|
|
13
18
|
|
|
14
|
-
export function loadUpdateCheckCache(): UpdateCheckCache | null {
|
|
19
|
+
export function loadUpdateCheckCache(filePath = getUpdateCheckCacheFilePath()): UpdateCheckCache | null {
|
|
15
20
|
try {
|
|
16
|
-
const text = readFileSync(
|
|
21
|
+
const text = readFileSync(filePath, "utf-8");
|
|
17
22
|
const data = JSON.parse(text) as Record<string, unknown>;
|
|
18
23
|
if (typeof data.lastChecked !== "number") return null;
|
|
19
24
|
if (typeof data.currentVersion !== "string") return null;
|
|
@@ -28,12 +33,12 @@ export function loadUpdateCheckCache(): UpdateCheckCache | null {
|
|
|
28
33
|
}
|
|
29
34
|
}
|
|
30
35
|
|
|
31
|
-
export function saveUpdateCheckCache(cache: UpdateCheckCache): void {
|
|
36
|
+
export function saveUpdateCheckCache(cache: UpdateCheckCache, filePath = getUpdateCheckCacheFilePath()): void {
|
|
32
37
|
try {
|
|
33
|
-
mkdirSync(dirname(
|
|
34
|
-
const tmp = `${
|
|
38
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
39
|
+
const tmp = `${filePath}.tmp`;
|
|
35
40
|
writeFileSync(tmp, JSON.stringify(cache, null, 2), "utf-8");
|
|
36
|
-
renameSync(tmp,
|
|
41
|
+
renameSync(tmp, filePath);
|
|
37
42
|
} catch {
|
|
38
43
|
// Best-effort — never crash on cache write failure.
|
|
39
44
|
}
|
|
@@ -43,6 +48,11 @@ function stripV(v: string): string {
|
|
|
43
48
|
return v.startsWith("v") ? v.slice(1) : v;
|
|
44
49
|
}
|
|
45
50
|
|
|
51
|
+
/** Returns true when a cache entry was created by the running Codexa version. */
|
|
52
|
+
export function isCacheForRunningVersion(cache: UpdateCheckCache, runningVersion: string): boolean {
|
|
53
|
+
return stripV(cache.currentVersion) === stripV(runningVersion);
|
|
54
|
+
}
|
|
55
|
+
|
|
46
56
|
/**
|
|
47
57
|
* Returns true only when the cache is still usable:
|
|
48
58
|
* - `runningVersion` matches the version the cache was written for (version-mismatch = stale)
|
|
@@ -54,7 +64,7 @@ export function isCacheValid(
|
|
|
54
64
|
runningVersion?: string,
|
|
55
65
|
): boolean {
|
|
56
66
|
if (runningVersion !== undefined) {
|
|
57
|
-
if (
|
|
67
|
+
if (!isCacheForRunningVersion(cache, runningVersion)) {
|
|
58
68
|
return false;
|
|
59
69
|
}
|
|
60
70
|
}
|
|
@@ -46,30 +46,18 @@ function readCacheFile(cacheFile: string): ProviderModelCacheFile | null {
|
|
|
46
46
|
}
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
// A model id is passed straight to a provider CLI, so it must be a bare token.
|
|
50
|
-
// Discovery scrapes CLI output, and a buggy build can bake an ANSI escape into
|
|
51
|
-
// an id (e.g. "claude-fable-5\x1b[1m"). Reject rather than repair: a cached id
|
|
52
|
-
// we cannot vouch for is dropped so discovery re-probes for a clean one.
|
|
53
|
-
const VALID_MODEL_ID = /^[A-Za-z0-9._:\/-]+$/;
|
|
54
|
-
|
|
55
|
-
function isUsableModel(model: unknown): model is ProviderModel {
|
|
56
|
-
if (typeof model !== "object" || model === null) {
|
|
57
|
-
return false;
|
|
58
|
-
}
|
|
59
|
-
const candidate = model as ProviderModel;
|
|
60
|
-
return typeof candidate.id === "string"
|
|
61
|
-
&& typeof candidate.modelId === "string"
|
|
62
|
-
&& typeof candidate.label === "string"
|
|
63
|
-
&& VALID_MODEL_ID.test(candidate.id)
|
|
64
|
-
&& VALID_MODEL_ID.test(candidate.modelId);
|
|
65
|
-
}
|
|
66
|
-
|
|
67
49
|
function isValidEntry(entry: unknown): entry is CachedProviderModels {
|
|
68
50
|
if (typeof entry !== "object" || entry === null) {
|
|
69
51
|
return false;
|
|
70
52
|
}
|
|
71
53
|
const candidate = entry as CachedProviderModels;
|
|
72
|
-
return typeof candidate.discoveredAt === "number"
|
|
54
|
+
return typeof candidate.discoveredAt === "number"
|
|
55
|
+
&& Array.isArray(candidate.models)
|
|
56
|
+
&& candidate.models.every((model) =>
|
|
57
|
+
typeof model === "object" && model !== null
|
|
58
|
+
&& typeof model.id === "string"
|
|
59
|
+
&& typeof model.modelId === "string"
|
|
60
|
+
&& typeof model.label === "string");
|
|
73
61
|
}
|
|
74
62
|
|
|
75
63
|
export function loadCachedProviderModels(
|
|
@@ -78,14 +66,10 @@ export function loadCachedProviderModels(
|
|
|
78
66
|
): CachedProviderModels | null {
|
|
79
67
|
const cache = readCacheFile(cacheFile);
|
|
80
68
|
const entry = cache?.providers?.[providerId];
|
|
81
|
-
if (!entry || !isValidEntry(entry)) {
|
|
82
|
-
return null;
|
|
83
|
-
}
|
|
84
|
-
const models = entry.models.filter(isUsableModel);
|
|
85
|
-
if (models.length === 0) {
|
|
69
|
+
if (!entry || !isValidEntry(entry) || entry.models.length === 0) {
|
|
86
70
|
return null;
|
|
87
71
|
}
|
|
88
|
-
return
|
|
72
|
+
return entry;
|
|
89
73
|
}
|
|
90
74
|
|
|
91
75
|
export function saveCachedProviderModels(
|
|
@@ -165,7 +165,18 @@ export function getAgyModelSelector(
|
|
|
165
165
|
}
|
|
166
166
|
|
|
167
167
|
export function getAntigravityModelLabel(modelId: string): string {
|
|
168
|
-
|
|
168
|
+
const discovered = getActiveAgyModels().find((m) => m.id === modelId || m.modelId === modelId);
|
|
169
|
+
if (discovered) return discovered.label;
|
|
170
|
+
|
|
171
|
+
const normalizedId = migrateAntigravityLegacyModelId(modelId).modelId;
|
|
172
|
+
const knownLabels: Record<string, string> = {
|
|
173
|
+
"gemini-3.5-flash": "Gemini 3.5 Flash",
|
|
174
|
+
"gemini-3.1-pro": "Gemini 3.1 Pro",
|
|
175
|
+
"claude-sonnet-4.6-thinking": "Claude Sonnet 4.6 (Thinking)",
|
|
176
|
+
"claude-opus-4.6-thinking": "Claude Opus 4.6 (Thinking)",
|
|
177
|
+
"gpt-oss-120b-medium": "GPT-OSS 120B",
|
|
178
|
+
};
|
|
179
|
+
return knownLabels[normalizedId] ?? modelId;
|
|
169
180
|
}
|
|
170
181
|
|
|
171
182
|
// ---------------------------------------------------------------------------
|
|
@@ -199,17 +199,21 @@ export function resolveActiveProviderRoute(options: {
|
|
|
199
199
|
route.modelId = resolveGeminiModelId(route.modelSelection);
|
|
200
200
|
} else if (route.providerId === "google") {
|
|
201
201
|
route.modelId = normalizeGeminiModelId(route.modelId);
|
|
202
|
-
} else if (route.providerId === "anthropic") {
|
|
203
|
-
const discovery = discoverProviderModels("anthropic");
|
|
202
|
+
} else if (route.providerId === "anthropic") {
|
|
203
|
+
const discovery = discoverProviderModels("anthropic");
|
|
204
204
|
const stillAvailable = discovery.models.some((model) =>
|
|
205
205
|
model.modelId === route.modelId ||
|
|
206
206
|
model.id === route.modelId ||
|
|
207
207
|
model.canonicalId === route.modelId
|
|
208
208
|
);
|
|
209
|
-
const hasNonFallbackModels = discovery.models.some((model) => model.source !== "fallback");
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
209
|
+
const hasNonFallbackModels = discovery.models.some((model) => model.source !== "fallback");
|
|
210
|
+
// Preserve explicit versioned Claude IDs even when the current discovery
|
|
211
|
+
// output does not advertise that historical model. Short aliases are the
|
|
212
|
+
// values that need migration to the first currently discovered model.
|
|
213
|
+
const isKnownShortAlias = ANTHROPIC_FALLBACK_MODELS.some((model) => model.modelId === route.modelId);
|
|
214
|
+
if (discovery.status === "ready" && hasNonFallbackModels && discovery.models.length > 0 && !stillAvailable && isKnownShortAlias) {
|
|
215
|
+
route.modelId = discovery.models[0]!.modelId;
|
|
216
|
+
}
|
|
213
217
|
} else if (route.providerId === "local") {
|
|
214
218
|
const discovery = discoverProviderModels("local");
|
|
215
219
|
const selectedModel = typeof discovery.diagnostics?.selectedModel === "string"
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import type { ChildProcess } from "child_process";
|
|
2
|
+
import {
|
|
3
|
+
runCommand,
|
|
4
|
+
runShellCommand,
|
|
5
|
+
type CommandResult,
|
|
6
|
+
type CommandSpec,
|
|
7
|
+
type CommandStreamHandlers,
|
|
8
|
+
} from "../process/CommandRunner.js";
|
|
9
|
+
import { CODEXA_NPM_PACKAGE } from "./updateCheck.js";
|
|
10
|
+
|
|
11
|
+
export type GlobalPackageManager = "npm" | "pnpm" | "yarn" | "bun";
|
|
12
|
+
|
|
13
|
+
const UPDATE_TIMEOUT_MS = 300_000;
|
|
14
|
+
|
|
15
|
+
const PACKAGE_SPEC = `${CODEXA_NPM_PACKAGE}@latest`;
|
|
16
|
+
|
|
17
|
+
// Yarn Classic only — Yarn Berry (v2+) removed `yarn global`, but Berry installs
|
|
18
|
+
// don't produce the global launcher paths we detect, so Classic is the only case.
|
|
19
|
+
const UPDATE_ARGV: Record<GlobalPackageManager, readonly string[]> = {
|
|
20
|
+
npm: ["npm", "install", "-g", PACKAGE_SPEC],
|
|
21
|
+
pnpm: ["pnpm", "add", "-g", PACKAGE_SPEC],
|
|
22
|
+
yarn: ["yarn", "global", "add", PACKAGE_SPEC],
|
|
23
|
+
bun: ["bun", "add", "-g", PACKAGE_SPEC],
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Infers which package manager owns the global Codexa install from the
|
|
28
|
+
* launcher script location (CODEXA_LAUNCHER_SCRIPT, set by bin/codexa.js).
|
|
29
|
+
*/
|
|
30
|
+
export function detectGlobalPackageManager(
|
|
31
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
32
|
+
launcherPathOverride?: string,
|
|
33
|
+
): GlobalPackageManager {
|
|
34
|
+
const launcherPath = launcherPathOverride ?? env.CODEXA_LAUNCHER_SCRIPT ?? process.argv[1] ?? "";
|
|
35
|
+
const normalized = launcherPath.toLowerCase().replace(/\\/g, "/");
|
|
36
|
+
if (!normalized) return "npm";
|
|
37
|
+
|
|
38
|
+
if (normalized.includes("pnpm")) return "pnpm";
|
|
39
|
+
if (normalized.includes("/.bun/") || normalized.includes("/bun/install/global/")) return "bun";
|
|
40
|
+
if (normalized.includes("/.yarn/") || normalized.includes("/yarn/") || normalized.includes(".config/yarn")) {
|
|
41
|
+
return "yarn";
|
|
42
|
+
}
|
|
43
|
+
return "npm";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function getUpdateCommand(pm: GlobalPackageManager): {
|
|
47
|
+
displayCommand: string;
|
|
48
|
+
argv: readonly string[];
|
|
49
|
+
} {
|
|
50
|
+
const argv = UPDATE_ARGV[pm];
|
|
51
|
+
return { displayCommand: argv.join(" "), argv };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface RunUpdateCommandDeps {
|
|
55
|
+
platform?: NodeJS.Platform;
|
|
56
|
+
cwd?: string;
|
|
57
|
+
runCommandFn?: (
|
|
58
|
+
spec: CommandSpec,
|
|
59
|
+
handlers?: CommandStreamHandlers,
|
|
60
|
+
) => { child: ChildProcess; result: Promise<CommandResult>; cancel: () => void };
|
|
61
|
+
runShellCommandFn?: (
|
|
62
|
+
command: string,
|
|
63
|
+
options: Pick<CommandSpec, "cwd" | "env" | "timeoutMs">,
|
|
64
|
+
handlers?: CommandStreamHandlers,
|
|
65
|
+
) => { child: ChildProcess; result: Promise<CommandResult>; cancel: () => void };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Runs the global update command for the detected package manager.
|
|
70
|
+
* POSIX spawns the argv directly; Windows routes the constant, whitelisted
|
|
71
|
+
* command string through cmd.exe so .cmd shims (npm.cmd, pnpm.cmd) resolve —
|
|
72
|
+
* spawning them without a shell throws EINVAL on Node >= 18.20.
|
|
73
|
+
*/
|
|
74
|
+
export function runUpdateCommand(
|
|
75
|
+
pm: GlobalPackageManager,
|
|
76
|
+
handlers: CommandStreamHandlers = {},
|
|
77
|
+
deps: RunUpdateCommandDeps = {},
|
|
78
|
+
): { result: Promise<CommandResult>; cancel: () => void } {
|
|
79
|
+
const platform = deps.platform ?? process.platform;
|
|
80
|
+
const cwd = deps.cwd ?? process.cwd();
|
|
81
|
+
const { displayCommand, argv } = getUpdateCommand(pm);
|
|
82
|
+
|
|
83
|
+
if (platform === "win32") {
|
|
84
|
+
const runShell = deps.runShellCommandFn ?? runShellCommand;
|
|
85
|
+
const { result, cancel } = runShell(displayCommand, { cwd, timeoutMs: UPDATE_TIMEOUT_MS }, handlers);
|
|
86
|
+
return { result, cancel };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const run = deps.runCommandFn ?? runCommand;
|
|
90
|
+
const { result, cancel } = run(
|
|
91
|
+
{ executable: argv[0]!, args: [...argv.slice(1)], cwd, timeoutMs: UPDATE_TIMEOUT_MS },
|
|
92
|
+
handlers,
|
|
93
|
+
);
|
|
94
|
+
return { result, cancel };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const PERMISSION_STDERR_RE = /EACCES|EPERM|permission denied/i;
|
|
98
|
+
|
|
99
|
+
/** npm reports EACCES via stderr with a nonzero exit, not as a spawn error. */
|
|
100
|
+
export function isPermissionError(result: CommandResult): boolean {
|
|
101
|
+
if (result.errorCode === "EACCES" || result.errorCode === "EPERM") return true;
|
|
102
|
+
return result.exitCode !== 0 && PERMISSION_STDERR_RE.test(result.stderr);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function formatPermissionGuidance(pm: GlobalPackageManager): string {
|
|
106
|
+
const { displayCommand } = getUpdateCommand(pm);
|
|
107
|
+
const lines = [
|
|
108
|
+
`${pm} could not write to its global package directory, so the update was not installed.`,
|
|
109
|
+
];
|
|
110
|
+
if (pm === "npm") {
|
|
111
|
+
lines.push(
|
|
112
|
+
"Check where global packages are installed with `npm config get prefix` and make sure that directory is writable by your user, or switch to a user-writable prefix (for example `npm config set prefix ~/.npm-global`, then add its bin directory to PATH).",
|
|
113
|
+
);
|
|
114
|
+
} else {
|
|
115
|
+
lines.push(`Make sure the ${pm} global package directory is writable by your user.`);
|
|
116
|
+
}
|
|
117
|
+
lines.push(`You can also run the command manually in a terminal with the right permissions: ${displayCommand}`);
|
|
118
|
+
return lines.join("\n");
|
|
119
|
+
}
|
|
@@ -154,7 +154,10 @@ export async function checkForUpdates(
|
|
|
154
154
|
}
|
|
155
155
|
}
|
|
156
156
|
|
|
157
|
-
export function formatUpdateInstructions(
|
|
157
|
+
export function formatUpdateInstructions(
|
|
158
|
+
result: UpdateCheckResult | null,
|
|
159
|
+
updateCommand: string = CODEXA_UPDATE_COMMAND,
|
|
160
|
+
): string {
|
|
158
161
|
const current = result?.currentVersion ?? APP_VERSION;
|
|
159
162
|
const latest = result?.latestVersion ?? "unknown";
|
|
160
163
|
|
|
@@ -166,21 +169,24 @@ export function formatUpdateInstructions(result: UpdateCheckResult | null): stri
|
|
|
166
169
|
].join("\n");
|
|
167
170
|
}
|
|
168
171
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
statusLine = "Status unknown — could not reach npm registry.";
|
|
172
|
+
if (result?.status === "up-to-date") {
|
|
173
|
+
return [
|
|
174
|
+
"Codexa is up to date.",
|
|
175
|
+
`Current installed version: ${current}`,
|
|
176
|
+
`npm latest version: ${latest}`,
|
|
177
|
+
].join("\n");
|
|
176
178
|
}
|
|
177
179
|
|
|
180
|
+
const statusLine = result?.status === "update-available" && result.latestVersion
|
|
181
|
+
? `Update available: Codexa ${formatVersionLabel(result.latestVersion)}`
|
|
182
|
+
: "Status unknown — could not reach npm registry.";
|
|
183
|
+
|
|
178
184
|
return [
|
|
179
185
|
`Current installed version: ${current}`,
|
|
180
186
|
`npm latest version: ${latest}`,
|
|
181
187
|
`Status: ${statusLine}`,
|
|
182
188
|
"",
|
|
183
|
-
`Run: ${
|
|
189
|
+
`Run: ${updateCommand}`,
|
|
184
190
|
].join("\n");
|
|
185
191
|
}
|
|
186
192
|
|
|
@@ -1,11 +1,4 @@
|
|
|
1
|
-
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
-
import { tmpdir } from "node:os";
|
|
3
|
-
import { join } from "node:path";
|
|
4
1
|
import { resolveRuntimeConfig, type RuntimeConfig, DEFAULT_RUNTIME_CONFIG, type ResolvedRuntimeConfig } from "../config/runtimeConfig.js";
|
|
5
|
-
import type { ProviderId } from "../core/providerLauncher/types.js";
|
|
6
|
-
import { resetAnthropicRouteValidationCacheForTests } from "../core/providerRuntime/anthropic.js";
|
|
7
|
-
import { resetAntigravityRouteValidationCacheForTests } from "../core/providerRuntime/antigravity.js";
|
|
8
|
-
import type { ProviderModel } from "../core/providerRuntime/types.js";
|
|
9
2
|
|
|
10
3
|
export function makeResolvedRuntime(overrides: Partial<RuntimeConfig> = {}): ResolvedRuntimeConfig {
|
|
11
4
|
return resolveRuntimeConfig({
|
|
@@ -19,86 +12,3 @@ export function makeResolvedRuntime(overrides: Partial<RuntimeConfig> = {}): Res
|
|
|
19
12
|
}
|
|
20
13
|
|
|
21
14
|
export const TEST_RUNTIME = makeResolvedRuntime();
|
|
22
|
-
|
|
23
|
-
function agyModel(id: string, label: string): ProviderModel {
|
|
24
|
-
return {
|
|
25
|
-
id,
|
|
26
|
-
modelId: id,
|
|
27
|
-
label,
|
|
28
|
-
description: null,
|
|
29
|
-
defaultReasoningLevel: null,
|
|
30
|
-
supportedReasoningLevels: null,
|
|
31
|
-
source: "discovered",
|
|
32
|
-
};
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* The catalog `agy models` reports on a configured machine. Antigravity resolves
|
|
37
|
-
* its status, backend kind, and model labels from whatever discovery last found,
|
|
38
|
-
* so tests that assert on any of those need a catalog seeded rather than
|
|
39
|
-
* whatever happens to be installed.
|
|
40
|
-
*/
|
|
41
|
-
export const ANTIGRAVITY_FIXTURE_MODELS: readonly ProviderModel[] = [
|
|
42
|
-
agyModel("gemini-3.5-flash", "Gemini 3.5 Flash"),
|
|
43
|
-
agyModel("gemini-3.1-pro", "Gemini 3.1 Pro"),
|
|
44
|
-
agyModel("claude-sonnet-4-6", "Claude Sonnet 4.6"),
|
|
45
|
-
agyModel("claude-sonnet-4-6-think", "Claude Sonnet 4.6 (Thinking)"),
|
|
46
|
-
agyModel("gpt-oss-120b", "GPT-OSS 120B"),
|
|
47
|
-
];
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* Drops every provider's in-memory discovery result.
|
|
51
|
-
*
|
|
52
|
-
* Bun shares the module registry across test files, so a catalog discovered by
|
|
53
|
-
* one test (some probe the real `claude` / `agy` CLIs) otherwise survives into
|
|
54
|
-
* the next and takes precedence over both the cache and the fallback list —
|
|
55
|
-
* making assertions depend on file order and on what is installed locally.
|
|
56
|
-
*/
|
|
57
|
-
export function resetProviderDiscoveryState(): void {
|
|
58
|
-
resetAntigravityRouteValidationCacheForTests();
|
|
59
|
-
resetAnthropicRouteValidationCacheForTests();
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
/**
|
|
63
|
-
* Runs `fn` with HOME pointed at a throwaway directory holding only the provider
|
|
64
|
-
* model cache built from `seed`.
|
|
65
|
-
*
|
|
66
|
-
* Provider discovery reads `~/.codexa-model-cache.json`, so a test that calls it
|
|
67
|
-
* without this is asserting against the developer's own machine — it passes with
|
|
68
|
-
* a warm cache and fails on a cold one (or in CI). HOME is read per call (see
|
|
69
|
-
* `getProviderModelCacheFile`), so redirecting the env var here is enough.
|
|
70
|
-
*
|
|
71
|
-
* Antigravity's in-memory discovery is reset around the body as well: Bun shares
|
|
72
|
-
* the module registry across test files, so a catalog discovered by one file
|
|
73
|
-
* otherwise leaks into the next and takes precedence over the seeded cache,
|
|
74
|
-
* making results depend on file order. Seed exactly what the assertions need.
|
|
75
|
-
*/
|
|
76
|
-
export function withSeededModelCache<T>(
|
|
77
|
-
seed: Partial<Record<ProviderId, readonly ProviderModel[]>>,
|
|
78
|
-
fn: () => T,
|
|
79
|
-
): T {
|
|
80
|
-
const tempHome = mkdtempSync(join(tmpdir(), "codexa-test-home-"));
|
|
81
|
-
const previousHome = process.env.HOME;
|
|
82
|
-
const previousUserProfile = process.env.USERPROFILE;
|
|
83
|
-
try {
|
|
84
|
-
process.env.HOME = tempHome;
|
|
85
|
-
delete process.env.USERPROFILE;
|
|
86
|
-
const providers = Object.fromEntries(
|
|
87
|
-
Object.entries(seed).map(([providerId, models]) => [providerId, { discoveredAt: 1, models }]),
|
|
88
|
-
);
|
|
89
|
-
writeFileSync(
|
|
90
|
-
join(tempHome, ".codexa-model-cache.json"),
|
|
91
|
-
`${JSON.stringify({ version: 1, providers }, null, 2)}\n`,
|
|
92
|
-
"utf8",
|
|
93
|
-
);
|
|
94
|
-
resetProviderDiscoveryState();
|
|
95
|
-
return fn();
|
|
96
|
-
} finally {
|
|
97
|
-
if (previousHome === undefined) delete process.env.HOME;
|
|
98
|
-
else process.env.HOME = previousHome;
|
|
99
|
-
if (previousUserProfile === undefined) delete process.env.USERPROFILE;
|
|
100
|
-
else process.env.USERPROFILE = previousUserProfile;
|
|
101
|
-
rmSync(tempHome, { recursive: true, force: true });
|
|
102
|
-
resetProviderDiscoveryState();
|
|
103
|
-
}
|
|
104
|
-
}
|
|
@@ -2,7 +2,7 @@ import React, { memo } from "react";
|
|
|
2
2
|
import { Box, Text } from "ink";
|
|
3
3
|
import { HEADER_CONFIG_DEFAULTS, type HeaderConfig } from "../../config/settings.js";
|
|
4
4
|
import { formatCodexaBrandLabel } from "../../core/version/channel.js";
|
|
5
|
-
import { formatVersionLabel } from "../../core/version/updateCheck.js";
|
|
5
|
+
import { CODEXA_UPDATE_COMMAND, formatVersionLabel } from "../../core/version/updateCheck.js";
|
|
6
6
|
import type { RuntimeSummary } from "../../config/runtimeConfig.js";
|
|
7
7
|
import type { CodexAuthState } from "../../core/auth/codexAuth.js";
|
|
8
8
|
import { getAuthStateLabel } from "../../core/auth/codexAuth.js";
|
|
@@ -58,6 +58,7 @@ type HeaderMetadataLine = { key: string; text: string; color: string; bold: bool
|
|
|
58
58
|
export interface UpdateAvailableInfo {
|
|
59
59
|
latestVersion: string;
|
|
60
60
|
currentVersion: string;
|
|
61
|
+
updateCommand?: string;
|
|
61
62
|
}
|
|
62
63
|
|
|
63
64
|
interface TopHeaderProps {
|
|
@@ -343,6 +344,7 @@ export function TopHeader({
|
|
|
343
344
|
<UpdateAvailableCard
|
|
344
345
|
latestVersion={updateAvailable.latestVersion}
|
|
345
346
|
currentVersion={updateAvailable.currentVersion}
|
|
347
|
+
updateCommand={updateAvailable.updateCommand}
|
|
346
348
|
width={metadataWidth}
|
|
347
349
|
/>
|
|
348
350
|
<Box height={UPDATE_CARD_GAP_ROWS} />
|
|
@@ -359,7 +361,7 @@ export function TopHeader({
|
|
|
359
361
|
)}
|
|
360
362
|
{metadataColumn}
|
|
361
363
|
{updateAvailable && (
|
|
362
|
-
<Text color={theme.warning} wrap="truncate">{`Update available: Codexa ${formatVersionLabel(updateAvailable.latestVersion)} — Run:
|
|
364
|
+
<Text color={theme.warning} wrap="truncate">{`Update available: Codexa ${formatVersionLabel(updateAvailable.latestVersion)} — Run: ${updateAvailable.updateCommand ?? CODEXA_UPDATE_COMMAND}`}</Text>
|
|
363
365
|
)}
|
|
364
366
|
</Box>
|
|
365
367
|
)}
|
|
@@ -10,13 +10,14 @@ export const UPDATE_CARD_ROWS = UPDATE_CARD_CONTENT_ROWS + 2; // +2 for top/bott
|
|
|
10
10
|
export interface UpdateAvailableCardProps {
|
|
11
11
|
latestVersion: string;
|
|
12
12
|
currentVersion: string;
|
|
13
|
+
updateCommand?: string;
|
|
13
14
|
/** Total box width including borders. Long lines are truncated to fit. */
|
|
14
15
|
width?: number;
|
|
15
16
|
}
|
|
16
17
|
|
|
17
|
-
export function UpdateAvailableCard({ latestVersion, currentVersion, width }: UpdateAvailableCardProps) {
|
|
18
|
+
export function UpdateAvailableCard({ latestVersion, currentVersion, updateCommand = CODEXA_UPDATE_COMMAND, width }: UpdateAvailableCardProps) {
|
|
18
19
|
const theme = useTheme();
|
|
19
|
-
const command = `Run: ${
|
|
20
|
+
const command = `Run: ${updateCommand}`;
|
|
20
21
|
// Inner content width = boxWidth - 2 (left/right border cols)
|
|
21
22
|
const innerWidth = width !== undefined ? Math.max(8, width - 2) : undefined;
|
|
22
23
|
|
|
@@ -24,6 +24,7 @@ export const SLASH_COMMANDS = [
|
|
|
24
24
|
{ cmd: "/auth", desc: "Manage authentication" },
|
|
25
25
|
{ cmd: "/workspace", desc: "Show the locked workspace" },
|
|
26
26
|
{ cmd: "/copy", desc: "Copy the full conversation transcript to clipboard" },
|
|
27
|
+
{ cmd: "/update", desc: "Check for updates and install the latest Codexa" },
|
|
27
28
|
{ cmd: "/mouse", desc: "Toggle mouse capture for wheel scrolling (off by default)" },
|
|
28
29
|
{ cmd: "/exit", desc: "Quit the application" },
|
|
29
30
|
] as const satisfies readonly SlashCommandSuggestion[];
|
|
@@ -1,31 +1,45 @@
|
|
|
1
1
|
import React, { useEffect, useRef, useState } from "react";
|
|
2
2
|
import { Box, Text, useFocus, useInput } from "ink";
|
|
3
|
-
import { spawn } from "child_process";
|
|
4
3
|
import { useTheme } from "../theme.js";
|
|
5
|
-
import { CODEXA_NPM_PACKAGE,
|
|
4
|
+
import { CODEXA_NPM_PACKAGE, formatVersionLabel } from "../../core/version/updateCheck.js";
|
|
5
|
+
import {
|
|
6
|
+
formatPermissionGuidance,
|
|
7
|
+
getUpdateCommand,
|
|
8
|
+
isPermissionError,
|
|
9
|
+
runUpdateCommand,
|
|
10
|
+
type GlobalPackageManager,
|
|
11
|
+
} from "../../core/version/packageManager.js";
|
|
12
|
+
import type { CommandResult, CommandStreamHandlers } from "../../core/process/CommandRunner.js";
|
|
6
13
|
|
|
7
14
|
type Phase = "menu" | "running" | "done" | "error";
|
|
8
15
|
|
|
16
|
+
export type RunUpdateFn = (
|
|
17
|
+
pm: GlobalPackageManager,
|
|
18
|
+
handlers?: CommandStreamHandlers,
|
|
19
|
+
) => { result: Promise<CommandResult>; cancel: () => void };
|
|
20
|
+
|
|
9
21
|
const MENU_ITEMS = [
|
|
10
22
|
{ label: "Update now" },
|
|
11
|
-
{ label: "
|
|
12
|
-
{ label: "Skip until next version" },
|
|
23
|
+
{ label: "Later" },
|
|
13
24
|
] as const;
|
|
14
25
|
|
|
15
26
|
interface UpdatePromptPanelProps {
|
|
16
27
|
focusId: string;
|
|
17
28
|
currentVersion: string;
|
|
18
29
|
latestVersion: string;
|
|
30
|
+
packageManager: GlobalPackageManager;
|
|
31
|
+
/** Test seam — defaults to the real cross-platform runner. */
|
|
32
|
+
runUpdate?: RunUpdateFn;
|
|
19
33
|
onSkip: () => void;
|
|
20
|
-
onSkipUntilNextVersion: (version: string) => void;
|
|
21
34
|
}
|
|
22
35
|
|
|
23
36
|
export function UpdatePromptPanel({
|
|
24
37
|
focusId,
|
|
25
38
|
currentVersion,
|
|
26
39
|
latestVersion,
|
|
40
|
+
packageManager,
|
|
41
|
+
runUpdate,
|
|
27
42
|
onSkip,
|
|
28
|
-
onSkipUntilNextVersion,
|
|
29
43
|
}: UpdatePromptPanelProps) {
|
|
30
44
|
const theme = useTheme();
|
|
31
45
|
const { isFocused } = useFocus({ id: focusId, autoFocus: true });
|
|
@@ -35,7 +49,7 @@ export function UpdatePromptPanel({
|
|
|
35
49
|
const [outputLines, setOutputLines] = useState<string[]>([]);
|
|
36
50
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
|
37
51
|
|
|
38
|
-
const
|
|
52
|
+
const runStartedRef = useRef(false);
|
|
39
53
|
|
|
40
54
|
useInput((input, key) => {
|
|
41
55
|
if (key.escape) {
|
|
@@ -54,10 +68,8 @@ export function UpdatePromptPanel({
|
|
|
54
68
|
if (key.return) {
|
|
55
69
|
if (selectedIndex === 0) {
|
|
56
70
|
setPhase("running");
|
|
57
|
-
} else if (selectedIndex === 1) {
|
|
58
|
-
onSkip();
|
|
59
71
|
} else {
|
|
60
|
-
|
|
72
|
+
onSkip();
|
|
61
73
|
}
|
|
62
74
|
return;
|
|
63
75
|
}
|
|
@@ -70,42 +82,42 @@ export function UpdatePromptPanel({
|
|
|
70
82
|
|
|
71
83
|
useEffect(() => {
|
|
72
84
|
if (phase !== "running") return;
|
|
73
|
-
if (
|
|
74
|
-
|
|
85
|
+
if (runStartedRef.current) return;
|
|
86
|
+
runStartedRef.current = true;
|
|
75
87
|
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
const appendLines = (buf: Buffer) => {
|
|
82
|
-
const lines = buf.toString("utf8").split(/\r?\n/).filter(Boolean);
|
|
88
|
+
const appendLines = (text: string) => {
|
|
89
|
+
const lines = text.split(/\r?\n/).filter(Boolean);
|
|
83
90
|
if (lines.length > 0) {
|
|
84
91
|
setOutputLines((prev) => [...prev, ...lines]);
|
|
85
92
|
}
|
|
86
93
|
};
|
|
87
94
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
setErrorMessage(err.message);
|
|
93
|
-
setPhase("error");
|
|
95
|
+
const runner = runUpdate ?? runUpdateCommand;
|
|
96
|
+
const { result, cancel } = runner(packageManager, {
|
|
97
|
+
onStdout: appendLines,
|
|
98
|
+
onStderr: appendLines,
|
|
94
99
|
});
|
|
95
100
|
|
|
96
|
-
|
|
97
|
-
|
|
101
|
+
let disposed = false;
|
|
102
|
+
void result.then((res) => {
|
|
103
|
+
if (disposed) return;
|
|
104
|
+
if (res.status === "completed" && res.exitCode === 0) {
|
|
98
105
|
setPhase("done");
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
if (isPermissionError(res)) {
|
|
109
|
+
setErrorMessage(formatPermissionGuidance(packageManager));
|
|
99
110
|
} else {
|
|
100
|
-
setErrorMessage(
|
|
101
|
-
setPhase("error");
|
|
111
|
+
setErrorMessage(res.userMessage);
|
|
102
112
|
}
|
|
113
|
+
setPhase("error");
|
|
103
114
|
});
|
|
104
115
|
|
|
105
116
|
return () => {
|
|
106
|
-
|
|
117
|
+
disposed = true;
|
|
118
|
+
cancel();
|
|
107
119
|
};
|
|
108
|
-
}, [phase]);
|
|
120
|
+
}, [phase, packageManager, runUpdate]);
|
|
109
121
|
|
|
110
122
|
const footerText = phase === "menu"
|
|
111
123
|
? "Esc to close · Enter to confirm"
|
|
@@ -122,16 +134,16 @@ export function UpdatePromptPanel({
|
|
|
122
134
|
flexDirection="column"
|
|
123
135
|
>
|
|
124
136
|
<Box>
|
|
125
|
-
<Text color={theme.accent} bold>{`Update available: Codexa ${
|
|
137
|
+
<Text color={theme.accent} bold>{`Update available: Codexa ${latestVersion}`}</Text>
|
|
126
138
|
</Box>
|
|
127
139
|
<Box marginTop={1}>
|
|
128
|
-
<Text color={theme.text}>{
|
|
140
|
+
<Text color={theme.text}>{`Current version: ${currentVersion}`}</Text>
|
|
129
141
|
</Box>
|
|
130
142
|
<Box>
|
|
131
143
|
<Text color={theme.textMuted}>{`Package: ${CODEXA_NPM_PACKAGE}`}</Text>
|
|
132
144
|
</Box>
|
|
133
145
|
<Box>
|
|
134
|
-
<Text color={theme.textMuted}>{`Run: ${
|
|
146
|
+
<Text color={theme.textMuted}>{`Run: ${getUpdateCommand(packageManager).displayCommand}`}</Text>
|
|
135
147
|
</Box>
|
|
136
148
|
</Box>
|
|
137
149
|
|
|
@@ -146,19 +158,17 @@ export function UpdatePromptPanel({
|
|
|
146
158
|
>
|
|
147
159
|
{phase === "menu" && (
|
|
148
160
|
<>
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
<Text color={index === selectedIndex ? theme.accent : theme.textMuted}>
|
|
152
|
-
{index === selectedIndex ? "› " : " "}
|
|
153
|
-
</Text>
|
|
161
|
+
<Box>
|
|
162
|
+
{MENU_ITEMS.map((item, index) => (
|
|
154
163
|
<Text
|
|
164
|
+
key={item.label}
|
|
155
165
|
color={index === selectedIndex ? theme.text : theme.textMuted}
|
|
156
166
|
bold={index === selectedIndex}
|
|
157
167
|
>
|
|
158
|
-
{
|
|
168
|
+
{`[ ${item.label} ]${index === 0 ? " " : ""}`}
|
|
159
169
|
</Text>
|
|
160
|
-
|
|
161
|
-
|
|
170
|
+
))}
|
|
171
|
+
</Box>
|
|
162
172
|
</>
|
|
163
173
|
)}
|
|
164
174
|
|
|
@@ -173,7 +183,7 @@ export function UpdatePromptPanel({
|
|
|
173
183
|
|
|
174
184
|
{phase === "done" && (
|
|
175
185
|
<>
|
|
176
|
-
<Text color={theme.success}>{
|
|
186
|
+
<Text color={theme.success}>{`Codexa ${formatVersionLabel(latestVersion)} installed successfully.`}</Text>
|
|
177
187
|
<Text color={theme.textMuted}>{"Restart Codexa to use the new version."}</Text>
|
|
178
188
|
</>
|
|
179
189
|
)}
|