@golba98/codexa 1.0.5 → 1.0.7
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 +119 -14
- package/bin/codexa.js +15 -4
- package/package.json +1 -1
- package/src/app.tsx +65 -81
- package/src/commands/handler.ts +13 -11
- package/src/config/appVersion.ts +65 -0
- package/src/config/buildInfo.ts +2 -2
- package/src/config/runtimeConfig.ts +1 -1
- package/src/config/settings.ts +5 -1
- package/src/config/updateCheckCache.ts +18 -8
- package/src/core/debug/modelStateDebug.ts +3 -2
- package/src/core/models/providerModelCache.ts +9 -25
- package/src/core/perf/renderDebug.ts +5 -4
- package/src/core/providerLauncher/launcher.ts +1 -1
- package/src/core/providerLauncher/workspaceConfig.ts +27 -13
- package/src/core/providerRuntime/antigravity.ts +12 -1
- package/src/core/providerRuntime/registry.ts +10 -6
- package/src/core/shared/attachments.ts +10 -10
- package/src/core/terminal/frameLock.ts +2 -1
- package/src/core/terminal/terminalTitle.ts +3 -2
- package/src/core/version/packageManager.ts +119 -0
- package/src/core/version/updateCheck.ts +15 -9
- package/src/core/workspace/appData.ts +45 -0
- package/src/core/workspace/planStorage.ts +7 -13
- 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/AttachmentImportPanel.tsx +4 -3
- package/src/ui/panels/UpdatePromptPanel.tsx +53 -43
|
@@ -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
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { appendFileSync, mkdirSync } from "fs";
|
|
2
|
-
import { dirname
|
|
2
|
+
import { dirname } from "path";
|
|
3
|
+
import { resolveCodexaDebugLogPath } from "../workspace/appData.js";
|
|
3
4
|
|
|
4
5
|
type ModelStateDebugDetails = Record<string, unknown>;
|
|
5
6
|
|
|
@@ -12,7 +13,7 @@ export function isModelStateDebugEnabled(): boolean {
|
|
|
12
13
|
export function getModelStateDebugLogPath(): string {
|
|
13
14
|
return process.env.CODEXA_RENDER_DEBUG_FILE?.trim()
|
|
14
15
|
|| process.env.CODEXA_DEBUG_MODEL_STATE_LOG?.trim()
|
|
15
|
-
||
|
|
16
|
+
|| resolveCodexaDebugLogPath();
|
|
16
17
|
}
|
|
17
18
|
|
|
18
19
|
export function traceModelStateDebug(event: string, details: ModelStateDebugDetails = {}): void {
|
|
@@ -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(
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { appendFileSync, mkdirSync } from "fs";
|
|
2
|
-
import { dirname
|
|
3
|
-
import { useEffect, useRef } from "react";
|
|
2
|
+
import { dirname } from "path";
|
|
3
|
+
import { useEffect, useRef } from "react";
|
|
4
|
+
import { resolveCodexaDebugLogPath } from "../workspace/appData.js";
|
|
4
5
|
|
|
5
6
|
type DebugEnv = Record<string, string | undefined>;
|
|
6
7
|
|
|
@@ -11,7 +12,7 @@ let renderTraceEnabled = false;
|
|
|
11
12
|
let lifecycleEnabled = false;
|
|
12
13
|
let flickerEnabled = false;
|
|
13
14
|
let plainActionsEnabled = false;
|
|
14
|
-
let logPath =
|
|
15
|
+
let logPath = resolveCodexaDebugLogPath();
|
|
15
16
|
let sessionId = `${Date.now()}-${process.pid}`;
|
|
16
17
|
const counters = new Map<string, number>();
|
|
17
18
|
|
|
@@ -29,7 +30,7 @@ function configureFromEnv(env: DebugEnv = process.env): void {
|
|
|
29
30
|
lifecycleEnabled = env["CODEXA_DEBUG_LIFECYCLE"] === "1";
|
|
30
31
|
flickerEnabled = env["CODEXA_DEBUG_FLICKER"] === "1";
|
|
31
32
|
plainActionsEnabled = env["CODEXA_DEBUG_PLAIN_ACTIONS"] === "1";
|
|
32
|
-
logPath = env["CODEXA_RENDER_DEBUG_FILE"]?.trim() ||
|
|
33
|
+
logPath = env["CODEXA_RENDER_DEBUG_FILE"]?.trim() || resolveCodexaDebugLogPath(env);
|
|
33
34
|
sessionId = `${Date.now()}-${process.pid}`;
|
|
34
35
|
configured = true;
|
|
35
36
|
}
|
|
@@ -39,7 +39,7 @@ export function buildProviderLaunchSpec(provider: ProviderConfig, cwd: string):
|
|
|
39
39
|
if (!provider.enabled) {
|
|
40
40
|
return {
|
|
41
41
|
status: "disabled",
|
|
42
|
-
message: `${provider.displayName} is disabled. Configure a command in
|
|
42
|
+
message: `${provider.displayName} is disabled. Configure a command in Codexa's provider settings before launching it.`,
|
|
43
43
|
};
|
|
44
44
|
}
|
|
45
45
|
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "fs";
|
|
2
2
|
import { dirname, join } from "path";
|
|
3
|
-
import { DEFAULT_MODEL } from "../../config/settings.js";
|
|
4
|
-
import {
|
|
3
|
+
import { DEFAULT_MODEL } from "../../config/settings.js";
|
|
4
|
+
import { resolveCodexaWorkspaceDataDir } from "../workspace/appData.js";
|
|
5
|
+
import { normalizeWorkspaceRoot } from "../workspace/workspaceRoot.js";
|
|
5
6
|
import { isKnownProviderId } from "./registry.js";
|
|
6
7
|
import { getDefaultRouteModel, getProviderRuntime, isProviderRouteConfigured, isProviderRoutableInCodexa } from "../providerRuntime/registry.js";
|
|
7
8
|
import { normalizeGeminiModelId } from "../providerRuntime/models.js";
|
|
@@ -17,8 +18,12 @@ const DEPRECATED_ANTIGRAVITY_PROVIDER_ID = "antigravity";
|
|
|
17
18
|
const DEPRECATED_ANTIGRAVITY_BACKENDS = new Set(["antigravity-cli-auth", "agy"]);
|
|
18
19
|
const DEPRECATED_GOOGLE_PROVIDER_ID = "google";
|
|
19
20
|
|
|
20
|
-
export function getProviderWorkspaceConfigFile(workspaceRoot: string): string {
|
|
21
|
-
return join(normalizeWorkspaceRoot(workspaceRoot), "
|
|
21
|
+
export function getProviderWorkspaceConfigFile(workspaceRoot: string): string {
|
|
22
|
+
return join(resolveCodexaWorkspaceDataDir(normalizeWorkspaceRoot(workspaceRoot)), "providers.json");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function getLegacyProviderWorkspaceConfigFile(workspaceRoot: string): string {
|
|
26
|
+
return join(normalizeWorkspaceRoot(workspaceRoot), ".codexa", "providers.json");
|
|
22
27
|
}
|
|
23
28
|
|
|
24
29
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
@@ -345,15 +350,24 @@ export function serializeProviderWorkspaceConfig(config: ProviderWorkspaceConfig
|
|
|
345
350
|
};
|
|
346
351
|
}
|
|
347
352
|
|
|
348
|
-
export function loadProviderWorkspaceConfig(workspaceRoot: string): ProviderWorkspaceConfig {
|
|
349
|
-
const filePath = getProviderWorkspaceConfigFile(workspaceRoot);
|
|
350
|
-
if (
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
}
|
|
353
|
+
export function loadProviderWorkspaceConfig(workspaceRoot: string): ProviderWorkspaceConfig {
|
|
354
|
+
const filePath = getProviderWorkspaceConfigFile(workspaceRoot);
|
|
355
|
+
if (existsSync(filePath)) {
|
|
356
|
+
try {
|
|
357
|
+
return parseProviderWorkspaceConfig(JSON.parse(readFileSync(filePath, "utf-8")));
|
|
358
|
+
} catch {
|
|
359
|
+
return {};
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const legacyFilePath = getLegacyProviderWorkspaceConfigFile(workspaceRoot);
|
|
364
|
+
if (!existsSync(legacyFilePath)) return {};
|
|
365
|
+
try {
|
|
366
|
+
return parseProviderWorkspaceConfig(JSON.parse(readFileSync(legacyFilePath, "utf-8")));
|
|
367
|
+
} catch {
|
|
368
|
+
return {};
|
|
369
|
+
}
|
|
370
|
+
}
|
|
357
371
|
|
|
358
372
|
export function saveProviderWorkspaceConfig(workspaceRoot: string, config: ProviderWorkspaceConfig): void {
|
|
359
373
|
const filePath = getProviderWorkspaceConfigFile(workspaceRoot);
|
|
@@ -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"
|
|
@@ -74,16 +74,16 @@ export async function importExternalFile(
|
|
|
74
74
|
return destPath;
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
-
export function rewritePromptWithImportedPaths(
|
|
78
|
-
prompt: string,
|
|
79
|
-
replacements: Array<{ rawPath: string;
|
|
80
|
-
): string {
|
|
81
|
-
let result = prompt;
|
|
82
|
-
for (const { rawPath,
|
|
83
|
-
const needsQuotes = /\s/.test(
|
|
84
|
-
const quotedReplacement = needsQuotes
|
|
85
|
-
? `"${
|
|
86
|
-
:
|
|
77
|
+
export function rewritePromptWithImportedPaths(
|
|
78
|
+
prompt: string,
|
|
79
|
+
replacements: Array<{ rawPath: string; replacementPath: string }>,
|
|
80
|
+
): string {
|
|
81
|
+
let result = prompt;
|
|
82
|
+
for (const { rawPath, replacementPath } of replacements) {
|
|
83
|
+
const needsQuotes = /\s/.test(replacementPath);
|
|
84
|
+
const quotedReplacement = needsQuotes
|
|
85
|
+
? `"${replacementPath}"`
|
|
86
|
+
: replacementPath;
|
|
87
87
|
// Replace quoted forms first so the unquoted pass doesn't double-replace
|
|
88
88
|
result = result.split(`"${rawPath}"`).join(quotedReplacement);
|
|
89
89
|
result = result.split(`'${rawPath}'`).join(quotedReplacement);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import { resolveCodexaDebugLogPath } from "../workspace/appData.js";
|
|
3
4
|
import { isTerminalResizing } from "./terminalControl.js";
|
|
4
5
|
|
|
5
6
|
export interface FrameLockOptions {
|
|
@@ -28,7 +29,7 @@ export function wrapStdoutWithFrameLock({
|
|
|
28
29
|
if (env.CODEXA_RENDER_DEBUG === "1") {
|
|
29
30
|
try {
|
|
30
31
|
const logPath = env.CODEXA_RENDER_DEBUG_FILE?.trim()
|
|
31
|
-
||
|
|
32
|
+
|| resolveCodexaDebugLogPath(env);
|
|
32
33
|
const logDir = path.dirname(logPath);
|
|
33
34
|
if (!fs.existsSync(logDir)) {
|
|
34
35
|
fs.mkdirSync(logDir, { recursive: true });
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { APP_NAME, type TerminalTitleMode, formatTerminalTitlePath } from "../../config/settings.js";
|
|
2
2
|
import { appendFileSync, mkdirSync } from "fs";
|
|
3
|
-
import { dirname
|
|
3
|
+
import { dirname } from "path";
|
|
4
4
|
import * as renderDebug from "../perf/renderDebug.js";
|
|
5
|
+
import { resolveCodexaDebugLogPath } from "../workspace/appData.js";
|
|
5
6
|
|
|
6
7
|
export const DEFAULT_TERMINAL_TITLE = APP_NAME;
|
|
7
8
|
|
|
@@ -52,7 +53,7 @@ function findIncompleteOscTitleStart(text: string): number {
|
|
|
52
53
|
|
|
53
54
|
const TERMINAL_TITLE_DEBUG_LOG_PATH = process.env["CODEXA_TERMINAL_TITLE_DEBUG_FILE"]?.trim()
|
|
54
55
|
|| process.env["CODEXA_RENDER_DEBUG_FILE"]?.trim()
|
|
55
|
-
||
|
|
56
|
+
|| resolveCodexaDebugLogPath();
|
|
56
57
|
|
|
57
58
|
let terminalTitleLifecycleState = "unknown";
|
|
58
59
|
|
|
@@ -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
|
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { isAbsolute, join, normalize } from "node:path";
|
|
4
|
+
|
|
5
|
+
type Platform = "win32" | "darwin" | "linux" | string;
|
|
6
|
+
type Environment = Record<string, string | undefined>;
|
|
7
|
+
|
|
8
|
+
export function resolveCodexaDataDir(
|
|
9
|
+
platformOverride?: Platform,
|
|
10
|
+
env: Environment = process.env,
|
|
11
|
+
home = homedir(),
|
|
12
|
+
): string {
|
|
13
|
+
const configuredDir = env["CODEXA_DATA_DIR"]?.trim();
|
|
14
|
+
if (configuredDir) return configuredDir;
|
|
15
|
+
|
|
16
|
+
const platform = platformOverride ?? process.platform;
|
|
17
|
+
if (platform === "win32") {
|
|
18
|
+
return join(env["LOCALAPPDATA"]?.trim() || env["APPDATA"]?.trim() || join(home, "AppData", "Local"), "Codexa");
|
|
19
|
+
}
|
|
20
|
+
if (platform === "darwin") {
|
|
21
|
+
return join(home, "Library", "Application Support", "Codexa");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return join(env["XDG_DATA_HOME"]?.trim() || join(home, ".local", "share"), "codexa");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function workspaceStorageKey(workspaceRoot: string): string {
|
|
28
|
+
return createHash("sha256").update(workspaceRoot).digest("hex").slice(0, 16);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function resolveCodexaWorkspaceDataDir(workspaceRoot: string): string {
|
|
32
|
+
return join(resolveCodexaDataDir(), "workspaces", workspaceStorageKey(workspaceRoot));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function resolveCodexaAttachmentDir(workspaceRoot: string, configuredDir: string): string {
|
|
36
|
+
if (isAbsolute(configuredDir)) return configuredDir;
|
|
37
|
+
|
|
38
|
+
const normalized = configuredDir.trim().replace(/\\/g, "/").replace(/^\.codexa\/?/, "") || "attachments";
|
|
39
|
+
const safeRelativeDir = normalize(normalized).replace(/^(\.\.([/\\]|$))+/, "") || "attachments";
|
|
40
|
+
return join(resolveCodexaWorkspaceDataDir(workspaceRoot), safeRelativeDir);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function resolveCodexaDebugLogPath(env: Environment = process.env): string {
|
|
44
|
+
return join(resolveCodexaDataDir(undefined, env), "debug", "render-status.log");
|
|
45
|
+
}
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { createHash } from "crypto";
|
|
2
2
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
3
|
-
import { homedir } from "os";
|
|
4
|
-
import { join } from "path";
|
|
5
|
-
import { isNoiseLine } from "../providers/codexTranscript.js";
|
|
6
|
-
import { sanitizeTerminalOutput } from "../terminal/terminalSanitize.js";
|
|
3
|
+
import { homedir } from "os";
|
|
4
|
+
import { join } from "path";
|
|
5
|
+
import { isNoiseLine } from "../providers/codexTranscript.js";
|
|
6
|
+
import { sanitizeTerminalOutput } from "../terminal/terminalSanitize.js";
|
|
7
|
+
import { resolveCodexaDataDir } from "./appData.js";
|
|
7
8
|
|
|
8
9
|
type Platform = "win32" | "darwin" | "linux" | string;
|
|
9
10
|
|
|
@@ -91,15 +92,8 @@ export function resolvePlanDir(platformOverride?: Platform): string {
|
|
|
91
92
|
return join(homedir(), "AppData", "Local", "Codexa", "plans");
|
|
92
93
|
}
|
|
93
94
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
// Linux and other Unix-like
|
|
99
|
-
const xdgDataHome = process.env["XDG_DATA_HOME"];
|
|
100
|
-
if (xdgDataHome) return join(xdgDataHome, "codexa", "plans");
|
|
101
|
-
return join(homedir(), ".local", "share", "codexa", "plans");
|
|
102
|
-
}
|
|
95
|
+
return join(resolveCodexaDataDir(platform), "plans");
|
|
96
|
+
}
|
|
103
97
|
|
|
104
98
|
// SHA-256 of the workspace path ensures filename uniqueness across projects with the same name.
|
|
105
99
|
function workspaceHash(workspaceRoot: string): string {
|