clearotron 0.3.0-beta.2 → 0.3.0-beta.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/INSTALL.md +32 -6
  2. package/bin/connect.mjs +68 -25
  3. package/bin/disconnect.mjs +16 -11
  4. package/bin/onboard.mjs +65 -6
  5. package/bin/start.mjs +7 -1
  6. package/build-info.json +2 -2
  7. package/driver/CHANGELOG.md +38 -0
  8. package/driver/driver.config.mjs +5 -0
  9. package/driver/engine/anthropic-agent.mjs +34 -17
  10. package/driver/gateway.mjs +76 -15
  11. package/driver/package.json +1 -1
  12. package/driver/portal-service.mjs +1 -1
  13. package/driver/profile-service.mjs +31 -3
  14. package/driver/publish/index.mjs +12 -9
  15. package/driver/publish/knockout.mjs +4 -2
  16. package/driver/publish/office-record-links.mjs +56 -21
  17. package/driver/recipe-service.mjs +14 -5
  18. package/driver/record-origins.mjs +14 -0
  19. package/driver/suite-census.json +34 -28
  20. package/mcp-server/CHANGELOG.md +8 -0
  21. package/mcp-server/lib/driver.mjs +2 -0
  22. package/mcp-server/lib/knockout.mjs +2 -2
  23. package/mcp-server/lib/options.mjs +9 -2
  24. package/mcp-server/package.json +1 -1
  25. package/package.json +1 -1
  26. package/portal-ui/dist/assets/{index-CsCuPshD.css → index-Cv-E_agg.css} +199 -86
  27. package/portal-ui/dist/assets/{index-CcFjgM78.js → index-DWYCsOCJ.js} +514 -380
  28. package/portal-ui/dist/index.html +2 -2
  29. package/portal-ui/package.json +1 -1
  30. package/providers/clarivate/src/core.js +5 -0
  31. package/providers/oauth-mcp-bridge/CHANGELOG.md +8 -0
  32. package/providers/oauth-mcp-bridge/package.json +1 -1
  33. package/scripts/e2e-unread-terminals.mjs +1 -1
  34. package/scripts/e2e.mjs +114 -9
  35. package/scripts/revisit-render-check.mjs +22 -4
  36. package/scripts/travelling-predicates.mjs +1 -1
  37. package/shared/connect-clients.mjs +282 -321
  38. package/shared/names-in-force.mjs +1 -0
  39. package/shared/stdio-connect.mjs +63 -0
  40. package/shared/store-in-repo.mjs +50 -2
