@oh-my-pi/pi-coding-agent 17.1.2 → 17.1.4

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.
Files changed (110) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/dist/cli.js +3806 -3798
  3. package/dist/types/cli/__tests__/auth-gateway-catalog.test.d.ts +1 -0
  4. package/dist/types/cli/auth-gateway-cli.d.ts +8 -0
  5. package/dist/types/cli/update-cli.d.ts +41 -0
  6. package/dist/types/cli/usage-cli.d.ts +4 -2
  7. package/dist/types/commands/update.d.ts +1 -0
  8. package/dist/types/config/model-registry.d.ts +19 -0
  9. package/dist/types/config/settings-schema.d.ts +30 -7
  10. package/dist/types/cursor.d.ts +47 -1
  11. package/dist/types/eval/js/process-entry.d.ts +2 -1
  12. package/dist/types/eval/js/worker-core.d.ts +4 -1
  13. package/dist/types/extensibility/extensions/runner.d.ts +1 -0
  14. package/dist/types/extensibility/legacy-pi-ai-shim.d.ts +4 -0
  15. package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +4 -0
  16. package/dist/types/extensibility/legacy-pi-tui-shim.d.ts +15 -7
  17. package/dist/types/extensibility/shared-events.d.ts +2 -0
  18. package/dist/types/modes/components/assistant-message.d.ts +9 -0
  19. package/dist/types/modes/components/custom-editor.d.ts +12 -8
  20. package/dist/types/plan-mode/approved-plan.d.ts +3 -2
  21. package/dist/types/secrets/obfuscator.d.ts +7 -31
  22. package/dist/types/session/agent-session.d.ts +19 -4
  23. package/dist/types/session/prewalk.d.ts +2 -1
  24. package/dist/types/session/session-advisors.d.ts +8 -0
  25. package/dist/types/session/session-metadata.d.ts +29 -0
  26. package/dist/types/session/turn-recovery.d.ts +5 -5
  27. package/dist/types/stt/sherpa-runtime.d.ts +38 -0
  28. package/dist/types/thinking.d.ts +24 -0
  29. package/dist/types/tools/auto-generated-guard.d.ts +5 -2
  30. package/dist/types/tools/bash.d.ts +5 -4
  31. package/dist/types/tools/computer/exposure.d.ts +8 -0
  32. package/dist/types/tools/computer/supervisor.d.ts +1 -1
  33. package/dist/types/tools/computer/worker-entry.d.ts +1 -1
  34. package/dist/types/tools/computer/worker.d.ts +1 -1
  35. package/dist/types/tools/computer.d.ts +6 -0
  36. package/dist/types/tools/memory-render.d.ts +1 -4
  37. package/dist/types/tools/shell-tokenize.d.ts +14 -0
  38. package/dist/types/tools/todo.d.ts +7 -1
  39. package/package.json +12 -12
  40. package/src/advisor/__tests__/advisor.test.ts +80 -10
  41. package/src/advisor/runtime.ts +27 -1
  42. package/src/cli/__tests__/auth-gateway-catalog.test.ts +111 -0
  43. package/src/cli/auth-gateway-cli.ts +63 -16
  44. package/src/cli/config-cli.ts +25 -7
  45. package/src/cli/update-cli.ts +229 -29
  46. package/src/cli/usage-cli.ts +144 -15
  47. package/src/cli.ts +21 -15
  48. package/src/commands/update.ts +6 -0
  49. package/src/config/__tests__/model-registry.test.ts +42 -7
  50. package/src/config/model-registry.ts +67 -4
  51. package/src/config/settings-schema.ts +39 -8
  52. package/src/config/settings.ts +24 -2
  53. package/src/cursor.ts +153 -0
  54. package/src/dap/session.ts +70 -11
  55. package/src/edit/hashline/filesystem.ts +1 -1
  56. package/src/edit/modes/patch.ts +1 -1
  57. package/src/eval/__tests__/js-context-manager.test.ts +9 -1
  58. package/src/eval/__tests__/process-entry-import.test.ts +111 -1
  59. package/src/eval/js/process-entry.ts +9 -5
  60. package/src/eval/js/worker-core.ts +6 -2
  61. package/src/exec/bash-executor.ts +4 -2
  62. package/src/extensibility/extensions/runner.ts +23 -8
  63. package/src/extensibility/legacy-pi-ai-shim.ts +9 -0
  64. package/src/extensibility/legacy-pi-coding-agent-shim.ts +14 -2
  65. package/src/extensibility/legacy-pi-tui-shim.ts +33 -0
  66. package/src/extensibility/shared-events.ts +2 -0
  67. package/src/main.ts +22 -1
  68. package/src/modes/acp/acp-agent.ts +6 -0
  69. package/src/modes/components/assistant-message.ts +88 -11
  70. package/src/modes/components/chat-transcript-builder.ts +2 -0
  71. package/src/modes/components/custom-editor.test.ts +170 -0
  72. package/src/modes/components/custom-editor.ts +79 -29
  73. package/src/modes/components/settings-defs.ts +5 -1
  74. package/src/modes/controllers/event-controller.ts +96 -4
  75. package/src/modes/controllers/selector-controller.ts +14 -0
  76. package/src/modes/interactive-mode.ts +34 -8
  77. package/src/modes/utils/context-usage.ts +19 -2
  78. package/src/modes/utils/interactive-context-helpers.ts +1 -0
  79. package/src/plan-mode/approved-plan.ts +18 -10
  80. package/src/prompts/system/custom-system-prompt.md +1 -1
  81. package/src/prompts/system/system-prompt.md +10 -1
  82. package/src/prompts/tools/ast-edit.md +1 -1
  83. package/src/sdk.ts +4 -0
  84. package/src/secrets/obfuscator.ts +36 -126
  85. package/src/session/agent-session.ts +69 -60
  86. package/src/session/prewalk.ts +8 -3
  87. package/src/session/session-advisors.ts +67 -3
  88. package/src/session/session-metadata.ts +53 -0
  89. package/src/session/session-provider-boundary.ts +0 -1
  90. package/src/session/session-tools.ts +35 -1
  91. package/src/session/stream-guards.ts +1 -1
  92. package/src/session/turn-recovery.ts +36 -19
  93. package/src/slash-commands/builtin-registry.ts +49 -7
  94. package/src/stt/asr-worker.ts +2 -37
  95. package/src/stt/sherpa-runtime.ts +71 -0
  96. package/src/task/executor.ts +6 -5
  97. package/src/thinking.ts +39 -0
  98. package/src/tools/auto-generated-guard.ts +18 -5
  99. package/src/tools/bash.ts +43 -15
  100. package/src/tools/computer/exposure.ts +38 -0
  101. package/src/tools/computer/supervisor.ts +61 -13
  102. package/src/tools/computer/worker-entry.ts +28 -19
  103. package/src/tools/computer/worker.ts +3 -7
  104. package/src/tools/computer.ts +65 -10
  105. package/src/tools/gh-cache-invalidation.ts +2 -82
  106. package/src/tools/memory-render.ts +11 -2
  107. package/src/tools/render-utils.ts +8 -3
  108. package/src/tools/shell-tokenize.ts +83 -0
  109. package/src/tools/todo.ts +44 -17
  110. package/src/tools/write.ts +1 -1
