Skip to content

Commit 91b2c4e

Browse files
authored
feat(cli): let the terminal resolve a conflict too (#3088)
* feat(cli): let the terminal resolve a conflict too PR #3084 gave the dashboard a way past a conflicted client config. The CLI got nothing, so `ocx integration client enable` still dead-ends on exactly the state the overwrite path exists to escape -- and it strands the user who has no browser: an SSH session, or an agent driving the proxy. Adds `--overwrite-conflict`, spelled the way `restore --confirm-drift` already is. Never assumed: without the flag a conflict is still refused, and the field is omitted from the request entirely rather than sent as false, so a proxy on an older build sees the request it has always seen. `--overwrite-conflict` with `disable` fails locally instead of being forwarded. The route answers 400 for that pair, but a usage error names the flag that is wrong where the route reply arrives as a generic failed request. Forcing a disable over a conflict deletes a block we never wrote, which is the one thing the refusal exists to prevent. Docs said the switch "locks and disable refuses rather than guessing", which is now only half true. The English guide describes Replace and the new flag, and the three translated copies of that page get the flag block so they do not contradict the source. Verification: 41 pass in tests/cli-headless-parity.test.ts, driven red twice -- dropping the flag from the request body, and neutering the disable guard. tsc clean, privacy:scan clean, skill:surface:check current, test:changed 86 pass. * test(gui): pin the two things that keep the overwrite dialog readable on a phone The conflict dialog was verified at desktop width only. Measured at 390px in both themes it is fine -- 370px wide at left:10, no horizontal overflow anywhere on the page, the Replace button not clipped, and the config path inside its container -- but two of those depended on details nothing was checking. The dialog is 370px wide and a config path is one long unbroken token, so the path needs an in-word break opportunity or it overflows and the single fact the user needs (which file is about to change) goes off screen. Two things have to hold: the path renders inside a <code> element, and that element is allowed to break. Guard one asserts the path is in a <code> with a long realistic path. Guard two asserts the stylesheet rule, because a CSS declaration has no type or render coverage in a DOM-less suite. Verification: 41 pass across the two files. Both driven red -- rendering the path as bare text, and dropping overflow-wrap from the dialog rule. The first attempt at the CSS falsification was itself wrong: it replaced the FIRST overflow-wrap in the file, which belongs to .integration-path, and the guard stayed green. Re-run against the whole declaration it goes red, which is the only version worth keeping. * test(gui): use a synthetic home in the dialog path fixture privacy:scan rejects a committed /Users/<name>/ path, and the fixture I added carried a real one. The scan ran clean before that test existed, which is how it reached CI.
1 parent b4303bb commit 91b2c4e

8 files changed

Lines changed: 153 additions & 2 deletions

File tree

docs-site/src/content/docs/fr/guides/integrations.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,16 @@ ocx integration client history --client hermes
145145
ocx integration client restore --op <opId> [--confirm-drift]
146146
```
147147

148+
`--overwrite-conflict` est la forme terminale de **Replace** :
149+
150+
```bash
151+
ocx integration client enable --client zcode --overwrite-conflict
152+
```
153+
154+
Comme `--confirm-drift`, il n'est jamais supposé : sans lui, un conflit reste refusé.
155+
Il ne s'applique qu'à `enable` ; forcer un *disable* sur un conflit supprimerait un bloc
156+
que nous n'avons jamais écrit, donc cette combinaison est rejetée.
157+
148158
Pour MiniMax Code, connectez une fois le fournisseur puis utilisez l’enveloppe qui vérifie la connexion :
149159

150160
```bash

docs-site/src/content/docs/guides/integrations.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,14 @@ written as whole documents), or
124124
whenever our own entries were edited, the switch locks and disable refuses rather
125125
than guessing which edits were yours.
126126

127+
That lock is no longer a dead end. A conflicted client shows **Replace** next to its
128+
switch, on both the overview card and the client's own page. It replaces whatever
129+
holds our settings with the block opencodex would write, and it asks first: the
130+
dialog names the file, says what is lost, and points at the snapshot that makes it
131+
undoable. The switch itself stays locked, because the switch cannot know which edits
132+
you meant to keep — only you can say so. Nothing else is relaxed: a file we cannot
133+
parse, or one whose structure we cannot reason about, still refuses.
134+
127135
## What to expect, honestly
128136

129137
**Formatting is generally not preserved.** Applying parses a config and writes it back
@@ -175,6 +183,16 @@ ocx integration client history --client hermes
175183
ocx integration client restore --op <opId> [--confirm-drift]
176184
```
177185

186+
`--overwrite-conflict` is the terminal form of **Replace**:
187+
188+
```bash
189+
ocx integration client enable --client zcode --overwrite-conflict
190+
```
191+
192+
Like `--confirm-drift`, it is never assumed — without it a conflict is still refused.
193+
It applies only to `enable`; forcing a *disable* over a conflict would delete a block
194+
we never wrote, so that combination is rejected.
195+
178196
For MiniMax Code, connect the provider once and launch through the checked wrapper:
179197

180198
```bash