@@ -82,6 +82,7 @@ export const NAMES_IN_FORCE = Object.freeze([
82
82
  "CLEAROTRON_JX_SERP_DEADLINE_MS",
83
83
  "CLEAROTRON_JX_SERP_GRID",
84
84
  "CLEAROTRON_JX_SUBCLASS_DB",
85
+ "CLEAROTRON_KEY",
85
86
  "CLEAROTRON_KIFF_PROBE",
86
87
  "CLEAROTRON_KILL_ESCALATE_MS",
87
88
  "CLEAROTRON_KNOCKOUT_COUNT_FIXTURES",
@@ -144,6 +144,69 @@ export const STDIO_SHAPES = Object.freeze({
144
144
  },
145
145
  });
146
146
 
147
+ // ── THE SAME SERVER, REACHED OVER THE WEB ──────────────────────────────────────────────────────────
148
+ //
149
+ // An install running elsewhere is reached at its public address with a key, and three hosts take that in
150
+ // three spellings: Claude Code registers it with `--transport http` and a header, Codex reads a `url` and
151
+ // the NAME of a variable holding the key, and everything else takes the two lines as they are. They are
152
+ // composed here for the reason the stdio shapes are: a surface that spelled `claude mcp add` for itself
153
+ // would be a second author of it, and the browser does not know the address anyway.
154
+ //
155
+ // A KEY IS NEVER IN THESE STRINGS. It is minted when a person presses, for that person, and the page
156
+ // holds it only for the length of the press. So a shape that needs one carries KEY_SLOT where the key
157
+ // goes, and the only thing a surface does with the string is put the key it was just handed there.
158
+
159
+ /** Where a minted key goes in a composed string. Nothing else in a copy looks like it. */
160
+ export const KEY_SLOT = "{key}";
161
+
162
+ /** The variable Codex reads the key from, so it never sits in the settings file itself. */
163
+ export const KEY_ENV_VAR = "CLEAROTRON_KEY";
164
+
165
+ /** How each host takes the public address. A step of CONNECT_CLIENTS names one of these by key. */
166
+ export const REMOTE_SHAPES = Object.freeze({
167
+ "address-and-key": {
168
+ label: "Copy address and key",
169
+ render: ({ address }) => `${address}\n${KEY_SLOT}`,
170
+ },
171
+ // A host that signs its reader in through the browser needs the address and nothing else.
172
+ "address": {
173
+ label: null,
174
+ render: ({ address }) => address,
175
+ },
176
+ "claude-cli-http": {
177
+ label: "Copy command",
178
+ render: ({ address }) =>
179
+ `claude mcp add --transport http ${STDIO_SERVER_NAME} ${address} --header "Authorization: Bearer ${KEY_SLOT}"`,
180
+ },
181
+ "codex-toml-http": {
182
+ label: null,
183
+ render: ({ address }) => [
184
+ `[mcp_servers.${STDIO_SERVER_NAME}]`,
185
+ `url = "${address}"`,
186
+ `bearer_token_env_var = "${KEY_ENV_VAR}"`,
187
+ ].join("\n"),
188
+ },
189
+ "codex-key-line": {
190
+ label: "Copy key line",
191
+ render: () => `export ${KEY_ENV_VAR}=${KEY_SLOT}`,
192
+ },
193
+ });
194
+
195
+ /**
196
+ * One remote copy, resolved against this install's public address. PURE.
197
+ *
198
+ * `secret` is read off the composed string rather than declared beside it, so a shape cannot say it
199
+ * needs no key while carrying the slot, or the reverse. Null for an unknown shape or for an install with
200
+ * no public address: there is nothing true to hand over in either case.
201
+ */
202
+ export function remoteConnectFor(shape, { address = null } = {}) {
203
+ const spec = Object.hasOwn(REMOTE_SHAPES, String(shape ?? "")) ? REMOTE_SHAPES[shape] : null;
204
+ if (!spec || !address) return null;
205
+ const text = spec.render({ address });
206
+ const secret = text.includes(KEY_SLOT);
207
+ return { shape, secret, label: secret ? spec.label : null, text, name: STDIO_SERVER_NAME };
208
+ }
209
+
147
210
  /**
148
211
  * The stdio route for ONE host, in that host's own shape. PURE.
149
212
  *
@@ -142,7 +142,7 @@ export function storeOutsideRepoMessage({ storeVar, storeDir, repoVar, repoRoot
142
142
  // knows both. So the appender returns a path only when that path is committable, and the core commits
143
143
  // what it is handed.
144
144
 
145
- import { appendFileSync } from "node:fs";
145
+ import { appendFileSync, rmSync } from "node:fs";
146
146
 
147
147
  /**
148
148
  * A repo root is a PATH, and every helper below interpolates it into a git invocation. Anything else
@@ -289,6 +289,39 @@ import { execFileSync } from "node:child_process";
289
289
  export const isTransientGitFault = (detail) =>
290
290
  /index\.lock|another git process seems to be running|Unable to create/i.test(String(detail ?? ""));
291
291
 
292
+ /**
293
+ * Why a commit into `repoRoot` would be refused, asked BEFORE anything is written; null when it would not
294
+ * be. Read-only: `rev-parse` and `git var` change nothing. `{ code, detail, message }`, where `message` names
295
+ * the store and the command that clears it, in terms an operator acts on rather than git's own words:
296
+ *
297
+ * not-a-repository — the store is not inside a git repository this process can use;
298
+ * no-identity — git has no committer identity here. That is the default state of any machine where
299
+ * nobody ran `git config user.email`, a fresh Windows install among them. A save names
300
+ * its author; the COMMITTER is the machine's, and git refuses a commit it cannot name
301
+ * one for. `git -c user.email=…` on a one-off seed commit does not help: `-c` configures
302
+ * that invocation, not the repository, so every save after it fails the same way.
303
+ */
304
+ export function storeCommitRefusal(repoRoot, { env = process.env } = {}) {
305
+ requireRepoRootPath(repoRoot, "storeCommitRefusal");
306
+ const ask = (...args) => execFileSync("git", ["-C", repoRoot, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], env });
307
+ const said = (e) => String(e?.stderr || e?.message || e).trim().split("\n").filter(Boolean);
308
+ try { ask("rev-parse", "--git-dir"); }
309
+ catch (e) {
310
+ const detail = said(e)[0]?.slice(0, 200) ?? "";
311
+ const fix = /dubious ownership/i.test(detail)
312
+ ? `run \`git config --global --add safe.directory ${repoRoot}\` as the account the service runs as`
313
+ : `run \`git init\` in ${repoRoot}, or point PROFILE_REPO_ROOT at the repository that holds the store`;
314
+ return { code: "not-a-repository", detail, message: `the store at ${repoRoot} is not a git repository this install can record into (${detail}) — ${fix}` };
315
+ }
316
+ try { ask("var", "GIT_COMMITTER_IDENT"); }
317
+ catch (e) {
318
+ return { code: "no-identity", detail: said(e).pop()?.slice(0, 200) ?? "",
319
+ message: `the store at ${repoRoot} has no git identity, so nothing saved to it can be recorded — run `
320
+ + `\`git -C ${repoRoot} config user.email "you@example.com"\` and \`git -C ${repoRoot} config user.name "Your Name"\`` };
321
+ }
322
+ return null;
323
+ }
324
+
292
325
  export function makeStoreCommit({ repoRoot, log = () => {}, what = "store", retries = 3, waitMs = 50 }) {
293
326
  requireRepoRootPath(repoRoot, "makeStoreCommit");
294
327
  const git = (...args) => execFileSync("git", ["-C", repoRoot, ...args], { encoding: "utf8" }).toString().trim();
@@ -309,7 +342,7 @@ export function makeStoreCommit({ repoRoot, log = () => {}, what = "store", retr
309
342
  const napping = (ms) => { try { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); } catch { /* best effort */ } };
310
343
  const detailOf = (e) => String(e?.stderr ?? e?.message ?? e);
311
344
 
312
- return ({ files, message, author }) => {
345
+ const commit = ({ files, message, author }) => {
313
346
  // Asked BEFORE anything composes a diff, so the caller gets the refusal rather than a fallback-mode
314
347
  // parse error. Throwing here also means the write is never followed by a silent half-save: the
315
348
  // caller's own catch is what turns this into a failed save.
@@ -350,6 +383,21 @@ export function makeStoreCommit({ repoRoot, log = () => {}, what = "store", retr
350
383
  }
351
384
  }
352
385
  };
386
+ // ── TWO MORE ANSWERS, FOR A CREATE, WHICH IS REFUSED RATHER THAN LEFT HALF-MADE ──────────────────────
387
+ //
388
+ // A save of something that already exists stays as above: live, staged, completed by the next save.
389
+ // A CREATE is different, because "before" exists: the paths it wrote were absent. So a create asks
390
+ // `refusal()` first and writes nothing when the store cannot record it, and when the commit fails anyway
391
+ // (a hook, a full disk), `withdraw(files)` returns those paths to absent: out of the index, so the next
392
+ // save's completion step cannot commit a company that does not exist, and off the disk. Nothing else is
393
+ // touched, and the audit row the create staged stays staged, because it records what happened.
394
+ commit.refusal = () => storeCommitRefusal(repoRoot);
395
+ commit.withdraw = (files) => {
396
+ const paths = (files ?? []).map((f) => resolve(repoRoot, f));
397
+ if (paths.length) git("rm", "--cached", "--quiet", "--ignore-unmatch", "--", ...paths);
398
+ for (const p of paths) rmSync(p, { force: true });
399
+ };
400
+ return commit;
353
401
  }
354
402
 
355
403
  export function resolveStoreRepoRoot({ names, fallback = null, env = process.env } = {}) {