@@ -0,0 +1,111 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { AuthStorage } from "@oh-my-pi/pi-ai";
3
+ import { getBundledModels } from "@oh-my-pi/pi-catalog/models";
4
+ import { TempDir } from "@oh-my-pi/pi-utils";
5
+ import { ModelRegistry } from "../../config/model-registry";
6
+ import { indexModelsByRequestId } from "../auth-gateway-cli";
7
+
8
+ function stubAuthStorage(configKeys?: string[]): AuthStorage {
9
+ const stub = {
10
+ setFallbackResolver: () => {},
11
+ clearConfigApiKeys: () => {},
12
+ setConfigApiKey: (provider: string) => configKeys?.push(provider),
13
+ removeConfigApiKey: () => {},
14
+ hasAuth: () => true,
15
+ getAll: () => ({ anthropic: {} }),
16
+ };
17
+ return stub as unknown as AuthStorage;
18
+ }
19
+
20
+ describe("indexModelsByRequestId (auth-gateway catalog)", () => {
21
+ test("resolves a discovery-only model absent from the bundled catalog", () => {
22
+ const registry = new ModelRegistry(stubAuthStorage());
23
+ // Simulate a model reached via provider discovery but not compiled into
24
+ // the bundle (e.g. a post-release id). registerProvider merges it into
25
+ // getAll() exactly as runtime discovery does.
26
+ registry.registerProvider("anthropic", {
27
+ baseUrl: "https://api.anthropic.com",
28
+ api: "anthropic-messages",
29
+ apiKey: "test-key",
30
+ models: [
31
+ {
32
+ id: "claude-opus-5-repro",
33
+ name: "Claude Opus 5 (repro)",
34
+ reasoning: false,
35
+ input: ["text"],
36
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
37
+ contextWindow: 200000,
38
+ maxTokens: 8192,
39
+ },
40
+ ],
41
+ });
42
+ expect(getBundledModels("anthropic").map(m => m.id)).not.toContain("claude-opus-5-repro");
43
+
44
+ const index = indexModelsByRequestId(registry.getAll(), new Set(["anthropic"]));
45
+
46
+ // The gateway can now resolve it by qualified id and bare id — was
47
+ // "Unknown model" when the index was built from getBundledModels only.
48
+ expect(index.get("anthropic/claude-opus-5-repro")?.id).toBe("claude-opus-5-repro");
49
+ expect(index.get("claude-opus-5-repro")?.id).toBe("claude-opus-5-repro");
50
+ });
51
+
52
+ test("gateway registry ignores local models.yml credential and routing overrides", async () => {
53
+ using tempDir = TempDir.createSync("@omp-auth-gateway-catalog-");
54
+ const modelsPath = tempDir.join("models.yml");
55
+ // anthropic: a plain credential/baseUrl override (no transport) — the
56
+ // reviewer's leak. openai: a pi-native gateway route — the self-routing loop.
57
+ await Bun.write(
58
+ modelsPath,
59
+ [
60
+ "providers:",
61
+ " anthropic:",
62
+ " baseUrl: http://127.0.0.1:18899",
63
+ " apiKey: gateway-token",
64
+ " openai:",
65
+ " baseUrl: http://127.0.0.1:18899",
66
+ " apiKey: gateway-token",
67
+ " transport: pi-native",
68
+ "",
69
+ ].join("\n"),
70
+ );
71
+
72
+ // A normal client registry applies the local overrides and installs the
73
+ // config API keys into AuthStorage.
74
+ const clientKeys: string[] = [];
75
+ const clientRegistry = new ModelRegistry(stubAuthStorage(clientKeys), modelsPath);
76
+ expect(clientRegistry.find("anthropic", "claude-sonnet-4-5")?.baseUrl).toBe("http://127.0.0.1:18899");
77
+ expect(clientRegistry.getAll().find(model => model.provider === "openai")?.transport).toBe("pi-native");
78
+ expect(clientKeys).toContain("anthropic");
79
+
80
+ // The gateway registry ignores models.yml entirely: bundled routing wins,
81
+ // no config key reaches AuthStorage, and no pi-native self-route survives.
82
+ const gatewayKeys: string[] = [];
83
+ const gatewayRegistry = new ModelRegistry(stubAuthStorage(gatewayKeys), modelsPath, {
84
+ ignoreLocalModelConfig: true,
85
+ });
86
+ const gatewayModel = gatewayRegistry.find("anthropic", "claude-sonnet-4-5");
87
+ const bundledModel = getBundledModels("anthropic").find(model => model.id === "claude-sonnet-4-5");
88
+ if (!gatewayModel || !bundledModel) throw new Error("expected bundled Anthropic model");
89
+
90
+ expect(gatewayModel.baseUrl).toBe(bundledModel.baseUrl);
91
+ expect(gatewayModel.transport).toBeUndefined();
92
+ expect(gatewayKeys).toHaveLength(0);
93
+ expect(gatewayRegistry.getAll().find(model => model.provider === "openai")?.transport).toBeUndefined();
94
+ expect(indexModelsByRequestId(gatewayRegistry.getAll(), new Set(["anthropic"])).get(gatewayModel.id)).toBe(
95
+ gatewayModel,
96
+ );
97
+ });
98
+
99
+ test("scopes the catalog to providers with credentials", () => {
100
+ const registry = new ModelRegistry(stubAuthStorage());
101
+ const all = registry.getAll();
102
+ const anthropicModel = all.find(m => m.provider === "anthropic");
103
+ const foreignModel = all.find(m => m.provider !== "anthropic");
104
+ if (!anthropicModel || !foreignModel) throw new Error("expected mixed-provider bundled catalog");
105
+
106
+ const index = indexModelsByRequestId(all, new Set(["anthropic"]));
107
+
108
+ expect(index.get(`anthropic/${anthropicModel.id}`)).toBeDefined();
109
+ expect(index.get(`${foreignModel.provider}/${foreignModel.id}`)).toBeUndefined();
110
+ });
111
+ });
@@ -31,9 +31,10 @@ import {
31
31
  type SnapshotResponse,
32
32
  } from "@oh-my-pi/pi-ai/auth-broker";
