@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.
@@ -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: npm install -g @golba98/codexa@latest`}</Text>
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: ${CODEXA_UPDATE_COMMAND}`;
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[];
@@ -43,7 +43,8 @@ export function AttachmentImportPanel({
43
43
  }
44
44
  }, { isActive: isFocused });
45
45
 
46
- const relativeAttachmentsDir = path.relative(workspaceRoot, attachmentsDir).replace(/\\/g, "/");
46
+ const relativeAttachmentsDir = path.relative(workspaceRoot, attachmentsDir).replace(/\\/g, "/");
47
+ const displayedAttachmentsDir = relativeAttachmentsDir.startsWith("..") ? attachmentsDir : relativeAttachmentsDir;
47
48
  const hasImages = files.some((f) => f.isImage);
48
49
  const showVisionWarning = hasImages && modelSupportsVision === false;
49
50
  const fileLabel = files.length === 1 ? "file" : "files";
@@ -59,7 +60,7 @@ export function AttachmentImportPanel({
59
60
  >
60
61
  <Text color={theme.accent} bold>IMPORT FILE </Text>
61
62
  <Text color={theme.textMuted}>
62
- Copy {files.length} outside-workspace {fileLabel} into .codexa/attachments?
63
+ Copy {files.length} outside-workspace {fileLabel} into {displayedAttachmentsDir}?
63
64
  </Text>
64
65
  </Box>
65
66
 
@@ -76,7 +77,7 @@ export function AttachmentImportPanel({
76
77
  <Box key={i} flexDirection="column" marginBottom={i < files.length - 1 ? 1 : 0}>
77
78
  <Text color={theme.text}>{path.basename(file.srcPath)}</Text>
78
79
  <Text color={theme.textDim}>
79
- {"→ "}{relativeAttachmentsDir}/{file.destFilename}
80
+ {"→ "}{displayedAttachmentsDir}/{file.destFilename}
80
81
  </Text>
81
82
  </Box>
82
83
  ))}
@@ -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, CODEXA_UPDATE_COMMAND, formatVersionLabel } from "../../core/version/updateCheck.js";
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: "Skip" },
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 spawnStartedRef = useRef(false);
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
- onSkipUntilNextVersion(latestVersion);
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 (spawnStartedRef.current) return;
74
- spawnStartedRef.current = true;
85
+ if (runStartedRef.current) return;
86
+ runStartedRef.current = true;
75
87
 
76
- const child = spawn("npm", ["install", "-g", `${CODEXA_NPM_PACKAGE}@latest`], {
77
- shell: false,
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
- child.stdout?.on("data", appendLines);
89
- child.stderr?.on("data", appendLines);
90
-
91
- child.once("error", (err: Error) => {
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
- child.once("close", (code: number | null) => {
97
- if (code === 0) {
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(`npm exited with code ${code ?? "unknown"}.`);
101
- setPhase("error");
111
+ setErrorMessage(res.userMessage);
102
112
  }
113
+ setPhase("error");
103
114
  });
104
115
 
105
116
  return () => {
106
- try { child.kill(); } catch { /* ignore */ }
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 ${formatVersionLabel(latestVersion)}`}</Text>
137
+ <Text color={theme.accent} bold>{`Update available: Codexa ${latestVersion}`}</Text>
126
138
  </Box>
127
139
  <Box marginTop={1}>
128
- <Text color={theme.text}>{`${formatVersionLabel(currentVersion)} -> ${formatVersionLabel(latestVersion)}`}</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: ${CODEXA_UPDATE_COMMAND}`}</Text>
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
- {MENU_ITEMS.map((item, index) => (
150
- <Box key={item.label}>
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
- {`${index + 1}. ${item.label}`}
168
+ {`[ ${item.label} ]${index === 0 ? " " : ""}`}
159
169
  </Text>
160
- </Box>
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}>{"Codexa was updated successfully."}</Text>
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
  )}