docs-site/src/content/docs/tr/guides/integrations.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,16 @@ ocx integration client history --client hermes
168168
ocx integration client restore --op <opId> [--confirm-drift]
169169
```
170170

171+
`--overwrite-conflict`, **Replace** eyleminin terminal karsiligidir:
172+
173+
```bash
174+
ocx integration client enable --client zcode --overwrite-conflict
175+
```
176+
177+
`--confirm-drift` gibi asla varsayilmaz: bayrak yazilmadan catisma yine reddedilir.
178+
Yalnizca `enable` icin gecerlidir; bir catismanin uzerine *disable* zorlamak hic
179+
yazmadigimiz bir blogu silecegi icin bu birlesim reddedilir.
180+
171181
MiniMax Code için sağlayıcıyı bir kez bağlayın ve denetimli başlatıcı üzerinden çalıştırın:
172182

173183
```bash

docs-site/src/content/docs/zh-tw/guides/integrations.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,15 @@ ocx integration client history --client hermes
8080
ocx integration client restore --op <opId> [--confirm-drift]
8181
```
8282

83+
`--overwrite-conflict`**Replace** 的終端形式:
84+
85+
```bash
86+
ocx integration client enable --client zcode --overwrite-conflict
87+
```
88+
89+
`--confirm-drift` 一樣,它永遠不會被預設:沒有這個旗標,衝突仍然會被拒絕。
90+
它只適用於 `enable`;對衝突強制 *disable* 會刪除我們從未寫入的區塊,因此這個組合會被拒絕。
91+
8392
MiniMax Code 先連接一次 provider,再透過會檢查設定的 launcher 啟動:
8493

8594
```bash

gui/tests/integration-marks.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,3 +177,19 @@ test("the three newest marks are painted the way their artwork requires", () =>
177177
expect(inksOf(bodyOf("/provider-icons/gajae-code.svg")).size).toBeGreaterThan(1);
178178
expect(/<linearGradient[\s>]/.test(bodyOf("/provider-icons/minimax.svg"))).toBe(true);
179179
});
180+
181+
/*
182+
* The stylesheet rule the mobile dialog depends on.
183+
*
184+
* A config path is one long unbroken token and the dialog is 370px wide at a 390px
185+
* viewport, so without an in-word break opportunity the path overflows and the one
186+
* fact the user needs -- WHICH file is about to change -- goes off screen. This is
187+
* a CSS declaration with no type or render coverage in a DOM-less suite, so it is
188+
* asserted as text.
189+
*/
190+
test("the consequence dialog lets a long path break mid-token", () => {
191+
const css = readFileSync(join(import.meta.dir, "..", "src", "styles-integrations.css"), "utf8");
192+
const rule = css.match(/\.integration-consequence-body code \{[^}]*\}/);
193+
expect(rule).not.toBeNull();
194+
expect(rule![0]).toMatch(/overflow-wrap:\s*anywhere/);
195+
});

gui/tests/integrations-surfaces.test.tsx

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -356,6 +356,37 @@ test("a foreign edit and an unowned block get different dialog copy", async () =
356356
.toContain("A block we did not write");
357357
});
358358