33
33
  import { DEFAULT_AUTH_GATEWAY_BIND, startAuthGateway } from "@oh-my-pi/pi-ai/auth-gateway";
34
- import { type GeneratedProvider, getBundledModels, getBundledProviders } from "@oh-my-pi/pi-catalog/models";
35
- import { getConfigRootDir, isEnoent, VERSION } from "@oh-my-pi/pi-utils";
34
+ import { type GeneratedProvider, getBundledModels } from "@oh-my-pi/pi-catalog/models";
35
+ import { getConfigRootDir, isEnoent, logger, VERSION } from "@oh-my-pi/pi-utils";
36
36
  import chalk from "chalk";
37
+ import { ModelRegistry } from "../config/model-registry";
37
38
  import { type AuthBrokerClientConfig, resolveAuthBrokerConfig } from "../session/auth-broker-config";
38
39
 
39
40
  export type AuthGatewayAction = "serve" | "token" | "status" | "check";
@@ -140,6 +141,33 @@ async function fetchBrokerSnapshot(client: AuthBrokerClient): Promise<SnapshotRe
140
141
  return result.snapshot;
141
142
  }
142
143
 
144
+ /**
145
+ * How often a long-lived `serve` rebuilds its catalog from the registry so
146
+ * models discovered after boot become routable without a restart. `refresh()`
147
+ * reuses the `models.db` cache and only hits the network when a provider's
148
+ * cached row is stale, so a short interval stays cheap.
149
+ */
150
+ const CATALOG_REFRESH_INTERVAL_MS = 15 * 60 * 1000;
151
+
152
+ /**
153
+ * Index resolvable models by the request ids clients may send: the
154
+ * provider-qualified `provider/id` (always) and the bare `id` (first-write-wins
155
+ * fallback for legacy clients). Scoped to providers the gateway holds broker
156
+ * credentials for, since only those are routable.
157
+ */
158
+ export function indexModelsByRequestId(
159
+ models: readonly Model<Api>[],
160
+ providersWithCreds: ReadonlySet<string>,
161
+ ): Map<string, Model<Api>> {
162
+ const modelById = new Map<string, Model<Api>>();
163
+ for (const model of models) {
164
+ if (!providersWithCreds.has(model.provider)) continue;
165
+ modelById.set(`${model.provider}/${model.id}`, model);
166
+ if (!modelById.has(model.id)) modelById.set(model.id, model);
167
+ }
168
+ return modelById;
169
+ }
170
+
143
171
  async function runServe(flags: AuthGatewayCommandArgs["flags"]): Promise<void> {
144
172
  const brokerConfig = await resolveAuthBrokerConfig();
145
173
  if (!brokerConfig) {
@@ -169,23 +197,23 @@ async function runServe(flags: AuthGatewayCommandArgs["flags"]): Promise<void> {
169
197
  });
170
198
  await storage.reload();
171
199
 
172
- // Build the model resolver + catalog from pi-ai's bundled metadata, scoped
173
- // to providers we hold credentials for. Format handlers ask `resolveModel`
174
- // to translate a client-requested `model` field into a pi-ai `Model<Api>`
175
- // before dispatch; `listModels` powers `/v1/models`.
200
+ // Build the model resolver + catalog from the ModelRegistry the same
201
+ // component the TUI/CLI use — scoped to providers we hold credentials for.
202
+ // `getAll()` is a superset of the bundled catalog (bundled first, then
203
+ // cached + broker-discovered), so the discovery-only models omp itself
204
+ // reaches become routable through the gateway instead of freezing on the
205
+ // compiled snapshot. `ignoreLocalModelConfig` keeps the host's `models.yml`
206
+ // out of the picture: client-side provider overrides (baseUrl/apiKey/headers/
207
+ // transport) and custom models must never route a broker-backed gateway or
208
+ // shadow broker credentials. Format handlers ask `resolveModel` to translate
209
+ // a client-requested `model` field into a pi-ai `Model<Api>` before dispatch;
210
+ // `listModels` powers `/v1/models`.
176
211
  const snapshot = storage.exportSnapshot();
177
212
  const providersWithCreds = new Set<string>();
178
213
  for (const entry of snapshot.credentials) providersWithCreds.add(entry.provider);
179
- const modelById = new Map<string, Model<Api>>();
180
- for (const provider of getBundledProviders()) {
181
- if (!providersWithCreds.has(provider)) continue;
182
- for (const model of getBundledModels(provider as GeneratedProvider)) {
183
- // Always set the qualified key (no collision possible)
184
- modelById.set(`${model.provider}/${model.id}`, model);
185
- // Bare id as fallback for legacy clients (first-write-wins)
186
- if (!modelById.has(model.id)) modelById.set(model.id, model);
187
- }
188
- }
214
+ const registry = new ModelRegistry(storage, undefined, { ignoreLocalModelConfig: true });
215
+ await registry.refresh();
216
+ let modelById = indexModelsByRequestId(registry.getAll(), providersWithCreds);
189
217
 
190
218
  const handle = startAuthGateway({
191
219
  storage,
@@ -203,12 +231,31 @@ async function runServe(flags: AuthGatewayCommandArgs["flags"]): Promise<void> {
203
231
  }
204
232
  process.stdout.write(`upstream broker: ${brokerConfig.url}\n`);
205
233
 
234
+ // `serve` is long-lived: rebuild the catalog periodically so models
235
+ // discovered after boot become routable without a restart. A failed refresh
236
+ // keeps serving the previous catalog. `unref()` so the timer never keeps the
237
+ // process alive on its own.
238
+ const catalogRefresh = setInterval(() => {
239
+ void registry
240
+ .refresh()
241
+ .then(() => {
242
+ modelById = indexModelsByRequestId(registry.getAll(), providersWithCreds);
243
+ })
244
+ .catch(error => {
245
+ logger.warn("auth-gateway catalog refresh failed", {
246
+ error: error instanceof Error ? error.message : String(error),
247
+ });
248
+ });
249
+ }, CATALOG_REFRESH_INTERVAL_MS);
250
+ catalogRefresh.unref();
251
+
206
252
  const stopped = Promise.withResolvers<void>();
207
253
  let shutdownStarted = false;
208
254
  const stop = async (signal: NodeJS.Signals): Promise<void> => {
209
255
  if (shutdownStarted) return;
210
256
  shutdownStarted = true;
211
257
  process.stdout.write(`\nReceived ${signal}, shutting down...\n`);
258
+ clearInterval(catalogRefresh);
212
259
  let closeError: unknown;
213
260
  try {
214
261
  await handle.close();
@@ -12,6 +12,7 @@ import {
12
12
  getEnumValues,
13
13
  getType,
14
14
  getUi,
15
+ isCredential,
15
16
  type SettingPath,
16
17
  Settings,
17
18
  type SettingValue,
@@ -49,6 +50,9 @@ type CliSettingDef = {
49
50
 
50
51
  const ALL_SETTING_PATHS = Object.keys(SETTINGS_SCHEMA) as SettingPath[];
51
52
 
53
+ /** Printed instead of a credential value in human output only. */
54
+ const REDACTED = "********";
55
+
52
56
  /** Find setting definition by path */
53
57
  function findSettingDef(path: string): CliSettingDef | undefined {
54
58
  if (!(path in SETTINGS_SCHEMA)) return undefined;
@@ -277,13 +281,22 @@ async function handleList(flags: { json?: boolean }): Promise<void> {
277
281
  const defs = ALL_SETTING_PATHS.map(path => findSettingDef(path)).filter((def): def is CliSettingDef => !!def);
278
282
 
279
283
  if (flags.json) {
280
- const result: Record<string, { value: unknown; type: string; description: string }> = {};
284
+ // A redacted entry omits `value` and says so, rather than substituting a
285
+ // placeholder string: a consumer cannot tell a stand-in from a real value
286
+ // and could write it back as the credential.
287
+ //
288
+ // Redaction is driven by the value, not by classification alone. Marking an
289
+ // unset credential as redacted would report every fresh install as having
290
+ // one configured, which leaks the opposite of what redaction is for. The
291
+ // settings panel persists "" when a credential is cleared and renders that
292
+ // as unset; the same semantics apply here (credentials are all strings).
293
+ const result: Record<string, { value?: unknown; redacted?: true; type: string; description: string }> = {};
281
294
  for (const def of defs) {
282
- result[def.path] = {
283
- value: settings.get(def.path),
284
- type: def.type,
285
- description: def.description,
286
- };
295
+ const value = settings.get(def.path);
296
+ result[def.path] =
297
+ isCredential(def.path) && value
298
+ ? { redacted: true, type: def.type, description: def.description }
299
+ : { value, type: def.type, description: def.description };
287
300
  }
288
301
  await writeStdout(`${JSON.stringify(result, null, 2)}\n`);
289
302
  return;
@@ -308,8 +321,13 @@ async function handleList(flags: { json?: boolean }): Promise<void> {
308
321
  for (const group of sortedGroups) {
309
322
  console.log(chalk.bold.blue(`[${group}]`));
310
323
  for (const def of groups[group]) {
324
+ // `list` dumps every value without anyone asking for a specific
325
+ // credential, so redact here. `get <path>` stays an explicit
326
+ // single-value request and is left alone. An unset or cleared ("")
327
+ // credential keeps its ordinary rendering: masking it would imply one
328
+ // is configured.
311
329
  const value = settings.get(def.path);
312
- const valueStr = formatValue(value);
330
+ const valueStr = isCredential(def.path) && value ? REDACTED : formatValue(value);
313
331
  const typeStr = getTypeDisplay(def);
314
332
  console.log(` ${chalk.white(def.path)} = ${valueStr} ${chalk.dim(typeStr)}`);
315
333
  }
@@ -4,11 +4,13 @@
4
4
  * Handles `omp update` to check for and install updates.
5
5
  * Uses the installer that owns the active omp executable when it can be detected.
6
6
  */
7
+ import { createHash } from "node:crypto";
7
8
  import * as fs from "node:fs";
8
9
  import * as os from "node:os";
9
10
  import * as path from "node:path";
11
+ import { Transform } from "node:stream";
10
12
  import { pipeline } from "node:stream/promises";
11
- import { $which, APP_NAME, isEnoent, VERSION } from "@oh-my-pi/pi-utils";
13
+ import { $env, $which, APP_NAME, isEnoent, VERSION } from "@oh-my-pi/pi-utils";
12
14
  import { $ } from "bun";
13
15
  import chalk from "chalk";
14
16
  import { theme } from "../modes/theme/theme";
@@ -30,6 +32,7 @@ const MISE_TOOL = "github:can1357/oh-my-pi";
30
32
  * See #1686.
31
33
  */
32
34
  const NPM_REGISTRY = "https://registry.npmjs.org/";
35
+ const GITHUB_API = "https://api.github.com";
33
36
  const RELEASE_METADATA_TIMEOUT_MS = 30_000;
34
37
  const BINARY_DOWNLOAD_TIMEOUT_MS = 15 * 60_000;
35
38
 
@@ -65,6 +68,173 @@ interface ReleaseInfo {
65
68
  version: string;
66
69
  }
67
70
 
71
+ export interface ReleaseBinaryAsset {
72
+ url: string;
73
+ size: number;
74
+ digest: string;
75
+ }
76
+
77
+ type Fetch = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
78
+
79
+ function isRecord(value: unknown): value is Record<string, unknown> {
80
+ return typeof value === "object" && value !== null;
81
+ }
82
+
83
+ /**
84
+ * Select and validate the binary asset from GitHub release metadata.
85
+ */
86
+ export function resolveReleaseBinaryAsset(
87
+ release: unknown,
88
+ expectedTag: string,
89
+ binaryName: string,
90
+ ): ReleaseBinaryAsset {
91
+ if (!isRecord(release)) {
92
+ throw new Error("Invalid GitHub release metadata");
93
+ }
94
+ if (release.tag_name !== expectedTag) {
95
+ throw new Error(`GitHub release tag mismatch: expected ${expectedTag}`);
96
+ }
97
+ if (release.draft !== false || release.prerelease !== false) {
98
+ throw new Error(`GitHub release ${expectedTag} is not a published stable release`);
99
+ }
100
+ if (!Array.isArray(release.assets)) {
101
+ throw new Error(`GitHub release ${expectedTag} has no asset list`);
102
+ }
103
+
104
+ const matches = release.assets.filter(asset => isRecord(asset) && asset.name === binaryName);
105
+ if (matches.length !== 1) {
106
+ throw new Error(`GitHub release ${expectedTag} has ${matches.length} assets named ${binaryName}`);
107
+ }
108
+
109
+ const asset = matches[0];
110
+ if (!isRecord(asset) || asset.state !== "uploaded") {
111
+ throw new Error(`GitHub release asset ${binaryName} is not fully uploaded`);
112
+ }
113
+ if (typeof asset.size !== "number" || !Number.isSafeInteger(asset.size) || asset.size <= 0) {
114
+ throw new Error(`GitHub release asset ${binaryName} has an invalid size`);
115
+ }
116
+ if (typeof asset.digest !== "string") {
117
+ throw new Error(`GitHub release asset ${binaryName} has no digest`);
118
+ }
119
+ const digest = /^sha256:([0-9a-f]{64})$/i.exec(asset.digest)?.[1];
120
+ if (!digest) {
121
+ throw new Error(`GitHub release asset ${binaryName} has an unsupported digest`);
122
+ }
123
+
124
+ const expectedUrl = `https://github.com/${REPO}/releases/download/${expectedTag}/${binaryName}`;
125
+ if (asset.browser_download_url !== expectedUrl) {
126
+ throw new Error(`GitHub release asset ${binaryName} has an unexpected download URL`);
127
+ }
128
+
129
+ return {
130
+ url: expectedUrl,
131
+ size: asset.size,
132
+ digest: `sha256:${digest.toLowerCase()}`,
133
+ };
134
+ }
135
+
136
+ async function getReleaseBinaryAsset(
137
+ expectedVersion: string,
138
+ binaryName: string,
139
+ fetchImpl: Fetch = fetch,
140
+ githubToken: string | undefined = $env.GITHUB_TOKEN || $env.GH_TOKEN,
141
+ ): Promise<ReleaseBinaryAsset> {
142
+ const tag = `v${expectedVersion}`;
143
+ const headers: Record<string, string> = {
144
+ Accept: "application/vnd.github+json",
145
+ "X-GitHub-Api-Version": "2022-11-28",
146
+ };
147
+ if (githubToken) headers.Authorization = `Bearer ${githubToken}`;
148
+
149
+ let response: Response;
150
+ try {
151
+ response = await fetchImpl(`${GITHUB_API}/repos/${REPO}/releases/tags/${encodeURIComponent(tag)}`, {
152
+ headers,
153
+ signal: withTimeoutSignal(RELEASE_METADATA_TIMEOUT_MS),
154
+ });
155
+ } catch (err) {
156
+ if (isTimeoutError(err)) {
157
+ throw new Error("Timed out fetching GitHub release metadata after 30s", { cause: err });
158
+ }
159
+ throw err;
160
+ }
161
+ if ((response.status === 403 && !githubToken) || response.status === 429) {
162
+ throw new Error(
163
+ "GitHub API rate limit exceeded while fetching release metadata; retry later or set GITHUB_TOKEN or GH_TOKEN",
164
+ );
165
+ }
166
+ if (!response.ok) {
167
+ throw new Error(`Failed to fetch GitHub release metadata: ${response.statusText}`);
168
+ }
169
+
170
+ return resolveReleaseBinaryAsset(await response.json(), tag, binaryName);
171
+ }
172
+
173
+ export interface VerifiedBinaryDownloadOptions {
174
+ url: string;
175
+ targetPath: string;
176
+ expectedSize: number;
177
+ expectedDigest: string;
178
+ fetchImpl?: Fetch;
179
+ }
180
+
181
+ /**
182
+ * Download a binary and verify its GitHub-reported size and SHA-256 digest.
183
+ */
184
+ export async function downloadVerifiedBinary(options: VerifiedBinaryDownloadOptions): Promise<void> {
185
+ const fetchImpl = options.fetchImpl ?? fetch;
186
+ await unlinkIfExists(options.targetPath);
187
+
188
+ let response: Response;
189
+ try {
190
+ response = await fetchImpl(options.url, {
191
+ redirect: "follow",
192
+ signal: withTimeoutSignal(BINARY_DOWNLOAD_TIMEOUT_MS),
193
+ });
194
+ } catch (err) {
195
+ if (isTimeoutError(err)) {
196
+ throw new Error("Timed out downloading release binary after 15 minutes", { cause: err });
197
+ }
198
+ throw err;
199
+ }
200
+ if (!response.ok || !response.body) {
201
+ throw new Error(`Download failed: ${response.statusText}`);
202
+ }
203
+
204
+ const hash = createHash("sha256");
205
+ let size = 0;
206
+ const verifier = new Transform({
207
+ transform(chunk, _encoding, callback) {
208
+ size += chunk.byteLength;
209
+ if (size > options.expectedSize) {
210
+ callback(
211
+ new Error(
212
+ `Downloaded binary size mismatch: expected ${options.expectedSize} bytes, received at least ${size}`,
213
+ ),
214
+ );
215
+ return;
216
+ }
217
+ hash.update(chunk);
218
+ callback(null, chunk);
219
+ },
220
+ });
221
+
222
+ try {
223
+ await pipeline(response.body, verifier, fs.createWriteStream(options.targetPath, { mode: 0o600 }));
224
+ const digest = `sha256:${hash.digest("hex")}`;
225
+ if (size !== options.expectedSize) {
226
+ throw new Error(`Downloaded binary size mismatch: expected ${options.expectedSize} bytes, received ${size}`);
227
+ }
228
+ if (digest !== options.expectedDigest) {
229
+ throw new Error(`Downloaded binary digest mismatch: expected ${options.expectedDigest}, received ${digest}`);
230
+ }
231
+ await fs.promises.chmod(options.targetPath, 0o755);
232
+ } catch (err) {
233
+ await unlinkIfExists(options.targetPath);
234
+ throw err;
235
+ }
236
+ }
237
+
68
238
  /** Result from running the installed binary and parsing its reported version. */
69
239
  export interface InstalledVersionVerification {
70
240
  ok: boolean;
@@ -209,6 +379,13 @@ interface UpdateMethodResolutionOptions {
209
379
  miseBinDirs?: readonly string[];
210
380
  miseDataDir?: string;
211
381
  npmBinDir?: string;
382
+ /**
383
+ * Whether the resolved omp path is a plain file (the standalone binary)
384
+ * rather than a package-manager symlink. Stops a binary install from being
385
+ * misrouted to npm/bun when the global bin dir overlaps the installer's
386
+ * target directory.
387
+ */
388
+ ompIsRegularFile?: boolean;
212
389
  }
213
390
 
214
391
  type UpdateTarget =
@@ -223,15 +400,26 @@ function resolveUpdateMethod(
223
400
  bunBinDir: string | undefined,
224
401
  options: UpdateMethodResolutionOptions = {},
225
402
  ): UpdateMethod {
226
- const { homebrewPrefix, miseBinDirs = [], miseDataDir, npmBinDir } = options;
403
+ const { homebrewPrefix, miseBinDirs = [], miseDataDir, npmBinDir, ompIsRegularFile = false } = options;
227
404
  const launcherExtension = path.extname(ompPath).toLowerCase();
228
405
  const isWindowsScriptLauncher =
229
406
  launcherExtension === ".cmd" || launcherExtension === ".ps1" || launcherExtension === ".bat";
230
407
  if (homebrewPrefix && isPathInDirectory(ompPath, path.join(homebrewPrefix, "bin"))) return "brew";
231
408
  if (miseBinDirs.some(dir => isPathInDirectory(ompPath, dir))) return "mise";
232
409
  if (miseDataDir && isPathInDirectory(ompPath, path.join(miseDataDir, "shims"))) return "mise";
233
- if (bunBinDir && isPathInDirectory(ompPath, bunBinDir)) return "bun";
234
- if ((npmBinDir && isPathInDirectory(ompPath, npmBinDir)) || isWindowsScriptLauncher) return "npm";
410
+ // A plain executable file in a package-manager bin dir is the standalone
411
+ // binary the installer placed there, not an npm/bun-managed install (those
412
+ // symlink into node_modules on POSIX). When the global bin dir overlaps the
413
+ // installer's default (~/.local/bin), classifying by directory alone routes
414
+ // a binary install through npm/bun, whose reinstall then collides with the
415
+ // existing file (npm EEXIST). Fall through to binary replacement instead.
416
+ // Windows is excluded: there package managers write regular-file shims
417
+ // (bun's .exe launcher, npm's .cmd/.ps1), so a regular file is NOT evidence
418
+ // of a standalone install and the override would hijack managed installs.
419
+ const isStandaloneRegularFile = ompIsRegularFile && process.platform !== "win32";
420
+ if (bunBinDir && isPathInDirectory(ompPath, bunBinDir) && !isStandaloneRegularFile) return "bun";
421
+ if ((npmBinDir && isPathInDirectory(ompPath, npmBinDir) && !isStandaloneRegularFile) || isWindowsScriptLauncher)
422
+ return "npm";
235
423
  return "binary";
236
424
  }
237
425
 
@@ -252,7 +440,22 @@ async function resolveUpdateTarget(): Promise<UpdateTarget> {
252
440
  const ompPath = resolveOmpPath();
253
441
 
254
442
  if (ompPath) {
255
- const method = resolveUpdateMethod(ompPath, bunBinDir, { homebrewPrefix, miseBinDirs, miseDataDir, npmBinDir });
443
+ // Package-manager installs symlink the bin entry into node_modules; the
444
+ // standalone installer writes a plain executable. When the global bin dir
445
+ // overlaps the installer's default (~/.local/bin), that file type — not
446
+ // directory containment — distinguishes a binary install from npm/bun.
447
+ let ompIsRegularFile = false;
448
+ try {
449
+ const stat = fs.lstatSync(ompPath);
450
+ ompIsRegularFile = stat.isFile() && !stat.isSymbolicLink();
451
+ } catch {}
452
+ const method = resolveUpdateMethod(ompPath, bunBinDir, {
453
+ homebrewPrefix,
454
+ miseBinDirs,
455
+ miseDataDir,
456
+ npmBinDir,
457
+ ompIsRegularFile,
458
+ });
256
459
  if (method === "binary") return { method, path: ompPath };
257
460
  return { method };
258
461
  }
@@ -895,36 +1098,33 @@ async function updateViaMise(expectedVersion: string, force: boolean): Promise<v
895
1098
  /**
896
1099
  * Download a release binary to a target path, replacing an existing file.
897
1100
  */
898
- async function updateViaBinaryAt(targetPath: string, expectedVersion: string): Promise<void> {
899
- const binaryName = getBinaryName();
900
- const tag = `v${expectedVersion}`;
901
- const url = `https://github.com/${REPO}/releases/download/${tag}/${binaryName}`;
902
-
1101
+ export async function updateViaBinaryAt(
1102
+ targetPath: string,
1103
+ expectedVersion: string,
1104
+ options: {
1105
+ binaryName?: string;
1106
+ fetchImpl?: Fetch;
1107
+ githubToken?: string;
1108
+ verifyInstalledVersion?: typeof verifyInstalledVersion;
1109
+ } = {},
1110
+ ): Promise<void> {
1111
+ const binaryName = options.binaryName ?? getBinaryName();
903
1112
  const tempPath = `${targetPath}.new`;
904
1113
  // Unique per attempt: a stale backup from an earlier update may still be
905
1114
  // locked (it is the previous process image on Windows), and a fixed name
906
1115
  // would force the move-aside rename to overwrite it. pid + timestamp keeps
907
1116
  // two forced updates in the same millisecond from colliding.
908
1117
  const backupPath = `${targetPath}.${Date.now()}.${process.pid}.bak`;
1118
+ const asset = await getReleaseBinaryAsset(expectedVersion, binaryName, options.fetchImpl, options.githubToken);
909
1119
  console.log(chalk.dim(`Downloading ${binaryName}…`));
910
-
911
- let response: Response;
912
- try {
913
- response = await fetch(url, {
914
- redirect: "follow",
915
- signal: withTimeoutSignal(BINARY_DOWNLOAD_TIMEOUT_MS),
916
- });
917
- } catch (err) {
918
- if (isTimeoutError(err)) {
919
- throw new Error("Timed out downloading release binary after 15 minutes", { cause: err });
920
- }
921
- throw err;
922
- }
923
- if (!response.ok || !response.body) {
924
- throw new Error(`Download failed: ${response.statusText}`);
925
- }
926
- const fileStream = fs.createWriteStream(tempPath, { mode: 0o755 });
927
- await pipeline(response.body, fileStream);
1120
+ await downloadVerifiedBinary({
1121
+ url: asset.url,
1122
+ targetPath: tempPath,
1123
+ expectedSize: asset.size,
1124
+ expectedDigest: asset.digest,
1125
+ fetchImpl: options.fetchImpl,
1126
+ });
1127
+ console.log(chalk.dim(`Verified ${asset.digest}`));
928
1128
 
929
1129
  console.log(chalk.dim("Installing update..."));
930
1130
  await replaceBinaryForUpdate({
@@ -932,7 +1132,7 @@ async function updateViaBinaryAt(targetPath: string, expectedVersion: string): P
932
1132
  tempPath,
933
1133
  backupPath,
934
1134
  expectedVersion,
935
- verifyInstalledVersion,
1135
+ verifyInstalledVersion: options.verifyInstalledVersion ?? verifyInstalledVersion,
936
1136
  });
937
1137
  // Reclaim backups from earlier updates whose owning process has since exited.
938
1138
  await sweepStaleBackups(targetPath);