@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.
- package/CHANGELOG.md +63 -0
- package/dist/cli.js +3806 -3798
- package/dist/types/cli/__tests__/auth-gateway-catalog.test.d.ts +1 -0
- package/dist/types/cli/auth-gateway-cli.d.ts +8 -0
- package/dist/types/cli/update-cli.d.ts +41 -0
- package/dist/types/cli/usage-cli.d.ts +4 -2
- package/dist/types/commands/update.d.ts +1 -0
- package/dist/types/config/model-registry.d.ts +19 -0
- package/dist/types/config/settings-schema.d.ts +30 -7
- package/dist/types/cursor.d.ts +47 -1
- package/dist/types/eval/js/process-entry.d.ts +2 -1
- package/dist/types/eval/js/worker-core.d.ts +4 -1
- package/dist/types/extensibility/extensions/runner.d.ts +1 -0
- package/dist/types/extensibility/legacy-pi-ai-shim.d.ts +4 -0
- package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +4 -0
- package/dist/types/extensibility/legacy-pi-tui-shim.d.ts +15 -7
- package/dist/types/extensibility/shared-events.d.ts +2 -0
- package/dist/types/modes/components/assistant-message.d.ts +9 -0
- package/dist/types/modes/components/custom-editor.d.ts +12 -8
- package/dist/types/plan-mode/approved-plan.d.ts +3 -2
- package/dist/types/secrets/obfuscator.d.ts +7 -31
- package/dist/types/session/agent-session.d.ts +19 -4
- package/dist/types/session/prewalk.d.ts +2 -1
- package/dist/types/session/session-advisors.d.ts +8 -0
- package/dist/types/session/session-metadata.d.ts +29 -0
- package/dist/types/session/turn-recovery.d.ts +5 -5
- package/dist/types/stt/sherpa-runtime.d.ts +38 -0
- package/dist/types/thinking.d.ts +24 -0
- package/dist/types/tools/auto-generated-guard.d.ts +5 -2
- package/dist/types/tools/bash.d.ts +5 -4
- package/dist/types/tools/computer/exposure.d.ts +8 -0
- package/dist/types/tools/computer/supervisor.d.ts +1 -1
- package/dist/types/tools/computer/worker-entry.d.ts +1 -1
- package/dist/types/tools/computer/worker.d.ts +1 -1
- package/dist/types/tools/computer.d.ts +6 -0
- package/dist/types/tools/memory-render.d.ts +1 -4
- package/dist/types/tools/shell-tokenize.d.ts +14 -0
- package/dist/types/tools/todo.d.ts +7 -1
- package/package.json +12 -12
- package/src/advisor/__tests__/advisor.test.ts +80 -10
- package/src/advisor/runtime.ts +27 -1
- package/src/cli/__tests__/auth-gateway-catalog.test.ts +111 -0
- package/src/cli/auth-gateway-cli.ts +63 -16
- package/src/cli/config-cli.ts +25 -7
- package/src/cli/update-cli.ts +229 -29
- package/src/cli/usage-cli.ts +144 -15
- package/src/cli.ts +21 -15
- package/src/commands/update.ts +6 -0
- package/src/config/__tests__/model-registry.test.ts +42 -7
- package/src/config/model-registry.ts +67 -4
- package/src/config/settings-schema.ts +39 -8
- package/src/config/settings.ts +24 -2
- package/src/cursor.ts +153 -0
- package/src/dap/session.ts +70 -11
- package/src/edit/hashline/filesystem.ts +1 -1
- package/src/edit/modes/patch.ts +1 -1
- package/src/eval/__tests__/js-context-manager.test.ts +9 -1
- package/src/eval/__tests__/process-entry-import.test.ts +111 -1
- package/src/eval/js/process-entry.ts +9 -5
- package/src/eval/js/worker-core.ts +6 -2
- package/src/exec/bash-executor.ts +4 -2
- package/src/extensibility/extensions/runner.ts +23 -8
- package/src/extensibility/legacy-pi-ai-shim.ts +9 -0
- package/src/extensibility/legacy-pi-coding-agent-shim.ts +14 -2
- package/src/extensibility/legacy-pi-tui-shim.ts +33 -0
- package/src/extensibility/shared-events.ts +2 -0
- package/src/main.ts +22 -1
- package/src/modes/acp/acp-agent.ts +6 -0
- package/src/modes/components/assistant-message.ts +88 -11
- package/src/modes/components/chat-transcript-builder.ts +2 -0
- package/src/modes/components/custom-editor.test.ts +170 -0
- package/src/modes/components/custom-editor.ts +79 -29
- package/src/modes/components/settings-defs.ts +5 -1
- package/src/modes/controllers/event-controller.ts +96 -4
- package/src/modes/controllers/selector-controller.ts +14 -0
- package/src/modes/interactive-mode.ts +34 -8
- package/src/modes/utils/context-usage.ts +19 -2
- package/src/modes/utils/interactive-context-helpers.ts +1 -0
- package/src/plan-mode/approved-plan.ts +18 -10
- package/src/prompts/system/custom-system-prompt.md +1 -1
- package/src/prompts/system/system-prompt.md +10 -1
- package/src/prompts/tools/ast-edit.md +1 -1
- package/src/sdk.ts +4 -0
- package/src/secrets/obfuscator.ts +36 -126
- package/src/session/agent-session.ts +69 -60
- package/src/session/prewalk.ts +8 -3
- package/src/session/session-advisors.ts +67 -3
- package/src/session/session-metadata.ts +53 -0
- package/src/session/session-provider-boundary.ts +0 -1
- package/src/session/session-tools.ts +35 -1
- package/src/session/stream-guards.ts +1 -1
- package/src/session/turn-recovery.ts +36 -19
- package/src/slash-commands/builtin-registry.ts +49 -7
- package/src/stt/asr-worker.ts +2 -37
- package/src/stt/sherpa-runtime.ts +71 -0
- package/src/task/executor.ts +6 -5
- package/src/thinking.ts +39 -0
- package/src/tools/auto-generated-guard.ts +18 -5
- package/src/tools/bash.ts +43 -15
- package/src/tools/computer/exposure.ts +38 -0
- package/src/tools/computer/supervisor.ts +61 -13
- package/src/tools/computer/worker-entry.ts +28 -19
- package/src/tools/computer/worker.ts +3 -7
- package/src/tools/computer.ts +65 -10
- package/src/tools/gh-cache-invalidation.ts +2 -82
- package/src/tools/memory-render.ts +11 -2
- package/src/tools/render-utils.ts +8 -3
- package/src/tools/shell-tokenize.ts +83 -0
- package/src/tools/todo.ts +44 -17
- 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
|
|
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
|
|
173
|
-
// to providers we hold credentials for.
|
|
174
|
-
//
|
|
175
|
-
//
|
|
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
|
|
180
|
-
|
|
181
|
-
|
|
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();
|
package/src/cli/config-cli.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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
|
}
|
package/src/cli/update-cli.ts
CHANGED
|
@@ -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
|
-
|
|
234
|
-
|
|
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
|
-
|
|
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(
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
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
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
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);
|