359+
test("the dialog's config path can break mid-string, so it cannot overflow a phone", async () => {
360+
/*
361+
* The dialog is 370px wide at a 390px viewport and the path it names is a long
362+
* unbroken token -- a real one is `~/.zcode/v2/config.json` and worse. Without a
363+
* break opportunity inside the word that token overflows its own container,
364+
* which is how the one piece of information the user needs (WHICH file) ends up
365+
* off screen.
366+
*
367+
* happy-dom does no layout, so measured geometry is not available here; what is
368+
* checkable is that the path renders inside an element the stylesheet allows to
369+
* break. Rendered geometry was measured separately at 390px in both themes
370+
* (dialog 370px wide at left:10, code element 212px, no overflow).
371+
*/
372+
// A synthetic home, not a real one: privacy:scan rejects a committed /Users/<name>/.
373+
const longPath = "/home/dev/Library/Application Support/SomeVendor/deeply/nested/config.json";
374+
stateResponse = () => json(status({
375+
state: "conflict",
376+
reason: "unowned-key",
377+
configPath: longPath,
378+
}));
379+
await remountClient();
380+
await act(async () => { buttonByText("Replace")!.click(); });
381+
382+
const dialog = container.querySelector(".integration-consequence-dialog")!;
383+
const code = dialog.querySelector("code");
384+
// A <code> element, not bare text: `.integration-consequence-body code` is what
385+
// carries `overflow-wrap: anywhere`.
386+
expect(code).not.toBeNull();
387+
expect(code!.textContent).toBe(longPath);
388+
});
389+
359390
test("unsafe locks the switch instead of guessing", async () => {
360391
stateResponse = () => json(status({ state: "unsafe", reason: "unparseable" }));
361392
await mountClient();

src/cli/integrations.ts

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ const GROK_USAGE = `Usage:
2929

3030
const CLIENT_USAGE = `Usage:
3131
ocx integration client [status] [--client <id>] [--json]
32-
ocx integration client <enable|disable> --client <id> [--json]
32+
ocx integration client <enable|disable> --client <id> [--overwrite-conflict] [--json]
3333
ocx integration client history [--client <id>] [--json]
3434
ocx integration client restore --op <opId> [--confirm-drift] [--json]`;
3535

@@ -212,11 +212,33 @@ export async function handleClientIntegrationCommand(
212212
throw new CliUsageError(`unknown client integration command ${action}`, CLIENT_USAGE);
213213
}
214214
const client = takeOption(args, "--client");
215+
/*
216+
* The conflict escape hatch, spelled the way `restore --confirm-drift` is: the
217+
* refusal is the default and the waiver has to be typed.
218+
*
219+
* Without it the dashboard could resolve a conflict and the CLI could not,
220+
* which strands exactly the user who cannot open a browser -- an SSH session,
221+
* or an agent driving the proxy. That dead end is the reason the overwrite
222+
* path exists at all.
223+
*/
224+
const overwriteConflict = takeFlag(args, "--overwrite-conflict");
215225
rejectArgs(args, CLIENT_USAGE);
216226
if (!client) throw new CliUsageError("--client <id> is required", CLIENT_USAGE);
227+
/*
228+
* Refused here rather than forwarded. The route answers 400 for this pair, but
229+
* a local usage error names the flag that is wrong, where the route's reply
230+
* arrives as a generic failed request.
231+
*/
232+
if (overwriteConflict && action === "disable") {
233+
throw new CliUsageError("--overwrite-conflict applies only to enable", CLIENT_USAGE);
234+
}
217235
const result = await runtimeRequest(`/api/client-integrations/${encodeURIComponent(client)}`, {
218236
method: "PUT",
219-
body: JSON.stringify({ enabled: action === "enable" }),
237+
// Sent only when asked for, so a proxy on an older build sees the request it
238+
// has always seen rather than an unknown field.
239+
body: JSON.stringify(overwriteConflict
240+
? { enabled: true, overwriteConflict: true }
241+
: { enabled: action === "enable" }),
220242
}, deps);
221243
printData(result, wantsJson, [String((result as Record<string, unknown>).message ?? `${client} ${action}d.`)]);
222244
});

tests/cli-headless-parity.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -554,6 +554,41 @@ describe("headless GUI parity CLI", () => {
554554
]);
555555
});
556556

557+
test("enable can waive a conflict, and only when the flag is typed", async () => {
558+
/*
559+
* The parity this closes: the dashboard could resolve a conflict and the CLI
560+
* could not, which strands the user who has no browser -- an SSH session, or
561+
* an agent driving the proxy. That dead end is the reason the overwrite path
562+
* exists, so leaving it GUI-only reproduces it for half the users.
563+
*/
564+
const runtime = fakeRuntime();
565+
expect(await handleClientIntegrationCommand(["enable", "--client", "hermes", "--json"], runtime.deps)).toBe(0);
566+
expect(await handleClientIntegrationCommand(
567+
["enable", "--client", "hermes", "--overwrite-conflict", "--json"],
568+
runtime.deps,
569+
)).toBe(0);
570+
expect(runtime.requests.map(row => row.body)).toEqual([
571+
// Absent rather than false: an older proxy sees the request it always saw.
572+
{ enabled: true },
573+
{ enabled: true, overwriteConflict: true },
574+
]);
575+
});
576+
577+
test("a conflict waiver cannot ride along with disable", async () => {
578+
/*
579+
* Forcing a DISABLE over a conflict deletes a block we do not own, which is
580+
* the one thing the refusal exists to prevent. The route answers 400; failing
581+
* locally names the offending flag instead of surfacing a generic request
582+
* failure, and sends nothing.
583+
*/
584+
const runtime = fakeRuntime();
585+
expect(await handleClientIntegrationCommand(
586+
["disable", "--client", "hermes", "--overwrite-conflict", "--json"],
587+
runtime.deps,
588+
)).not.toBe(0);
589+
expect(runtime.requests).toEqual([]);
590+
});
591+
557592
test("a client integration command without its required target fails instead of guessing", async () => {
558593
const runtime = fakeRuntime();
559594
// No `--client`: picking one for the user would write a config they never named.

0 commit comments

Comments
 (0)