@bitkyc08/opencodex 2.60.0 → 2.61.0-preview.20260922
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/AGENTS_INSTALL.md +64 -0
- package/README.md +28 -1
- package/bin/ocx.mjs +382 -209
- package/gui/dist/assets/App-E64Rzjap.js +50 -0
- package/gui/dist/assets/Tray-_nfzD8k4.js +1 -0
- package/gui/dist/assets/index-DpdfZWMK.js +86 -0
- package/gui/dist/assets/index-_bpvxJu0.css +1 -0
- package/gui/dist/assets/usage-companion-chart-DtoK7T6h.js +1 -0
- package/gui/dist/favicon.png +0 -0
- package/gui/dist/index.html +2 -2
- package/gui/dist/provider-icons/stepfun-color.svg +1 -0
- package/package.json +5 -1
- package/src/adapters/anthropic.ts +16 -0
- package/src/adapters/coding-agent/protocol.ts +36 -6
- package/src/adapters/coding-agent/turn.ts +10 -2
- package/src/adapters/command-code.ts +2 -1
- package/src/adapters/cursor/catalog.ts +51 -7
- package/src/adapters/cursor/protobuf-request.ts +6 -3
- package/src/adapters/cursor/request-builder.ts +13 -3
- package/src/adapters/cursor.ts +11 -2
- package/src/adapters/declaration-carrier.ts +45 -0
- package/src/adapters/devin.ts +75 -23
- package/src/adapters/google-antigravity-wire.ts +5 -2
- package/src/adapters/google-errors.ts +7 -1
- package/src/adapters/google.ts +29 -5
- package/src/adapters/image.ts +4 -1
- package/src/adapters/input-media-guard.ts +21 -9
- package/src/adapters/kiro/usage.ts +3 -2
- package/src/adapters/kiro-tool-fallback.ts +1 -1
- package/src/adapters/ollama-native.ts +6 -0
- package/src/adapters/openai-chat/developer-role.ts +61 -0
- package/src/adapters/openai-chat/messages.ts +46 -27
- package/src/adapters/openai-chat/parallel-tool-calls.ts +32 -0
- package/src/adapters/openai-chat/passthrough.ts +33 -9
- package/src/adapters/openai-chat/reasoning-wire.ts +89 -0
- package/src/adapters/openai-chat.ts +18 -57
- package/src/adapters/openai-responses/passthrough.ts +2 -0
- package/src/adapters/registry.ts +3 -2
- package/src/adapters/run-turn-queue.ts +178 -29
- package/src/adapters/xai-web-search.ts +16 -1
- package/src/bridge/errors.ts +8 -2
- package/src/bridge/response-json.ts +9 -1
- package/src/bridge/sse.ts +10 -0
- package/src/chat/inbound.ts +141 -5
- package/src/claude/desktop-3p.ts +7 -1
- package/src/claude/desktop-first-party.ts +183 -0
- package/src/claude/desktop-gateway-state.ts +41 -0
- package/src/claude/inbound-content-options.ts +6 -0
- package/src/claude/inbound.ts +32 -6
- package/src/claude/intercept/connect-proxy.ts +179 -0
- package/src/claude/intercept/listener.ts +122 -0
- package/src/claude/intercept/local-ca.ts +298 -0
- package/src/claude/intercept/runtime.ts +98 -0
- package/src/claude/intercept/settings.ts +189 -0
- package/src/cli/access.ts +87 -0
- package/src/cli/account-auth.ts +19 -0
- package/src/cli/capabilities.ts +31 -0
- package/src/cli/claude-desktop.ts +206 -16
- package/src/cli/codex-shim-autorestore.ts +3 -0
- package/src/cli/companion.ts +56 -0
- package/src/cli/dispatch.ts +43 -4
- package/src/cli/ensure-desired-integrations.ts +43 -5
- package/src/cli/help.ts +7 -9
- package/src/cli/index.ts +200 -61
- package/src/cli/init.ts +8 -0
- package/src/cli/integrations.ts +7 -1
- package/src/cli/registry.ts +41 -2
- package/src/cli/resolve.ts +230 -0
- package/src/cli/root.ts +24 -1
- package/src/cli/start-ownership-publication.ts +56 -0
- package/src/cli/status-probes.ts +2 -18
- package/src/cli/status.ts +62 -0
- package/src/cli/stop-report.ts +143 -0
- package/src/cli/uninstall-plan.ts +9 -0
- package/src/client/machine-listener.ts +2 -5
- package/src/clients/aside-profiles.ts +4 -0
- package/src/clients/config-export/zcode-store.ts +157 -0
- package/src/clients/config-export.ts +36 -0
- package/src/codex/app-server-processes.ts +72 -40
- package/src/codex/auth-api/login-flow.ts +6 -1
- package/src/codex/autostart-health.ts +28 -0
- package/src/codex/catalog/build-entries.ts +2 -2
- package/src/codex/catalog/effort.ts +3 -3
- package/src/codex/catalog/provider-models.ts +24 -15
- package/src/codex/catalog/retained-sync.ts +2 -2
- package/src/codex/convergence.ts +2 -2
- package/src/codex/history-provider.ts +12 -1
- package/src/codex/inject/config-toml.ts +41 -6
- package/src/codex/inject/paginated-openai-compat.ts +90 -0
- package/src/codex/inject.ts +18 -15
- package/src/codex/injected-marker.ts +18 -0
- package/src/codex/main-account.ts +6 -0
- package/src/codex/model-cache.ts +52 -6
- package/src/codex/model-entitlement-admission.ts +59 -0
- package/src/codex/model-entitlements.ts +87 -44
- package/src/codex/native-main-admission.ts +83 -0
- package/src/codex/routing/health-store.ts +39 -0
- package/src/codex/routing/selection.ts +37 -1
- package/src/codex/routing.ts +5 -41
- package/src/codex/shim-templates.ts +29 -3
- package/src/companion/settings.ts +132 -0
- package/src/config/atomic-write.ts +117 -5
- package/src/config/load-degrade.ts +34 -7
- package/src/config/process-state.ts +1 -1
- package/src/config/schema/config-schema.ts +27 -1
- package/src/config/schema/leaf-validators.ts +47 -0
- package/src/config.ts +1 -1
- package/src/generated/compatibility-version.json +418 -174
- package/src/integrations/config-io.ts +44 -10
- package/src/integrations/merge.ts +120 -13
- package/src/integrations/mutation-plan.ts +124 -18
- package/src/integrations/registry.ts +38 -0
- package/src/integrations/state.ts +78 -45
- package/src/integrations/target.ts +208 -0
- package/src/integrations/writer.ts +49 -11
- package/src/lab/conformance/fixture-provider.ts +5 -0
- package/src/lib/browser-launch-notice.ts +59 -0
- package/src/lib/bun-runtime.ts +6 -2
- package/src/lib/debug.ts +40 -0
- package/src/lib/open-url.ts +51 -7
- package/src/lib/package-tree-integrity.ts +2 -1
- package/src/lib/package-version.ts +8 -0
- package/src/lib/provider-egress.ts +310 -0
- package/src/lib/provider-outbound.ts +59 -14
- package/src/lib/proxy-env.ts +82 -7
- package/src/lib/request-execution-budget.ts +72 -0
- package/src/lib/request-failure-attribution.ts +183 -0
- package/src/lib/request-failure-model.ts +236 -0
- package/src/lib/request-resend-gate.ts +138 -0
- package/src/lib/standalone.ts +16 -0
- package/src/lib/upstream-retry.ts +167 -16
- package/src/lib/winsw.ts +2 -2
- package/src/oauth/index.ts +24 -1
- package/src/oauth/login-cli.ts +80 -29
- package/src/providers/api-key-resolve.ts +133 -0
- package/src/providers/api-key-selection.ts +5 -1
- package/src/providers/key-failover.ts +31 -1
- package/src/providers/key-store.ts +34 -110
- package/src/providers/model-rename-fields.ts +147 -0
- package/src/providers/model-rename-migration.ts +124 -37
- package/src/providers/quota/vendor-probes-key.ts +37 -22
- package/src/providers/reasoning-metadata.ts +43 -18
- package/src/providers/registry/entries-core.ts +9 -4
- package/src/providers/registry/entries-extended.ts +29 -4
- package/src/providers/registry/model-seeds.ts +47 -10
- package/src/providers/xai-transport.ts +12 -1
- package/src/reasoning-effort.ts +8 -0
- package/src/responses/function-call-compat.ts +38 -1
- package/src/responses/inline-document.ts +65 -0
- package/src/responses/input-media.ts +42 -8
- package/src/responses/muse-tool-name-alias.ts +19 -0
- package/src/responses/parser-content.ts +8 -2
- package/src/responses/parser-tools.ts +3 -0
- package/src/responses/parser.ts +3 -1
- package/src/responses/schema.ts +3 -0
- package/src/router.ts +17 -2
- package/src/server/admission-model-scope.ts +219 -0
- package/src/server/audio-live.ts +9 -3
- package/src/server/audio-upstream.ts +18 -0
- package/src/server/auth-cors.ts +26 -0
- package/src/server/chat-completions.ts +55 -2
- package/src/server/chat-native.ts +19 -4
- package/src/server/claude-messages.ts +55 -17
- package/src/server/grok-responses-snapshot-repair.ts +113 -11
- package/src/server/gui-freshness.ts +103 -0
- package/src/server/gui-static.ts +7 -9
- package/src/server/images.ts +59 -6
- package/src/server/index/claude-intercept-lifecycle.ts +49 -0
- package/src/server/index/serve-options.ts +56 -10
- package/src/server/index/spend-ledger-lifecycle.ts +34 -8
- package/src/server/index/startup-warnings.ts +24 -0
- package/src/server/index.ts +21 -28
- package/src/server/lifecycle.ts +4 -4
- package/src/server/live-call-bindings.ts +6 -0
- package/src/server/live.ts +88 -3
- package/src/server/management/agent-settings-routes.ts +121 -36
- package/src/server/management/companion-routes.ts +77 -0
- package/src/server/management/logs-usage-routes.ts +19 -0
- package/src/server/management/native-integration-routes.ts +103 -6
- package/src/server/management/oauth-account-routes.ts +45 -7
- package/src/server/management/route-registry.ts +6 -0
- package/src/server/management/shared.ts +18 -1
- package/src/server/management/usage-timeline-routes.ts +44 -0
- package/src/server/management-api.ts +8 -9
- package/src/server/proxy-liveness.ts +75 -0
- package/src/server/relay.ts +19 -2
- package/src/server/request-log-failure-attribution.ts +99 -0
- package/src/server/request-log.ts +114 -0
- package/src/server/request-metrics.ts +92 -30
- package/src/server/responses/codex-ws-wire.ts +34 -8
- package/src/server/responses/combo-stream-preflight.ts +168 -6
- package/src/server/responses/compact.ts +11 -0
- package/src/server/responses/core-opaque-recovery.ts +90 -0
- package/src/server/responses/fetch-helpers.ts +124 -8
- package/src/server/responses/input-admission.ts +10 -0
- package/src/server/responses/passthrough-delivery.ts +14 -1
- package/src/server/responses/passthrough-dispatch.ts +179 -35
- package/src/server/responses/passthrough-error.ts +27 -8
- package/src/server/responses/request-prepare.ts +42 -1
- package/src/server/responses/request-send-budget.ts +12 -0
- package/src/server/responses/request-transport.ts +24 -4
- package/src/server/responses/reset-replay.ts +108 -0
- package/src/server/responses-request-tool-scope.ts +214 -0
- package/src/server/responses-undeclared-tool-guard.ts +4 -1
- package/src/server/search.ts +25 -1
- package/src/server/usage-ledger-retention.ts +73 -0
- package/src/service/cli.ts +48 -2
- package/src/service/health.ts +3 -2
- package/src/service/install-state-contract.d.mts +27 -0
- package/src/service/install-state-contract.mjs +34 -0
- package/src/service/launchd.ts +1 -1
- package/src/service/orchestration.ts +2 -4
- package/src/service/ownership-compatibility.ts +164 -0
- package/src/service/ownership-mutation-lease.d.mts +32 -0
- package/src/service/ownership-mutation-lease.mjs +211 -0
- package/src/service/repair.ts +45 -1
- package/src/service/state-lock.ts +269 -0
- package/src/service/state-record.d.mts +36 -0
- package/src/service/state-record.mjs +138 -0
- package/src/service/state.ts +582 -68
- package/src/service/windows-taskxml.ts +11 -10
- package/src/service.ts +7 -3
- package/src/tray/windows-tray.ps1 +1 -1
- package/src/types/config.ts +37 -0
- package/src/types/provider.ts +73 -0
- package/src/types/request.ts +28 -2
- package/src/types/tools.ts +19 -0
- package/src/types.ts +3 -0
- package/src/update/index.ts +207 -63
- package/src/update/job.ts +9 -5
- package/src/update/ownership-transaction.ts +47 -0
- package/src/update/restart-ownership.ts +54 -0
- package/src/update/runtime-ownership.d.mts +40 -0
- package/src/update/runtime-ownership.mjs +122 -0
- package/src/usage/attempt-delivery.ts +198 -0
- package/src/usage/cache-diagnostic.ts +305 -0
- package/src/usage/failure-fingerprint.ts +118 -0
- package/src/usage/failure-projection-cache.ts +174 -0
- package/src/usage/failure-projection.ts +174 -0
- package/src/usage/ledger-retention.ts +165 -0
- package/src/usage/log.ts +126 -79
- package/src/usage/request-outcome.ts +150 -0
- package/src/usage/retention-contract.ts +28 -0
- package/src/usage/summary.ts +2 -2
- package/src/usage/telemetry-contract.ts +237 -0
- package/src/usage/timeline.ts +236 -0
- package/src/web-search/alpha-search.ts +21 -1
- package/gui/dist/assets/index-BTuCbqQd.css +0 -1
- package/gui/dist/assets/index-DoBVdPHP.js +0 -134
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
import { lstatSync, mkdirSync, readFileSync, rmSync, statSync } from "node:fs";
|
|
11
11
|
import type { ConfigFormat } from "../clients/config-export";
|
|
12
12
|
import { MAX_JSON_NESTING } from "./serialize";
|
|
13
|
-
import {
|
|
13
|
+
import { atomicWriteFileNoFollow, isMissingPathError } from "../config/atomic-write";
|
|
14
14
|
import type { JournalEntry } from "./journal";
|
|
15
15
|
import type { OwnershipRecord } from "./ownership";
|
|
16
16
|
import type { IntegrationClientId } from "./registry";
|
|
@@ -192,6 +192,15 @@ export type ReadResult =
|
|
|
192
192
|
|
|
193
193
|
export type StatKind = "file" | "dir" | "other" | "missing" | "failed";
|
|
194
194
|
|
|
195
|
+
function lstatKind(path: string): StatKind {
|
|
196
|
+
try {
|
|
197
|
+
const stats = lstatSync(path);
|
|
198
|
+
return stats.isFile() ? "file" : stats.isDirectory() ? "dir" : "other";
|
|
199
|
+
} catch (error) {
|
|
200
|
+
return (error as NodeJS.ErrnoException).code === "ENOENT" ? "missing" : "failed";
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
195
204
|
export interface IntegrationIO {
|
|
196
205
|
/**
|
|
197
206
|
* ONLY a missing file yields `missing`. Every other failure (EACCES, EPERM,
|
|
@@ -234,6 +243,18 @@ export type TargetState =
|
|
|
234
243
|
* an unreadable config gets clobbered.
|
|
235
244
|
*/
|
|
236
245
|
export function loadTarget(io: IntegrationIO, configPath: string): TargetState {
|
|
246
|
+
// Managed client paths are a lower-trust boundary. Never inspect through a
|
|
247
|
+
// final symlink that can be retargeted between this read and the eventual
|
|
248
|
+
// write: when a no-follow probe exists, the named directory entry itself must
|
|
249
|
+
// be a regular file or absent. `missing` stays legal so a virtual pair probe
|
|
250
|
+
// (such as Cline's pair-aware statKind) can still report one absent member.
|
|
251
|
+
const named = io.lstatKind?.(configPath);
|
|
252
|
+
// A failed probe is uncertainty, not evidence about the entry's shape: it
|
|
253
|
+
// keeps the read-failed classification the follow-probe would have produced.
|
|
254
|
+
if (named === "failed") return { ok: false, why: "read-failed" };
|
|
255
|
+
if (named !== undefined && named !== "file" && named !== "missing") {
|
|
256
|
+
return { ok: false, why: "not-regular-file" };
|
|
257
|
+
}
|
|
237
258
|
const kind = io.statKind(configPath);
|
|
238
259
|
if (kind === "missing") return { ok: true, before: null };
|
|
239
260
|
if (kind === "failed") return { ok: false, why: "read-failed" };
|
|
@@ -252,14 +273,7 @@ export function loadTarget(io: IntegrationIO, configPath: string): TargetState {
|
|
|
252
273
|
*/
|
|
253
274
|
export function fileIO(): Omit<IntegrationIO, "appendJournal" | "putRecord" | "dropRecord"> {
|
|
254
275
|
return {
|
|
255
|
-
lstatKind
|
|
256
|
-
try {
|
|
257
|
-
const stats = lstatSync(path);
|
|
258
|
-
return stats.isFile() ? "file" : stats.isDirectory() ? "dir" : "other";
|
|
259
|
-
} catch (error) {
|
|
260
|
-
return (error as NodeJS.ErrnoException).code === "ENOENT" ? "missing" : "failed";
|
|
261
|
-
}
|
|
262
|
-
},
|
|
276
|
+
lstatKind,
|
|
263
277
|
readText: path => {
|
|
264
278
|
try {
|
|
265
279
|
return { kind: "text", text: readFileSync(path, "utf8") };
|
|
@@ -277,8 +291,28 @@ export function fileIO(): Omit<IntegrationIO, "appendJournal" | "putRecord" | "d
|
|
|
277
291
|
}
|
|
278
292
|
},
|
|
279
293
|
writeText: (path, text) => {
|
|
294
|
+
const kind = lstatKind(path);
|
|
295
|
+
if (kind !== "file" && kind !== "missing") {
|
|
296
|
+
throw new Error(`refusing unsafe integration write target: ${path}`);
|
|
297
|
+
}
|
|
280
298
|
assertIntegrationWriteOwnership(path);
|
|
281
|
-
|
|
299
|
+
atomicWriteFileNoFollow(path, text, undefined, {
|
|
300
|
+
// The pre-check above rejects a symlink already in place; this one runs
|
|
301
|
+
// inside the atomic write immediately before the rename, so a link
|
|
302
|
+
// exchanged after validation is refused rather than followed. Even a
|
|
303
|
+
// swap past this point can only replace the named entry, never redirect
|
|
304
|
+
// the write through it.
|
|
305
|
+
validateBeforeRename: target => {
|
|
306
|
+
try {
|
|
307
|
+
if (lstatSync(target).isSymbolicLink()) {
|
|
308
|
+
throw new Error(`refusing to replace symbolic-link integration target: ${target}`);
|
|
309
|
+
}
|
|
310
|
+
} catch (error) {
|
|
311
|
+
if (isMissingPathError(error)) return;
|
|
312
|
+
throw error;
|
|
313
|
+
}
|
|
314
|
+
},
|
|
315
|
+
});
|
|
282
316
|
},
|
|
283
317
|
removeFile: path => rmSync(path, { force: true }),
|
|
284
318
|
mkdirp: path => mkdirSync(path, { recursive: true, mode: 0o700 }),
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
*
|
|
10
10
|
* Design of record: devlog/_fin/260802_client_toggle_api/031_wp3_writer_impl.md.
|
|
11
11
|
*/
|
|
12
|
-
import type { ManagedContribution } from "../clients/config-export";
|
|
12
|
+
import type { ManagedContribution } from "../clients/config-export/contracts";
|
|
13
13
|
|
|
14
14
|
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
|
15
15
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
@@ -22,20 +22,87 @@ function clone<T>(value: T): T {
|
|
|
22
22
|
|
|
23
23
|
/**
|
|
24
24
|
* `[field=value]` addresses the ONE element of a sequence whose `field` equals
|
|
25
|
-
* `value
|
|
25
|
+
* `value`, and `[v2:field=value,field=value]` the one whose every named field
|
|
26
|
+
* matches. Raycast keeps its providers as a YAML list, so the element is the
|
|
26
27
|
* smallest thing we can own there; an index would move under us the moment
|
|
27
28
|
* the user reordered their own entries. Any other segment is a plain key.
|
|
29
|
+
*
|
|
30
|
+
* A conjunction exists because one field is not always the identity. ZCode
|
|
31
|
+
* keys a model rule in its provider store by the PAIR `(providerId, modelId)`,
|
|
32
|
+
* so a `[modelId=…]` selector alone would match another provider's rule for the
|
|
33
|
+
* same model and then replace it, in a file holding every provider the user
|
|
34
|
+
* has. Addressing an element by less than what identifies it is the same
|
|
35
|
+
* defect as addressing it by index.
|
|
36
|
+
*
|
|
37
|
+
* The single-criterion spelling keeps its original grammar, where the value may
|
|
38
|
+
* itself contain commas and equals signs. A conjunction uses an explicit v2
|
|
39
|
+
* marker, so no path already written into an ownership record on disk changes
|
|
40
|
+
* meaning. Once the marker is present the whole segment must be valid; falling
|
|
41
|
+
* back to a key or v1 selector would recreate the silent reselection hazard.
|
|
28
42
|
*/
|
|
29
43
|
const ARRAY_SELECTOR = /^\[([A-Za-z_][A-Za-z0-9_]*)=([^\]]+)\]$/u;
|
|
44
|
+
const SELECTOR_FIELD = /^[A-Za-z_][A-Za-z0-9_]*$/u;
|
|
45
|
+
const SELECTOR_CONJUNCTION_PREFIX = "[v2:";
|
|
46
|
+
|
|
47
|
+
/** One `field=value` equality a selector requires of the element it names. */
|
|
48
|
+
export interface SelectorCriterion {
|
|
49
|
+
field: string;
|
|
50
|
+
value: string;
|
|
51
|
+
}
|
|
30
52
|
|
|
31
53
|
export type PathSegment =
|
|
32
54
|
| { kind: "key"; key: string }
|
|
33
|
-
| { kind: "select";
|
|
55
|
+
| { kind: "select"; criteria: readonly SelectorCriterion[] };
|
|
56
|
+
|
|
57
|
+
/** A reserved v2 selector marker was present, but its complete grammar was not. */
|
|
58
|
+
export class InvalidSelectorError extends Error {
|
|
59
|
+
constructor() {
|
|
60
|
+
super("invalid versioned selector");
|
|
61
|
+
this.name = "InvalidSelectorError";
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function validConjunctionCriteria(criteria: readonly SelectorCriterion[]): boolean {
|
|
66
|
+
return criteria.length >= 2 && criteria.every(criterion => (
|
|
67
|
+
SELECTOR_FIELD.test(criterion.field)
|
|
68
|
+
&& criterion.value.length > 0
|
|
69
|
+
&& !criterion.value.includes(",")
|
|
70
|
+
&& !criterion.value.includes("]")
|
|
71
|
+
));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Spell a multi-field selector without colliding with persisted v1 paths. */
|
|
75
|
+
export function formatSelectorConjunction(criteria: readonly SelectorCriterion[]): string | null {
|
|
76
|
+
if (!validConjunctionCriteria(criteria)) return null;
|
|
77
|
+
return `${SELECTOR_CONJUNCTION_PREFIX}${criteria
|
|
78
|
+
.map(criterion => `${criterion.field}=${criterion.value}`)
|
|
79
|
+
.join(",")}]`;
|
|
80
|
+
}
|
|
34
81
|
|
|
35
82
|
export function parseSegment(raw: string): PathSegment {
|
|
83
|
+
if (raw.startsWith(SELECTOR_CONJUNCTION_PREFIX)) {
|
|
84
|
+
if (!raw.endsWith("]")) throw new InvalidSelectorError();
|
|
85
|
+
const parts = raw.slice(SELECTOR_CONJUNCTION_PREFIX.length, -1).split(",");
|
|
86
|
+
const criteria = parts.map(part => {
|
|
87
|
+
const equals = part.indexOf("=");
|
|
88
|
+
return equals < 0
|
|
89
|
+
? { field: "", value: "" }
|
|
90
|
+
: { field: part.slice(0, equals), value: part.slice(equals + 1) };
|
|
91
|
+
});
|
|
92
|
+
if (!validConjunctionCriteria(criteria)) throw new InvalidSelectorError();
|
|
93
|
+
return {
|
|
94
|
+
kind: "select",
|
|
95
|
+
criteria,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
36
98
|
const match = ARRAY_SELECTOR.exec(raw);
|
|
37
99
|
if (!match) return { kind: "key", key: raw };
|
|
38
|
-
return { kind: "select", field: match[1]!, value: match[2]! };
|
|
100
|
+
return { kind: "select", criteria: [{ field: match[1]!, value: match[2]! }] };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The `field=value` list a selector segment spells, for messages and seeding. */
|
|
104
|
+
export function selectorPairs(criteria: readonly SelectorCriterion[]): string {
|
|
105
|
+
return criteria.map(criterion => `${criterion.field}=${criterion.value}`).join(", ");
|
|
39
106
|
}
|
|
40
107
|
|
|
41
108
|
/**
|
|
@@ -44,19 +111,21 @@ export function parseSegment(raw: string): PathSegment {
|
|
|
44
111
|
* this to an `unsafe` refusal instead.
|
|
45
112
|
*/
|
|
46
113
|
export class AmbiguousSelectorError extends Error {
|
|
47
|
-
constructor(
|
|
48
|
-
super(`more than one entry has ${
|
|
114
|
+
constructor(criteria: readonly SelectorCriterion[]) {
|
|
115
|
+
super(`more than one entry has ${selectorPairs(criteria)}`);
|
|
49
116
|
this.name = "AmbiguousSelectorError";
|
|
50
117
|
}
|
|
51
118
|
}
|
|
52
119
|
|
|
53
120
|
/** The index of the element a selector names, -1 when none matches. */
|
|
54
|
-
export function selectIndex(items: readonly unknown[],
|
|
121
|
+
export function selectIndex(items: readonly unknown[], criteria: readonly SelectorCriterion[]): number {
|
|
55
122
|
const matches: number[] = [];
|
|
56
123
|
items.forEach((item, index) => {
|
|
57
|
-
if (isPlainRecord(item) && item[field] === value)
|
|
124
|
+
if (isPlainRecord(item) && criteria.every(criterion => item[criterion.field] === criterion.value)) {
|
|
125
|
+
matches.push(index);
|
|
126
|
+
}
|
|
58
127
|
});
|
|
59
|
-
if (matches.length > 1) throw new AmbiguousSelectorError(
|
|
128
|
+
if (matches.length > 1) throw new AmbiguousSelectorError(criteria);
|
|
60
129
|
return matches[0] ?? -1;
|
|
61
130
|
}
|
|
62
131
|
|
|
@@ -64,6 +133,44 @@ function assertNever(segment: never): never {
|
|
|
64
133
|
throw new Error(`unknown path segment ${JSON.stringify(segment)}`);
|
|
65
134
|
}
|
|
66
135
|
|
|
136
|
+
/** The element a selector names, or `undefined` when none matches. */
|
|
137
|
+
function selectElement(items: readonly unknown[], segment: PathSegment & { kind: "select" }): unknown {
|
|
138
|
+
return items[selectIndex(items, segment.criteria)];
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Read `path` through the same segment grammar `setPath` writes through.
|
|
143
|
+
*
|
|
144
|
+
* It belongs here rather than beside the classifier because the grammar does: a
|
|
145
|
+
* reader that resolved a selector differently from the writer would report one
|
|
146
|
+
* element as ours and then rewrite another. `state` re-exports it for the
|
|
147
|
+
* callers that have always imported it from there.
|
|
148
|
+
*/
|
|
149
|
+
export function readPath(doc: unknown, path: readonly string[]): unknown {
|
|
150
|
+
let cursor: unknown = doc;
|
|
151
|
+
// Validate the complete persisted grammar before document shape can short-circuit
|
|
152
|
+
// the walk. Otherwise an absent early key can hide a malformed later selector,
|
|
153
|
+
// making an unreadable ownership record look absent and move the operation to a
|
|
154
|
+
// different file.
|
|
155
|
+
const segments = path.map(parseSegment);
|
|
156
|
+
for (const segment of segments) {
|
|
157
|
+
switch (segment.kind) {
|
|
158
|
+
case "key":
|
|
159
|
+
if (!isPlainRecord(cursor)) return undefined;
|
|
160
|
+
cursor = cursor[segment.key];
|
|
161
|
+
break;
|
|
162
|
+
case "select":
|
|
163
|
+
if (!Array.isArray(cursor)) return undefined;
|
|
164
|
+
cursor = selectElement(cursor, segment);
|
|
165
|
+
break;
|
|
166
|
+
default:
|
|
167
|
+
return assertNever(segment);
|
|
168
|
+
}
|
|
169
|
+
if (cursor === undefined) return undefined;
|
|
170
|
+
}
|
|
171
|
+
return cursor;
|
|
172
|
+
}
|
|
173
|
+
|
|
67
174
|
/**
|
|
68
175
|
* Write `value` at `path`, creating intermediate containers. Returns a new document.
|
|
69
176
|
*
|
|
@@ -99,7 +206,7 @@ export function setPath(doc: unknown, path: readonly string[], value: unknown):
|
|
|
99
206
|
case "select": {
|
|
100
207
|
if (!Array.isArray(read())) write([]);
|
|
101
208
|
const items = read() as unknown[];
|
|
102
|
-
const found = selectIndex(items, segment.
|
|
209
|
+
const found = selectIndex(items, segment.criteria);
|
|
103
210
|
parent = items;
|
|
104
211
|
if (found >= 0) {
|
|
105
212
|
slot = found;
|
|
@@ -107,7 +214,7 @@ export function setPath(doc: unknown, path: readonly string[], value: unknown):
|
|
|
107
214
|
// Seed the element so the selector stays true for whatever a deeper
|
|
108
215
|
// segment writes into it; a last-position select replaces it whole.
|
|
109
216
|
slot = items.length;
|
|
110
|
-
items.push(
|
|
217
|
+
items.push(Object.fromEntries(segment.criteria.map(criterion => [criterion.field, criterion.value])));
|
|
111
218
|
}
|
|
112
219
|
break;
|
|
113
220
|
}
|
|
@@ -155,7 +262,7 @@ export function deletePath(
|
|
|
155
262
|
}
|
|
156
263
|
case "select": {
|
|
157
264
|
if (!Array.isArray(container)) return { doc: root, removed: false };
|
|
158
|
-
const found = selectIndex(container, segment.
|
|
265
|
+
const found = selectIndex(container, segment.criteria);
|
|
159
266
|
if (found < 0) return { doc: root, removed: false };
|
|
160
267
|
slots.push(found);
|
|
161
268
|
chain.push(container[found] as Record<string, unknown> | unknown[]);
|
|
@@ -249,7 +356,7 @@ export function createdContainerPaths(
|
|
|
249
356
|
case "select": {
|
|
250
357
|
// A selector that matches nothing means setPath will push the element.
|
|
251
358
|
next = Array.isArray(cursor)
|
|
252
|
-
? cursor[selectIndex(cursor, segment.
|
|
359
|
+
? cursor[selectIndex(cursor, segment.criteria)]
|
|
253
360
|
: undefined;
|
|
254
361
|
break;
|
|
255
362
|
}
|
|
@@ -17,12 +17,23 @@ import { createHash } from "node:crypto";
|
|
|
17
17
|
import { canonicalContribution, fingerprint, type OwnershipRecord } from "./ownership";
|
|
18
18
|
import { ClientPathError, EXPORT_CLIENTS, type ExportModel, type ManagedContribution } from "../clients/config-export";
|
|
19
19
|
import { OPENCODE_PROVIDER_ID } from "../clients/config-export/constants";
|
|
20
|
+
import {
|
|
21
|
+
ZCODE_STORE_MODEL_RULES_PATH,
|
|
22
|
+
ZCODE_STORE_PROVIDER_RULES_PATH,
|
|
23
|
+
} from "../clients/config-export/zcode-store";
|
|
20
24
|
import { createClineIO, ClineTransactionError } from "./cline-io";
|
|
21
25
|
import { parseClineDocument } from "./cline-document";
|
|
22
26
|
import { PARSE_FAILED, defaultIntegrationIO, loadTarget, parseConfig, type IntegrationIO } from "./config-io";
|
|
23
|
-
import {
|
|
27
|
+
import {
|
|
28
|
+
INTEGRATION_CLIENTS,
|
|
29
|
+
isLoopbackOnly,
|
|
30
|
+
resolveIntegrationPaths,
|
|
31
|
+
type IntegrationClientId,
|
|
32
|
+
} from "./registry";
|
|
33
|
+
import { declaredIntegrationTarget, resolveIntegrationTarget, type IntegrationTarget } from "./target";
|
|
24
34
|
import { shouldInjectApiAuthHeader } from "../codex/inject";
|
|
25
35
|
import { classifyIntegration, exportContextOf, readPath, type IntegrationState, type StateReason } from "./state";
|
|
36
|
+
import { InvalidSelectorError } from "./merge";
|
|
26
37
|
import { createIntegrationStateStore, type IntegrationStateStore } from "./store";
|
|
27
38
|
import type { OcxConfig } from "../types";
|
|
28
39
|
import { matchesOperationResult, type JournalEntry } from "./journal";
|
|
@@ -37,6 +48,7 @@ export type RefusalReason =
|
|
|
37
48
|
| "conflict"
|
|
38
49
|
| "unsafe"
|
|
39
50
|
| "non_loopback"
|
|
51
|
+
| "superseded_store"
|
|
40
52
|
| "drift_requires_confirm"
|
|
41
53
|
| "snapshot_expired"
|
|
42
54
|
| "write_failed";
|
|
@@ -114,7 +126,16 @@ const CLIENT_MANAGED_PATHS = {
|
|
|
114
126
|
gajae: [["providers", OPENCODE_PROVIDER_ID]],
|
|
115
127
|
dsh: [["llm-pi-ai", "providers", OPENCODE_PROVIDER_ID]],
|
|
116
128
|
mcode: [["custom_provider", OPENCODE_PROVIDER_ID]],
|
|
117
|
-
zcode: [
|
|
129
|
+
zcode: [
|
|
130
|
+
["provider", OPENCODE_PROVIDER_ID],
|
|
131
|
+
/*
|
|
132
|
+
* The store the current client reads. The model rule carries the model id in
|
|
133
|
+
* its own selector, so the published segment is the dynamic one: the template
|
|
134
|
+
* leaves this module, never the observed path.
|
|
135
|
+
*/
|
|
136
|
+
[...ZCODE_STORE_PROVIDER_RULES_PATH, `[providerId=${OPENCODE_PROVIDER_ID}]`],
|
|
137
|
+
[...ZCODE_STORE_MODEL_RULES_PATH, DYNAMIC_SEGMENT],
|
|
138
|
+
],
|
|
118
139
|
prime: [["providers", OPENCODE_PROVIDER_ID]],
|
|
119
140
|
aside: [["providers", OPENCODE_PROVIDER_ID]],
|
|
120
141
|
raycast: [["providers", `[id=${OPENCODE_PROVIDER_ID}]`]],
|
|
@@ -205,6 +226,18 @@ export interface PlanFingerprintInput {
|
|
|
205
226
|
* plan that did not bind it could be confirmed after the proxy stopped being a legal target.
|
|
206
227
|
*/
|
|
207
228
|
readonly admissionBlocked: boolean;
|
|
229
|
+
/**
|
|
230
|
+
* Why a write to the target would not reach the client, or null.
|
|
231
|
+
*
|
|
232
|
+
* Bound for the same reason `installKind` is: it can flip without touching the
|
|
233
|
+
* file, the record or the contribution. A client that creates its new store
|
|
234
|
+
* while a confirmation is outstanding has changed whether the write can reach
|
|
235
|
+
* it, and a plan that did not bind this would still authorize the write. The
|
|
236
|
+
* reason travels with the location because both can move on their own: a store
|
|
237
|
+
* whose schema version changes under an unchanged path is the same class of
|
|
238
|
+
* flip as a store appearing.
|
|
239
|
+
*/
|
|
240
|
+
readonly ineffectiveWrite: string | null;
|
|
208
241
|
/** Exact current bytes, or null when the target is missing. Missing and empty are not equal. */
|
|
209
242
|
readonly before: string | null;
|
|
210
243
|
readonly contribution: ManagedContribution | null;
|
|
@@ -226,7 +259,7 @@ export interface PlanFingerprintInput {
|
|
|
226
259
|
};
|
|
227
260
|
}
|
|
228
261
|
|
|
229
|
-
const PLAN_FINGERPRINT_VERSION = "
|
|
262
|
+
const PLAN_FINGERPRINT_VERSION = "p2";
|
|
230
263
|
|
|
231
264
|
function digest(value: string): string {
|
|
232
265
|
return createHash("sha256").update(value).digest("hex").slice(0, 32);
|
|
@@ -251,6 +284,7 @@ export function planFingerprint(input: PlanFingerprintInput): string {
|
|
|
251
284
|
input.detectDir,
|
|
252
285
|
input.installKind,
|
|
253
286
|
input.admissionBlocked,
|
|
287
|
+
input.ineffectiveWrite,
|
|
254
288
|
input.before === null ? "\u0000absent" : fingerprint(input.before),
|
|
255
289
|
input.contribution === null ? null : fingerprint(canonicalContribution(input.contribution)),
|
|
256
290
|
input.record === null ? null : fingerprint(JSON.stringify(input.record)),
|
|
@@ -305,6 +339,13 @@ function foreignEditOf(input: PlanInput): IntegrationPlanForeignEdit {
|
|
|
305
339
|
function applyOutcome(input: PlanInput): PlanOutcome {
|
|
306
340
|
if (input.installKind !== "dir") return deny("not_installed");
|
|
307
341
|
if (input.admissionBlocked) return deny("non_loopback");
|
|
342
|
+
/*
|
|
343
|
+
* Before any file state. The document may be perfectly writable and our block
|
|
344
|
+
* may already be current in it; neither says anything about whether the
|
|
345
|
+
* client reads it, and reporting a change to a file nobody opens is the
|
|
346
|
+
* defect this refusal exists for.
|
|
347
|
+
*/
|
|
348
|
+
if (input.ineffectiveWrite !== null) return deny("superseded_store");
|
|
308
349
|
// Overwrite exists precisely to proceed through a conflict the operator has been shown.
|
|
309
350
|
if (input.classified.state === "conflict" && input.operation !== "overwrite") return deny("conflict");
|
|
310
351
|
if (input.classified.state === "unsafe") return deny("unsafe");
|
|
@@ -400,7 +441,20 @@ function changesOf(input: PlanInput): readonly IntegrationPlanChange[] {
|
|
|
400
441
|
*/
|
|
401
442
|
const documentKnown = input.parsed !== PARSE_FAILED;
|
|
402
443
|
for (const [path, fragment] of prior) {
|
|
403
|
-
|
|
444
|
+
let absent = false;
|
|
445
|
+
if (documentKnown) {
|
|
446
|
+
try {
|
|
447
|
+
absent = readPath(input.parsed, fragment) === undefined;
|
|
448
|
+
} catch (error) {
|
|
449
|
+
/*
|
|
450
|
+
* Restore eligibility is byte-based and does not parse ownership paths.
|
|
451
|
+
* A malformed versioned selector therefore makes this ONE descriptive
|
|
452
|
+
* comparison unknown; it must not make preview execute a different
|
|
453
|
+
* selector, and it must not hide the backup behind an internal error.
|
|
454
|
+
*/
|
|
455
|
+
if (!(error instanceof InvalidSelectorError)) throw error;
|
|
456
|
+
}
|
|
457
|
+
}
|
|
404
458
|
changes.push({ kind: absent ? "add" : "replace", path });
|
|
405
459
|
}
|
|
406
460
|
for (const fragment of input.record?.fragmentPaths ?? []) {
|
|
@@ -514,9 +568,16 @@ export function observeRestore(
|
|
|
514
568
|
return { failed: observationFailure("unsafe", "unsafe", "that operation cannot be undone") } as const;
|
|
515
569
|
}
|
|
516
570
|
const configPath = entry.configPath;
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
571
|
+
/*
|
|
572
|
+
* An undo acts on the path the operation was journaled against. A row recorded for one home must
|
|
573
|
+
* never be allowed to rewrite a file in another — but a client may legally have written more than
|
|
574
|
+
* one file, so the test is whether this client still names that location, not whether it is the
|
|
575
|
+
* config file. The answer also carries the document shape those bytes are in.
|
|
576
|
+
*/
|
|
577
|
+
const rowTarget = declaredIntegrationTarget({
|
|
578
|
+
clientId, configPath, resolvedConfigPath: resolved.configPath, env: input.env, home: input.home,
|
|
579
|
+
});
|
|
580
|
+
if (rowTarget === null) {
|
|
520
581
|
return {
|
|
521
582
|
failed: observationFailure("conflict", "conflict", "that operation was recorded for a different location"),
|
|
522
583
|
} as const;
|
|
@@ -546,6 +607,7 @@ export function observeRestore(
|
|
|
546
607
|
failed: undefined,
|
|
547
608
|
clientId,
|
|
548
609
|
configPath,
|
|
610
|
+
format: rowTarget.format,
|
|
549
611
|
detectDir: resolved.detectDir,
|
|
550
612
|
installKind: io.statKind(resolved.detectDir),
|
|
551
613
|
entry,
|
|
@@ -589,6 +651,7 @@ export function previewIntegration(input: IntegrationWriteInput, request: Previe
|
|
|
589
651
|
installKind: observed.io.statKind(observed.detectDir),
|
|
590
652
|
// Loopback-only clients cannot carry the admission header a non-loopback bind requires.
|
|
591
653
|
admissionBlocked: isLoopbackOnly(observed.clientId) && shouldInjectApiAuthHeader(input.config),
|
|
654
|
+
ineffectiveWrite: observed.ineffectiveWrite,
|
|
592
655
|
before: observed.before,
|
|
593
656
|
contribution: observed.contribution,
|
|
594
657
|
record: observed.record,
|
|
@@ -659,6 +722,13 @@ function previewRestore(input: IntegrationWriteInput, request: PreviewRequest):
|
|
|
659
722
|
detectDir: observed.detectDir,
|
|
660
723
|
installKind: observed.installKind,
|
|
661
724
|
admissionBlocked: false,
|
|
725
|
+
/*
|
|
726
|
+
* Undo puts back bytes this project already wrote to this file. Whether the
|
|
727
|
+
* client still reads the file does not change whether those bytes may be
|
|
728
|
+
* restored, and refusing here would strand a user on a state they asked to
|
|
729
|
+
* leave.
|
|
730
|
+
*/
|
|
731
|
+
ineffectiveWrite: null,
|
|
662
732
|
before: observed.before,
|
|
663
733
|
contribution: null,
|
|
664
734
|
// Descriptive, never decisive. The record says which places are ours now and the document
|
|
@@ -673,7 +743,7 @@ function previewRestore(input: IntegrationWriteInput, request: PreviewRequest):
|
|
|
673
743
|
? {}
|
|
674
744
|
: observed.clientId === "cline"
|
|
675
745
|
? parseClineDocument(observed.before)
|
|
676
|
-
: parseConfig(observed.before,
|
|
746
|
+
: parseConfig(observed.before, observed.format),
|
|
677
747
|
restore: {
|
|
678
748
|
opId: observed.entry.opId,
|
|
679
749
|
entry: observed.entry,
|
|
@@ -757,6 +827,8 @@ export function observeIntegration(input: IntegrationWriteInput, effects: Observ
|
|
|
757
827
|
*/
|
|
758
828
|
let configPath: string;
|
|
759
829
|
let detectDir: string;
|
|
830
|
+
let effective: IntegrationTarget;
|
|
831
|
+
let stored: OwnershipRecord | null;
|
|
760
832
|
try {
|
|
761
833
|
/*
|
|
762
834
|
* Resolve the PAIR, never one half.
|
|
@@ -768,9 +840,26 @@ export function observeIntegration(input: IntegrationWriteInput, effects: Observ
|
|
|
768
840
|
* verify account 1 was installed and then write account 0's catalog.
|
|
769
841
|
*/
|
|
770
842
|
const resolved = input.resolvedPaths ?? resolveIntegrationPaths(clientId, input.env, input.home);
|
|
771
|
-
configPath = resolved.configPath;
|
|
772
843
|
detectDir = resolved.detectDir;
|
|
773
|
-
if (clientId === "cline") io = createClineIO(io, configPath, store, effects.recover);
|
|
844
|
+
if (clientId === "cline") io = createClineIO(io, resolved.configPath, store, effects.recover);
|
|
845
|
+
/*
|
|
846
|
+
* A record proves ownership of the file it was written FOR, and it is also
|
|
847
|
+
* one of the inputs the target is chosen from: a block we already wrote to
|
|
848
|
+
* the config file keeps this operation on that file, so disable removes what
|
|
849
|
+
* we wrote from where we wrote it. Matching by path happens after the
|
|
850
|
+
* target is known, because that is the path it has to match.
|
|
851
|
+
*/
|
|
852
|
+
stored = store.readRecords()[clientId] ?? null;
|
|
853
|
+
/*
|
|
854
|
+
* Inside the same guard as resolution, because this resolver can refuse the
|
|
855
|
+
* same way: the store is named by a client env var, and a relative one is a
|
|
856
|
+
* misconfiguration to report rather than an exception to leak through the
|
|
857
|
+
* collection route.
|
|
858
|
+
*/
|
|
859
|
+
effective = resolveIntegrationTarget({
|
|
860
|
+
clientId, configPath: resolved.configPath, io, record: stored, env: input.env, home: input.home,
|
|
861
|
+
});
|
|
862
|
+
configPath = effective.configPath;
|
|
774
863
|
} catch (error) {
|
|
775
864
|
if (error instanceof ClineTransactionError) {
|
|
776
865
|
return { failed: { ...observationFailure("unsafe", "unsafe", error.message, error.snapshotPath), residual: true } } as const;
|
|
@@ -781,26 +870,31 @@ export function observeIntegration(input: IntegrationWriteInput, effects: Observ
|
|
|
781
870
|
// Pruning writes, so only a mutation may perform it. Preview reports the state it finds.
|
|
782
871
|
if (effects.maintenance) store.retryPendingPrunes();
|
|
783
872
|
|
|
784
|
-
const
|
|
785
|
-
if (!
|
|
873
|
+
const loaded = loadTarget(io, configPath);
|
|
874
|
+
if (!loaded.ok) {
|
|
786
875
|
return {
|
|
787
876
|
failed: observationFailure("unsafe", "unsafe",
|
|
788
|
-
|
|
877
|
+
loaded.why === "read-failed"
|
|
789
878
|
? `${configPath} exists but could not be read`
|
|
790
879
|
: `${configPath} is not a regular file`),
|
|
791
880
|
} as const;
|
|
792
881
|
}
|
|
793
|
-
const before =
|
|
794
|
-
const parsed = clientId === "cline" ? parseClineDocument(before) : parseConfig(before,
|
|
882
|
+
const before = loaded.before;
|
|
883
|
+
const parsed = clientId === "cline" ? parseClineDocument(before) : parseConfig(before, effective.format);
|
|
795
884
|
if (parsed === PARSE_FAILED) {
|
|
796
885
|
return { failed: observationFailure("unsafe", "unsafe",
|
|
797
886
|
`${configPath} could not be parsed, or holds something opencodex cannot rewrite without changing it (a non-finite number, a large integer or a tiny one a rewrite would round, -0, a duplicate member, or nesting deeper than 1000 levels)`) } as const;
|
|
798
887
|
}
|
|
799
|
-
|
|
888
|
+
/*
|
|
889
|
+
* The shape the TARGET's reader understands, which is not always the
|
|
890
|
+
* client's config format: a client that moved its providers to another file
|
|
891
|
+
* reads a different document there, and a write in the config file's shape
|
|
892
|
+
* would be as unread as a write to the config file itself.
|
|
893
|
+
*/
|
|
894
|
+
const contribution = effective.buildContribution(exportContextOf(input));
|
|
800
895
|
// A record proves ownership of the file it was written FOR. Matching only by
|
|
801
896
|
// client id let a record for one home authorize a write to another whose
|
|
802
897
|
// bytes happened to hash the same — which deleted a config we never touched.
|
|
803
|
-
const stored = store.readRecords()[clientId] ?? null;
|
|
804
898
|
const record = stored && stored.clientId === clientId && stored.configPath === configPath
|
|
805
899
|
? stored
|
|
806
900
|
: null;
|
|
@@ -810,6 +904,18 @@ export function observeIntegration(input: IntegrationWriteInput, effects: Observ
|
|
|
810
904
|
// ownership here and disable would delete fragments it never wrote.
|
|
811
905
|
const classified = classifyIntegration({
|
|
812
906
|
fileText: before, fileIsRegular: true, parsed, record, contribution, configPath, clientId,
|
|
907
|
+
format: effective.format,
|
|
813
908
|
});
|
|
814
|
-
return {
|
|
909
|
+
return {
|
|
910
|
+
failed: undefined, store, io, clientId, spec, exportSpec, target: effective, configPath, detectDir,
|
|
911
|
+
/*
|
|
912
|
+
* One token for the plan, because the location alone is not the input: a
|
|
913
|
+
* store whose schema stops being one we recognise changes the answer while
|
|
914
|
+
* its path stays exactly the same.
|
|
915
|
+
*/
|
|
916
|
+
ineffectiveWrite: effective.ineffective === null
|
|
917
|
+
? null
|
|
918
|
+
: `${effective.ineffective.why}\u0000${effective.ineffective.store}`,
|
|
919
|
+
before, parsed, contribution, record, classified,
|
|
920
|
+
} as const;
|
|
815
921
|
}
|
|
@@ -43,6 +43,11 @@ import {
|
|
|
43
43
|
raycastConfigPath,
|
|
44
44
|
zcodeConfigPath,
|
|
45
45
|
zcodeHomeDir,
|
|
46
|
+
zcodeProviderStorePath,
|
|
47
|
+
buildZcodeStoreContribution,
|
|
48
|
+
zcodeStoreSchemaEstablished,
|
|
49
|
+
type BuildContribution,
|
|
50
|
+
type ConfigFormat,
|
|
46
51
|
type ExportClientId,
|
|
47
52
|
} from "../clients/config-export";
|
|
48
53
|
|
|
@@ -58,6 +63,28 @@ export interface IntegrationClientSpec {
|
|
|
58
63
|
configPath: (env?: NodeJS.ProcessEnv, home?: string) => string;
|
|
59
64
|
/** Directory whose existence is the cheap "is it installed?" signal. */
|
|
60
65
|
detectDir: (env?: NodeJS.ProcessEnv, home?: string) => string;
|
|
66
|
+
/**
|
|
67
|
+
* The provider store this client reads INSTEAD of `configPath`.
|
|
68
|
+
*
|
|
69
|
+
* A client that moves its store between releases usually keeps a one-shot
|
|
70
|
+
* import from the old location, and that import is exactly what makes the old
|
|
71
|
+
* write look like it still works: it runs once, on an install that has never
|
|
72
|
+
* created the new file, and never again. Everything after it lands in a file
|
|
73
|
+
* the client does not open.
|
|
74
|
+
*
|
|
75
|
+
* A declaration carries everything needed to write the store, not only its
|
|
76
|
+
* location: the text format, the contribution shape its reader understands,
|
|
77
|
+
* and the predicate that says whether a document on disk is a version whose
|
|
78
|
+
* shape has been observed. The last one is what keeps this honest — a store
|
|
79
|
+
* we cannot establish is reported as the reason the write cannot reach the
|
|
80
|
+
* client, never merged into on a guess.
|
|
81
|
+
*/
|
|
82
|
+
currentStore?: {
|
|
83
|
+
path: (env?: NodeJS.ProcessEnv, home?: string) => string;
|
|
84
|
+
format: ConfigFormat;
|
|
85
|
+
establishes: (parsed: unknown) => boolean;
|
|
86
|
+
buildContribution: BuildContribution;
|
|
87
|
+
};
|
|
61
88
|
/** Patch only this block-map YAML leaf; never re-render the shared file. */
|
|
62
89
|
sourcePreservingYaml?: { path: readonly string[] };
|
|
63
90
|
/** Coordinate the complete mutation through a sibling config lock. */
|
|
@@ -231,6 +258,17 @@ export const INTEGRATION_CLIENTS: Record<IntegrationClientId, IntegrationClientS
|
|
|
231
258
|
id: "zcode",
|
|
232
259
|
configPath: (env = process.env, home = homedir()) => zcodeConfigPath(env, home),
|
|
233
260
|
detectDir: (env = process.env, home = homedir()) => zcodeHomeDir(env, home),
|
|
261
|
+
/*
|
|
262
|
+
* ZCode 3.14 reads its providers from `v2/provider_config.json` and reaches
|
|
263
|
+
* `v2/config.json` only through the import that seeded it. Where the new
|
|
264
|
+
* file exists the import is spent, so our write is read by nobody (#5348).
|
|
265
|
+
*/
|
|
266
|
+
currentStore: {
|
|
267
|
+
path: (env = process.env, home = homedir()) => zcodeProviderStorePath(env, home),
|
|
268
|
+
format: "json",
|
|
269
|
+
establishes: zcodeStoreSchemaEstablished,
|
|
270
|
+
buildContribution: buildZcodeStoreContribution,
|
|
271
|
+
},
|
|
234
272
|
},
|
|
235
273
|
prime: {
|
|
236
274
|
id: "prime",
|