@sentropic/h2a-runtime 0.81.0
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/dist/account-pool.d.ts +168 -0
- package/dist/account-pool.d.ts.map +1 -0
- package/dist/account-pool.js +385 -0
- package/dist/account-pool.js.map +1 -0
- package/dist/agents-projection.d.ts +56 -0
- package/dist/agents-projection.d.ts.map +1 -0
- package/dist/agents-projection.js +82 -0
- package/dist/agents-projection.js.map +1 -0
- package/dist/attach.d.ts +75 -0
- package/dist/attach.d.ts.map +1 -0
- package/dist/attach.js +432 -0
- package/dist/attach.js.map +1 -0
- package/dist/auth-bundle.d.ts +21 -0
- package/dist/auth-bundle.d.ts.map +1 -0
- package/dist/auth-bundle.js +112 -0
- package/dist/auth-bundle.js.map +1 -0
- package/dist/auth-diagnostics.d.ts +18 -0
- package/dist/auth-diagnostics.d.ts.map +1 -0
- package/dist/auth-diagnostics.js +23 -0
- package/dist/auth-diagnostics.js.map +1 -0
- package/dist/auth-login.d.ts +9 -0
- package/dist/auth-login.d.ts.map +1 -0
- package/dist/auth-login.js +21 -0
- package/dist/auth-login.js.map +1 -0
- package/dist/auth-refresh.d.ts +32 -0
- package/dist/auth-refresh.d.ts.map +1 -0
- package/dist/auth-refresh.js +83 -0
- package/dist/auth-refresh.js.map +1 -0
- package/dist/auth-tools.d.ts +55 -0
- package/dist/auth-tools.d.ts.map +1 -0
- package/dist/auth-tools.js +163 -0
- package/dist/auth-tools.js.map +1 -0
- package/dist/browser-bridge-local.d.ts +27 -0
- package/dist/browser-bridge-local.d.ts.map +1 -0
- package/dist/browser-bridge-local.js +51 -0
- package/dist/browser-bridge-local.js.map +1 -0
- package/dist/browser.d.ts +53 -0
- package/dist/browser.d.ts.map +1 -0
- package/dist/browser.js +61 -0
- package/dist/browser.js.map +1 -0
- package/dist/conductor-launch.d.ts +179 -0
- package/dist/conductor-launch.d.ts.map +1 -0
- package/dist/conductor-launch.js +414 -0
- package/dist/conductor-launch.js.map +1 -0
- package/dist/config.d.ts +182 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +334 -0
- package/dist/config.js.map +1 -0
- package/dist/conv-guard.d.ts +76 -0
- package/dist/conv-guard.d.ts.map +1 -0
- package/dist/conv-guard.js +131 -0
- package/dist/conv-guard.js.map +1 -0
- package/dist/convsync.d.ts +24 -0
- package/dist/convsync.d.ts.map +1 -0
- package/dist/convsync.js +108 -0
- package/dist/convsync.js.map +1 -0
- package/dist/cred-health.d.ts +96 -0
- package/dist/cred-health.d.ts.map +1 -0
- package/dist/cred-health.js +170 -0
- package/dist/cred-health.js.map +1 -0
- package/dist/delegate.d.ts +406 -0
- package/dist/delegate.d.ts.map +1 -0
- package/dist/delegate.js +739 -0
- package/dist/delegate.js.map +1 -0
- package/dist/enroll.d.ts +91 -0
- package/dist/enroll.d.ts.map +1 -0
- package/dist/enroll.js +248 -0
- package/dist/enroll.js.map +1 -0
- package/dist/fanout.d.ts +63 -0
- package/dist/fanout.d.ts.map +1 -0
- package/dist/fanout.js +115 -0
- package/dist/fanout.js.map +1 -0
- package/dist/forward.d.ts +66 -0
- package/dist/forward.d.ts.map +1 -0
- package/dist/forward.js +141 -0
- package/dist/forward.js.map +1 -0
- package/dist/gitdiff.d.ts +31 -0
- package/dist/gitdiff.d.ts.map +1 -0
- package/dist/gitdiff.js +133 -0
- package/dist/gitdiff.js.map +1 -0
- package/dist/h2a-bridge.d.ts +102 -0
- package/dist/h2a-bridge.d.ts.map +1 -0
- package/dist/h2a-bridge.js +333 -0
- package/dist/h2a-bridge.js.map +1 -0
- package/dist/h2a-jobs.d.ts +235 -0
- package/dist/h2a-jobs.d.ts.map +1 -0
- package/dist/h2a-jobs.js +388 -0
- package/dist/h2a-jobs.js.map +1 -0
- package/dist/h2a-ping.d.ts +38 -0
- package/dist/h2a-ping.d.ts.map +1 -0
- package/dist/h2a-ping.js +58 -0
- package/dist/h2a-ping.js.map +1 -0
- package/dist/index.d.ts +98 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +5887 -0
- package/dist/index.js.map +1 -0
- package/dist/interactive-throttle.d.ts +164 -0
- package/dist/interactive-throttle.d.ts.map +1 -0
- package/dist/interactive-throttle.js +194 -0
- package/dist/interactive-throttle.js.map +1 -0
- package/dist/lineage-client.d.ts +77 -0
- package/dist/lineage-client.d.ts.map +1 -0
- package/dist/lineage-client.js +117 -0
- package/dist/lineage-client.js.map +1 -0
- package/dist/lineage-lease.d.ts +152 -0
- package/dist/lineage-lease.d.ts.map +1 -0
- package/dist/lineage-lease.js +303 -0
- package/dist/lineage-lease.js.map +1 -0
- package/dist/llm-gateway-runtime/accounts.d.ts +48 -0
- package/dist/llm-gateway-runtime/accounts.d.ts.map +1 -0
- package/dist/llm-gateway-runtime/accounts.js +226 -0
- package/dist/llm-gateway-runtime/accounts.js.map +1 -0
- package/dist/llm-gateway-runtime/index.d.ts +4 -0
- package/dist/llm-gateway-runtime/index.d.ts.map +1 -0
- package/dist/llm-gateway-runtime/index.js +58 -0
- package/dist/llm-gateway-runtime/index.js.map +1 -0
- package/dist/llm-gateway-runtime/model-catalog.d.ts +36 -0
- package/dist/llm-gateway-runtime/model-catalog.d.ts.map +1 -0
- package/dist/llm-gateway-runtime/model-catalog.js +146 -0
- package/dist/llm-gateway-runtime/model-catalog.js.map +1 -0
- package/dist/llm-gateway-runtime/proxy-anthropic.d.ts +3 -0
- package/dist/llm-gateway-runtime/proxy-anthropic.d.ts.map +1 -0
- package/dist/llm-gateway-runtime/proxy-anthropic.js +139 -0
- package/dist/llm-gateway-runtime/proxy-anthropic.js.map +1 -0
- package/dist/llm-gateway-runtime/proxy-openai.d.ts +117 -0
- package/dist/llm-gateway-runtime/proxy-openai.d.ts.map +1 -0
- package/dist/llm-gateway-runtime/proxy-openai.js +1132 -0
- package/dist/llm-gateway-runtime/proxy-openai.js.map +1 -0
- package/dist/llm-gateway-runtime/session-ledger.d.ts +32 -0
- package/dist/llm-gateway-runtime/session-ledger.d.ts.map +1 -0
- package/dist/llm-gateway-runtime/session-ledger.js +62 -0
- package/dist/llm-gateway-runtime/session-ledger.js.map +1 -0
- package/dist/llm-gateway-runtime/sticky.d.ts +40 -0
- package/dist/llm-gateway-runtime/sticky.d.ts.map +1 -0
- package/dist/llm-gateway-runtime/sticky.js +254 -0
- package/dist/llm-gateway-runtime/sticky.js.map +1 -0
- package/dist/llm-mesh.d.ts +102 -0
- package/dist/llm-mesh.d.ts.map +1 -0
- package/dist/llm-mesh.js +433 -0
- package/dist/llm-mesh.js.map +1 -0
- package/dist/migrate-candidates.d.ts +35 -0
- package/dist/migrate-candidates.d.ts.map +1 -0
- package/dist/migrate-candidates.js +128 -0
- package/dist/migrate-candidates.js.map +1 -0
- package/dist/migrate.d.ts +125 -0
- package/dist/migrate.d.ts.map +1 -0
- package/dist/migrate.js +561 -0
- package/dist/migrate.js.map +1 -0
- package/dist/plugin-manifest.d.ts +143 -0
- package/dist/plugin-manifest.d.ts.map +1 -0
- package/dist/plugin-manifest.js +233 -0
- package/dist/plugin-manifest.js.map +1 -0
- package/dist/plugin.d.ts +274 -0
- package/dist/plugin.d.ts.map +1 -0
- package/dist/plugin.js +919 -0
- package/dist/plugin.js.map +1 -0
- package/dist/pod-liveness.d.ts +51 -0
- package/dist/pod-liveness.d.ts.map +1 -0
- package/dist/pod-liveness.js +80 -0
- package/dist/pod-liveness.js.map +1 -0
- package/dist/profile-menu.d.ts +7 -0
- package/dist/profile-menu.d.ts.map +1 -0
- package/dist/profile-menu.js +52 -0
- package/dist/profile-menu.js.map +1 -0
- package/dist/profiles.d.ts +23 -0
- package/dist/profiles.d.ts.map +1 -0
- package/dist/profiles.js +66 -0
- package/dist/profiles.js.map +1 -0
- package/dist/protocol-local.d.ts +27 -0
- package/dist/protocol-local.d.ts.map +1 -0
- package/dist/protocol-local.js +12 -0
- package/dist/protocol-local.js.map +1 -0
- package/dist/pty.d.ts +26 -0
- package/dist/pty.d.ts.map +1 -0
- package/dist/pty.js +45 -0
- package/dist/pty.js.map +1 -0
- package/dist/readiness.d.ts +38 -0
- package/dist/readiness.d.ts.map +1 -0
- package/dist/readiness.js +169 -0
- package/dist/readiness.js.map +1 -0
- package/dist/registry.d.ts +258 -0
- package/dist/registry.d.ts.map +1 -0
- package/dist/registry.js +570 -0
- package/dist/registry.js.map +1 -0
- package/dist/relaunch.d.ts +52 -0
- package/dist/relaunch.d.ts.map +1 -0
- package/dist/relaunch.js +76 -0
- package/dist/relaunch.js.map +1 -0
- package/dist/restore.d.ts +165 -0
- package/dist/restore.d.ts.map +1 -0
- package/dist/restore.js +530 -0
- package/dist/restore.js.map +1 -0
- package/dist/run.d.ts +29 -0
- package/dist/run.d.ts.map +1 -0
- package/dist/run.js +206 -0
- package/dist/run.js.map +1 -0
- package/dist/secrets.d.ts +25 -0
- package/dist/secrets.d.ts.map +1 -0
- package/dist/secrets.js +98 -0
- package/dist/secrets.js.map +1 -0
- package/dist/session-restore.d.ts +26 -0
- package/dist/session-restore.d.ts.map +1 -0
- package/dist/session-restore.js +122 -0
- package/dist/session-restore.js.map +1 -0
- package/dist/skills-sync.d.ts +126 -0
- package/dist/skills-sync.d.ts.map +1 -0
- package/dist/skills-sync.js +255 -0
- package/dist/skills-sync.js.map +1 -0
- package/dist/smoke.d.ts +23 -0
- package/dist/smoke.d.ts.map +1 -0
- package/dist/smoke.js +138 -0
- package/dist/smoke.js.map +1 -0
- package/dist/soft-refresh.d.ts +107 -0
- package/dist/soft-refresh.d.ts.map +1 -0
- package/dist/soft-refresh.js +426 -0
- package/dist/soft-refresh.js.map +1 -0
- package/dist/sync-status.d.ts +22 -0
- package/dist/sync-status.d.ts.map +1 -0
- package/dist/sync-status.js +41 -0
- package/dist/sync-status.js.map +1 -0
- package/dist/sync.d.ts +77 -0
- package/dist/sync.d.ts.map +1 -0
- package/dist/sync.js +302 -0
- package/dist/sync.js.map +1 -0
- package/dist/throttle-signatures.d.ts +43 -0
- package/dist/throttle-signatures.d.ts.map +1 -0
- package/dist/throttle-signatures.js +116 -0
- package/dist/throttle-signatures.js.map +1 -0
- package/dist/tmux.d.ts +286 -0
- package/dist/tmux.d.ts.map +1 -0
- package/dist/tmux.js +970 -0
- package/dist/tmux.js.map +1 -0
- package/dist/tunnel.d.ts +25 -0
- package/dist/tunnel.d.ts.map +1 -0
- package/dist/tunnel.js +127 -0
- package/dist/tunnel.js.map +1 -0
- package/dist/workspace-merge.d.ts +17 -0
- package/dist/workspace-merge.d.ts.map +1 -0
- package/dist/workspace-merge.js +153 -0
- package/dist/workspace-merge.js.map +1 -0
- package/dist/workspace-sync-incremental.d.ts +64 -0
- package/dist/workspace-sync-incremental.d.ts.map +1 -0
- package/dist/workspace-sync-incremental.js +199 -0
- package/dist/workspace-sync-incremental.js.map +1 -0
- package/dist/workspace-sync.d.ts +3 -0
- package/dist/workspace-sync.d.ts.map +1 -0
- package/dist/workspace-sync.js +187 -0
- package/dist/workspace-sync.js.map +1 -0
- package/dist/workspace.d.ts +70 -0
- package/dist/workspace.d.ts.map +1 -0
- package/dist/workspace.js +173 -0
- package/dist/workspace.js.map +1 -0
- package/package.json +44 -0
package/dist/plugin.js
ADDED
|
@@ -0,0 +1,919 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `remote plugin` — install npm "plugin" packages (a CLI + an MCP server, e.g.
|
|
3
|
+
* @sentropic/track shipping bins `track` and `track-mcp`) for every agent CLI
|
|
4
|
+
* (claude, codex, agy), both LOCALLY (npm i -g + MCP registration) and inside
|
|
5
|
+
* live REMOTE session Pods (`remote plugin sync`: kubectl exec → npm i -g +
|
|
6
|
+
* per-profile MCP registration).
|
|
7
|
+
*
|
|
8
|
+
* agy (Antigravity CLI) has NO `agy mcp` subcommand: MCP servers are declared
|
|
9
|
+
* in ~/.gemini/config/mcp_config.json, a Claude-style `{"mcpServers": {…}}`
|
|
10
|
+
* JSON file (the agy changelog 1.0.3 calls this the "migrated" path; the old
|
|
11
|
+
* ~/.gemini/antigravity/mcp_config.json is legacy). We merge idempotently and
|
|
12
|
+
* keep a one-shot `.bak.<epoch>` backup the first time we touch a non-empty
|
|
13
|
+
* file.
|
|
14
|
+
*
|
|
15
|
+
* KNOWN PITFALL — broken entrypoint guard through the npm-global symlink:
|
|
16
|
+
* some packages (track@0.2.0) guard their entry script with a
|
|
17
|
+
* "was-I-run-directly?" check that compares argv[1] with the module path;
|
|
18
|
+
* invoked through the npm-global bin SYMLINK the two differ and the guard
|
|
19
|
+
* never fires (the CLI/MCP silently does nothing). So MCP servers are ALWAYS
|
|
20
|
+
* registered as `node <realpathSync(script)>` — never the bare bin name.
|
|
21
|
+
*
|
|
22
|
+
* Baking plugins into the session image is a separate TODO that belongs in
|
|
23
|
+
* packages/session-agent/Dockerfile (left untouched here): until then a Pod
|
|
24
|
+
* restart loses globally-installed plugins and `remote plugin sync` must be
|
|
25
|
+
* re-run.
|
|
26
|
+
*
|
|
27
|
+
* DRIFT (remote supervise slice 3, ADDITIVE): a desired-state manifest
|
|
28
|
+
* (plugin-manifest.ts) + a sidecar pushed to each Pod (~/.remote-manifest.json /
|
|
29
|
+
* ~/.remote-manifest.sha256, gated EXACTLY like CREDS_HASH_FILE), a read-only
|
|
30
|
+
* `plugin sync --check` drift report, and a watch-pass reconcile that re-runs
|
|
31
|
+
* the EXISTING buildPodSyncScript on a hash mismatch (an extra TRIGGER, not a
|
|
32
|
+
* new push path). The plain `plugin add`/`plugin sync` push is UNCHANGED.
|
|
33
|
+
*
|
|
34
|
+
* TODO(slice 4 — converge-on-session-start, the BIGGEST remaining drift source):
|
|
35
|
+
* a freshly (re)created Pod boots with NO plugins until the next sync/watch pass,
|
|
36
|
+
* so its CLI launches WITHOUT track/h2a/etc. Closing that needs the session-agent
|
|
37
|
+
* startup path (packages/session-agent, the pane that launches `<cli>`) to run
|
|
38
|
+
* the sync + write the manifest sidecar BEFORE exec'ing the CLI — OUT OF SCOPE
|
|
39
|
+
* here (do not touch session-agent this slice). The watch-pass reconcile is the
|
|
40
|
+
* interim safety net.
|
|
41
|
+
*/
|
|
42
|
+
import { spawnSync } from "node:child_process";
|
|
43
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync, } from "node:fs";
|
|
44
|
+
import { homedir } from "node:os";
|
|
45
|
+
import { join } from "node:path";
|
|
46
|
+
import { listRemoteSessions } from "./attach.js";
|
|
47
|
+
import { getPlugins, getTunnel, setPlugins, } from "./config.js";
|
|
48
|
+
import { canonicalManifestJson, checkExitCode, diffManifest, isDrift, manifestHash, parseNpmLsVersions, parseRegisteredMcpServers, renderManifest, } from "./plugin-manifest.js";
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
// Pure helpers (exported for tests)
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
/** MCP server names end up as TOML bare keys and shell words: keep them tame. */
|
|
53
|
+
const SAFE_NAME = /^[A-Za-z0-9_-]+$/;
|
|
54
|
+
/** npm package name, optionally scoped. */
|
|
55
|
+
const SAFE_PKG = /^(@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/;
|
|
56
|
+
/** Version as installed (1.2.3, 1.2.3-rc.1+build, …). */
|
|
57
|
+
const SAFE_VERSION = /^[0-9A-Za-z.+-]+$/;
|
|
58
|
+
/** Package-relative script path embedded in the Pod sync script. */
|
|
59
|
+
const SAFE_REL = /^[A-Za-z0-9_@./-]+$/;
|
|
60
|
+
function assertSafeName(name) {
|
|
61
|
+
if (!SAFE_NAME.test(name)) {
|
|
62
|
+
throw new Error(`invalid MCP server name "${name}" (allowed: letters, digits, "_", "-")`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
function assertSafePkg(pkg) {
|
|
66
|
+
if (!SAFE_PKG.test(pkg))
|
|
67
|
+
throw new Error(`invalid npm package name "${pkg}"`);
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* The shell install command for a plugin + a one-line label. Pure, exported for
|
|
71
|
+
* tests. npm (default): `npm install -g <pkg>@<ver>`. curl: pipe an https
|
|
72
|
+
* installer (`curl -fsSL <url> | bash`) — the url is single-quoted and must be
|
|
73
|
+
* a clean https URL. script: an arbitrary shell line straight from the user's
|
|
74
|
+
* own config (no extra guard — it is the user's command). Same command runs
|
|
75
|
+
* locally (pluginAdd) and in the Pod (buildPodSyncScript).
|
|
76
|
+
*/
|
|
77
|
+
export function buildInstallCommand(plugin) {
|
|
78
|
+
const method = plugin.install?.method ?? "npm";
|
|
79
|
+
if (method === "npm") {
|
|
80
|
+
assertSafePkg(plugin.pkg);
|
|
81
|
+
assertSafeVersion(plugin.version);
|
|
82
|
+
const spec = `${plugin.pkg}@${plugin.version}`;
|
|
83
|
+
return { cmd: `npm install -g '${spec}'`, label: `installed ${spec}` };
|
|
84
|
+
}
|
|
85
|
+
if (method === "curl") {
|
|
86
|
+
const url = plugin.install?.spec ?? "";
|
|
87
|
+
if (!/^https:\/\/[^'"\s]+$/.test(url)) {
|
|
88
|
+
throw new Error(`invalid curl install url "${url}" (need a clean https URL)`);
|
|
89
|
+
}
|
|
90
|
+
return {
|
|
91
|
+
cmd: `curl -fsSL '${url}' | bash`,
|
|
92
|
+
label: `installed ${plugin.pkg} (curl)`,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
// script: the user's own shell command, run verbatim.
|
|
96
|
+
const sh = (plugin.install?.spec ?? "").trim();
|
|
97
|
+
if (!sh)
|
|
98
|
+
throw new Error(`empty script install for "${plugin.pkg}"`);
|
|
99
|
+
return { cmd: sh, label: `installed ${plugin.pkg} (script)` };
|
|
100
|
+
}
|
|
101
|
+
function assertSafeVersion(version) {
|
|
102
|
+
if (!SAFE_VERSION.test(version))
|
|
103
|
+
throw new Error(`invalid version "${version}"`);
|
|
104
|
+
}
|
|
105
|
+
function assertSafeRel(rel) {
|
|
106
|
+
if (!SAFE_REL.test(rel) || rel.split("/").includes("..")) {
|
|
107
|
+
throw new Error(`invalid package-relative script path "${rel}"`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
/** Split `pkg[@version]` (scope-aware: the leading @ is not a version sep). */
|
|
111
|
+
export function splitNpmSpec(spec) {
|
|
112
|
+
const at = spec.indexOf("@", 1);
|
|
113
|
+
if (at === -1)
|
|
114
|
+
return { pkg: spec };
|
|
115
|
+
return { pkg: spec.slice(0, at), version: spec.slice(at + 1) };
|
|
116
|
+
}
|
|
117
|
+
/** Parse one `--mcp <name>=<bin>` spec. */
|
|
118
|
+
export function parseMcpSpec(spec) {
|
|
119
|
+
const eq = spec.indexOf("=");
|
|
120
|
+
if (eq <= 0 || eq === spec.length - 1) {
|
|
121
|
+
throw new Error(`invalid --mcp "${spec}" — expected <name>=<bin> (e.g. track=track-mcp)`);
|
|
122
|
+
}
|
|
123
|
+
const name = spec.slice(0, eq).trim();
|
|
124
|
+
const bin = spec.slice(eq + 1).trim();
|
|
125
|
+
assertSafeName(name);
|
|
126
|
+
if (!bin)
|
|
127
|
+
throw new Error(`invalid --mcp "${spec}" — empty bin`);
|
|
128
|
+
return { name, bin };
|
|
129
|
+
}
|
|
130
|
+
export function parseMcpSpecs(specs) {
|
|
131
|
+
return specs.map(parseMcpSpec);
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Heuristic when no --mcp is given: every bin ending in `-mcp` is an MCP
|
|
135
|
+
* server named after the bin minus the suffix (track-mcp → track).
|
|
136
|
+
*/
|
|
137
|
+
export function detectMcpBins(bins) {
|
|
138
|
+
const requests = [];
|
|
139
|
+
for (const bin of Object.keys(bins).sort()) {
|
|
140
|
+
if (!bin.endsWith("-mcp"))
|
|
141
|
+
continue;
|
|
142
|
+
const name = bin.slice(0, -"-mcp".length);
|
|
143
|
+
if (!name || !SAFE_NAME.test(name))
|
|
144
|
+
continue;
|
|
145
|
+
requests.push({ name, bin });
|
|
146
|
+
}
|
|
147
|
+
return requests;
|
|
148
|
+
}
|
|
149
|
+
/** Normalize a package.json `bin` field (string or map) to name -> rel path. */
|
|
150
|
+
export function normalizeBins(pkgName, bin) {
|
|
151
|
+
if (typeof bin === "string") {
|
|
152
|
+
const name = pkgName.split("/").pop() ?? pkgName;
|
|
153
|
+
return { [name]: bin };
|
|
154
|
+
}
|
|
155
|
+
if (bin && typeof bin === "object") {
|
|
156
|
+
const out = {};
|
|
157
|
+
for (const [name, rel] of Object.entries(bin)) {
|
|
158
|
+
if (typeof rel === "string")
|
|
159
|
+
out[name] = rel;
|
|
160
|
+
}
|
|
161
|
+
return out;
|
|
162
|
+
}
|
|
163
|
+
return {};
|
|
164
|
+
}
|
|
165
|
+
/** The `[mcp_servers.<name>]` TOML section for ~/.codex/config.toml. */
|
|
166
|
+
export function codexMcpServerBlock(name, command, args) {
|
|
167
|
+
assertSafeName(name);
|
|
168
|
+
return [
|
|
169
|
+
`[mcp_servers.${name}]`,
|
|
170
|
+
`command = ${JSON.stringify(command)}`,
|
|
171
|
+
`args = [${args.map((a) => JSON.stringify(a)).join(", ")}]`,
|
|
172
|
+
].join("\n");
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Idempotently upsert the `[mcp_servers.<name>]` section in a config.toml
|
|
176
|
+
* body: an existing section (up to the next `[…]` header) is replaced in
|
|
177
|
+
* place, otherwise the block is appended. Applying twice is a no-op.
|
|
178
|
+
*/
|
|
179
|
+
export function upsertCodexMcpServer(toml, name, command, args) {
|
|
180
|
+
const block = codexMcpServerBlock(name, command, args); // validates the name
|
|
181
|
+
// SAFE_NAME chars ([A-Za-z0-9_-]) are all regex-literal outside classes.
|
|
182
|
+
const headerRe = new RegExp(`^\\s*\\[mcp_servers\\.${name}\\]\\s*$`);
|
|
183
|
+
const lines = toml.split("\n");
|
|
184
|
+
const start = lines.findIndex((line) => headerRe.test(line));
|
|
185
|
+
if (start === -1) {
|
|
186
|
+
const body = toml.replace(/\n+$/, "");
|
|
187
|
+
return (body ? `${body}\n\n` : "") + block + "\n";
|
|
188
|
+
}
|
|
189
|
+
let end = start + 1;
|
|
190
|
+
while (end < lines.length && !/^\s*\[/.test(lines[end]))
|
|
191
|
+
end++;
|
|
192
|
+
const replacement = block.split("\n");
|
|
193
|
+
if (end < lines.length)
|
|
194
|
+
replacement.push("");
|
|
195
|
+
const next = [
|
|
196
|
+
...lines.slice(0, start),
|
|
197
|
+
...replacement,
|
|
198
|
+
...lines.slice(end),
|
|
199
|
+
].join("\n");
|
|
200
|
+
// A section at EOF swallows the final-newline "" line element — restore it.
|
|
201
|
+
return end >= lines.length && toml.endsWith("\n") && !next.endsWith("\n")
|
|
202
|
+
? next + "\n"
|
|
203
|
+
: next;
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Idempotently merge `mcpServers.<name>` into a JSON config body (the shape
|
|
207
|
+
* shared by ~/.claude.json and agy's mcp_config.json). Empty input starts a
|
|
208
|
+
* fresh object; invalid JSON throws (never clobber the user's state). All
|
|
209
|
+
* sibling keys are preserved.
|
|
210
|
+
*/
|
|
211
|
+
function mergeMcpServersJson(json, name, command, args, label) {
|
|
212
|
+
assertSafeName(name);
|
|
213
|
+
let root = {};
|
|
214
|
+
if (json.trim()) {
|
|
215
|
+
const parsed = JSON.parse(json); // throws on corrupt input — on purpose
|
|
216
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
217
|
+
throw new Error(`${label} root is not an object — not touching it`);
|
|
218
|
+
}
|
|
219
|
+
root = parsed;
|
|
220
|
+
}
|
|
221
|
+
const existing = root.mcpServers;
|
|
222
|
+
const servers = existing && typeof existing === "object" && !Array.isArray(existing)
|
|
223
|
+
? existing
|
|
224
|
+
: {};
|
|
225
|
+
servers[name] = { command, args: [...args] };
|
|
226
|
+
root.mcpServers = servers;
|
|
227
|
+
return JSON.stringify(root, null, 2) + "\n";
|
|
228
|
+
}
|
|
229
|
+
/** Idempotent `mcpServers.<name>` merge for a ~/.claude.json body. */
|
|
230
|
+
export function mergeClaudeMcpServers(json, name, command, args) {
|
|
231
|
+
return mergeMcpServersJson(json, name, command, args, "claude.json");
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Idempotent `mcpServers.<name>` merge for agy's
|
|
235
|
+
* ~/.gemini/config/mcp_config.json (same Claude-style shape — the agy binary
|
|
236
|
+
* schema accepts command/args/env/url per server; we only emit command+args).
|
|
237
|
+
*/
|
|
238
|
+
export function mergeAgyMcpServers(json, name, command, args) {
|
|
239
|
+
return mergeMcpServersJson(json, name, command, args, "mcp_config.json");
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* Pure write plan for agy's mcp_config.json: the merged body, whether it
|
|
243
|
+
* differs from `before`, and whether a `.bak.<epoch>` backup is owed (only on
|
|
244
|
+
* the FIRST real modification of a non-empty file — an empty/absent file has
|
|
245
|
+
* nothing worth backing up, and an unchanged file is never rewritten).
|
|
246
|
+
*/
|
|
247
|
+
export function planAgyMcpConfigUpdate(before, name, command, args) {
|
|
248
|
+
const next = mergeAgyMcpServers(before, name, command, args);
|
|
249
|
+
const changed = next !== before;
|
|
250
|
+
return { next, changed, needsBackup: changed && before.trim().length > 0 };
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* In-Pod ~/.claude.json merge, run as `node -e '<this>' <name> <scriptPath>`.
|
|
254
|
+
* Double quotes only — the snippet is single-quoted inside the bash script.
|
|
255
|
+
*/
|
|
256
|
+
export const POD_CLAUDE_MERGE_JS = 'const fs=require("fs");const p=process.env.HOME+"/.claude.json";' +
|
|
257
|
+
'let j={};try{const t=fs.readFileSync(p,"utf8");if(t.trim())j=JSON.parse(t)}' +
|
|
258
|
+
'catch(e){if(e.code!=="ENOENT")throw e}' +
|
|
259
|
+
'if(typeof j!=="object"||j===null||Array.isArray(j))j={};' +
|
|
260
|
+
'const s=j.mcpServers&&typeof j.mcpServers==="object"&&!Array.isArray(j.mcpServers)?j.mcpServers:{};' +
|
|
261
|
+
's[process.argv[1]]={command:"node",args:[process.argv[2]]};j.mcpServers=s;' +
|
|
262
|
+
'fs.writeFileSync(p,JSON.stringify(j,null,2)+"\\n");';
|
|
263
|
+
/**
|
|
264
|
+
* In-Pod ~/.gemini/config/mcp_config.json merge, run as
|
|
265
|
+
* `node -e '<this>' <name> <scriptPath>`. Double quotes only — the snippet is
|
|
266
|
+
* single-quoted inside the bash script. Same shape as the claude merge; the
|
|
267
|
+
* config dir may not exist yet in a fresh Pod, hence mkdirSync.
|
|
268
|
+
*/
|
|
269
|
+
export const POD_AGY_MERGE_JS = 'const fs=require("fs");const d=process.env.HOME+"/.gemini/config";' +
|
|
270
|
+
"fs.mkdirSync(d,{recursive:true});" +
|
|
271
|
+
'const p=d+"/mcp_config.json";' +
|
|
272
|
+
'let j={};try{const t=fs.readFileSync(p,"utf8");if(t.trim())j=JSON.parse(t)}' +
|
|
273
|
+
'catch(e){if(e.code!=="ENOENT")throw e}' +
|
|
274
|
+
'if(typeof j!=="object"||j===null||Array.isArray(j))j={};' +
|
|
275
|
+
'const s=j.mcpServers&&typeof j.mcpServers==="object"&&!Array.isArray(j.mcpServers)?j.mcpServers:{};' +
|
|
276
|
+
's[process.argv[1]]={command:"node",args:[process.argv[2]]};j.mcpServers=s;' +
|
|
277
|
+
'fs.writeFileSync(p,JSON.stringify(j,null,2)+"\\n");';
|
|
278
|
+
/** claude / claude-code → claude; codex → codex; agy / antigravity → agy. */
|
|
279
|
+
export function mcpTargetForProfile(profile) {
|
|
280
|
+
if (profile === "claude" || profile === "claude-code")
|
|
281
|
+
return "claude";
|
|
282
|
+
if (profile === "codex")
|
|
283
|
+
return "codex";
|
|
284
|
+
if (profile === "agy" || profile === "antigravity")
|
|
285
|
+
return "agy";
|
|
286
|
+
return "todo";
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* Bash script run inside a session Pod (`kubectl exec … bash -lc`) by
|
|
290
|
+
* `remote plugin sync`: installs the plugin globally, recomputes each MCP
|
|
291
|
+
* script's realpath against the POD's npm global root (the local realpath is
|
|
292
|
+
* meaningless there), then registers the MCP server for the Pod's profile.
|
|
293
|
+
* Every step echoes one line — the CLI prints them as the per-session recap.
|
|
294
|
+
*/
|
|
295
|
+
export function buildPodSyncScript(plugin, profile) {
|
|
296
|
+
const { cmd, label } = buildInstallCommand(plugin);
|
|
297
|
+
const lines = [
|
|
298
|
+
"set -e",
|
|
299
|
+
`${cmd} >/dev/null 2>&1`,
|
|
300
|
+
`echo "${label}"`,
|
|
301
|
+
`ROOT="$(npm root -g)"`,
|
|
302
|
+
];
|
|
303
|
+
const target = mcpTargetForProfile(profile);
|
|
304
|
+
for (const mcp of plugin.mcp) {
|
|
305
|
+
assertSafeName(mcp.name);
|
|
306
|
+
if (!mcp.scriptRel) {
|
|
307
|
+
lines.push(`echo "mcp ${mcp.name}: no scriptRel recorded — re-run: remote plugin add ${plugin.pkg}"`);
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
assertSafeRel(mcp.scriptRel);
|
|
311
|
+
if (target === "todo") {
|
|
312
|
+
lines.push(`echo "mcp ${mcp.name}: profile ${profile} TODO non câblé (installed only)"`);
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
// realpath INSIDE the Pod (same symlink pitfall as locally — see header).
|
|
316
|
+
lines.push(`REAL="$(node -p 'require("fs").realpathSync(process.argv[1])' "$ROOT/${plugin.pkg}/${mcp.scriptRel}")"`);
|
|
317
|
+
if (target === "claude") {
|
|
318
|
+
lines.push(`node -e '${POD_CLAUDE_MERGE_JS}' '${mcp.name}' "$REAL"`, `echo "mcp ${mcp.name} -> claude.json (node $REAL)"`);
|
|
319
|
+
}
|
|
320
|
+
else if (target === "agy") {
|
|
321
|
+
lines.push(`node -e '${POD_AGY_MERGE_JS}' '${mcp.name}' "$REAL"`, `echo "mcp ${mcp.name} -> agy mcp_config.json (node $REAL)"`);
|
|
322
|
+
}
|
|
323
|
+
else {
|
|
324
|
+
lines.push(`mkdir -p "$HOME/.codex"`, `touch "$HOME/.codex/config.toml"`, `if grep -q "^\\[mcp_servers\\.${mcp.name}\\]" "$HOME/.codex/config.toml"; then ` +
|
|
325
|
+
`echo "mcp ${mcp.name} already in codex config.toml"; else ` +
|
|
326
|
+
`printf '\\n[mcp_servers.${mcp.name}]\\ncommand = "node"\\nargs = ["%s"]\\n' "$REAL" >> "$HOME/.codex/config.toml"; ` +
|
|
327
|
+
`echo "mcp ${mcp.name} -> codex config.toml (node $REAL)"; fi`);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
return lines.join("\n");
|
|
331
|
+
}
|
|
332
|
+
// ---------------------------------------------------------------------------
|
|
333
|
+
// Local side effects (npm, claude/codex registration)
|
|
334
|
+
// ---------------------------------------------------------------------------
|
|
335
|
+
function npmGlobalRoot() {
|
|
336
|
+
const r = spawnSync("npm", ["root", "-g"], { encoding: "utf8" });
|
|
337
|
+
if (r.status !== 0) {
|
|
338
|
+
throw new Error(`npm root -g failed: ${(r.stderr || "").trim()}`);
|
|
339
|
+
}
|
|
340
|
+
return r.stdout.trim();
|
|
341
|
+
}
|
|
342
|
+
function commandExists(cmd) {
|
|
343
|
+
const r = spawnSync("bash", ["-lc", `command -v ${cmd}`], {
|
|
344
|
+
stdio: "ignore",
|
|
345
|
+
});
|
|
346
|
+
return r.status === 0;
|
|
347
|
+
}
|
|
348
|
+
function installGlobally(spec, stderr) {
|
|
349
|
+
const r = spawnSync("npm", ["install", "-g", spec], { encoding: "utf8" });
|
|
350
|
+
if (r.status !== 0) {
|
|
351
|
+
const tail = (r.stderr || r.stdout || "")
|
|
352
|
+
.trim()
|
|
353
|
+
.split("\n")
|
|
354
|
+
.slice(-3)
|
|
355
|
+
.join(" | ");
|
|
356
|
+
throw new Error(`npm install -g ${spec} failed: ${tail}`);
|
|
357
|
+
}
|
|
358
|
+
// stdio summary: npm's "added N packages in Xs" line, not the full wall.
|
|
359
|
+
const summary = (r.stdout || "")
|
|
360
|
+
.split("\n")
|
|
361
|
+
.map((line) => line.trim())
|
|
362
|
+
.find((line) => /^(added|changed|removed|up to date)/.test(line)) ??
|
|
363
|
+
"installed";
|
|
364
|
+
stderr.write(`[remote] npm i -g ${spec}: ${summary}\n`);
|
|
365
|
+
}
|
|
366
|
+
function readFileIfExists(path) {
|
|
367
|
+
try {
|
|
368
|
+
return readFileSync(path, "utf8");
|
|
369
|
+
}
|
|
370
|
+
catch (error) {
|
|
371
|
+
if (error.code === "ENOENT")
|
|
372
|
+
return "";
|
|
373
|
+
throw error;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
/**
|
|
377
|
+
* Register an MCP server with claude: `claude mcp add --scope user` when the
|
|
378
|
+
* CLI is available (remove-first for idempotence — add errors on an existing
|
|
379
|
+
* name), else merge ~/.claude.json mcpServers directly.
|
|
380
|
+
*/
|
|
381
|
+
function registerClaudeLocal(name, scriptPath, stderr) {
|
|
382
|
+
if (commandExists("claude")) {
|
|
383
|
+
spawnSync("claude", ["mcp", "remove", "--scope", "user", name], {
|
|
384
|
+
stdio: "ignore",
|
|
385
|
+
});
|
|
386
|
+
const r = spawnSync("claude", ["mcp", "add", "--scope", "user", name, "--", "node", scriptPath], { encoding: "utf8" });
|
|
387
|
+
if (r.status !== 0) {
|
|
388
|
+
throw new Error(`claude mcp add ${name} failed: ${(r.stderr || r.stdout || "").trim()}`);
|
|
389
|
+
}
|
|
390
|
+
stderr.write(`[remote] claude: MCP ${name} -> node ${scriptPath} (scope user)\n`);
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
const path = join(homedir(), ".claude.json");
|
|
394
|
+
writeFileSync(path, mergeClaudeMcpServers(readFileIfExists(path), name, "node", [scriptPath]), "utf8");
|
|
395
|
+
stderr.write(`[remote] claude: MCP ${name} merged into ~/.claude.json (claude CLI not found)\n`);
|
|
396
|
+
}
|
|
397
|
+
/**
|
|
398
|
+
* Idempotent `mcpServers.<name>` merge into ~/.gemini/config/mcp_config.json
|
|
399
|
+
* (agy has no `mcp add` CLI command — file merge is the only mechanism). The
|
|
400
|
+
* first time a non-empty file is actually modified we keep a `.bak.<epoch>`
|
|
401
|
+
* sibling; an unchanged file is never rewritten.
|
|
402
|
+
*/
|
|
403
|
+
function registerAgyLocal(name, scriptPath, stderr) {
|
|
404
|
+
const dir = join(homedir(), ".gemini", "config");
|
|
405
|
+
mkdirSync(dir, { recursive: true });
|
|
406
|
+
const path = join(dir, "mcp_config.json");
|
|
407
|
+
const before = readFileIfExists(path);
|
|
408
|
+
const { next, changed, needsBackup } = planAgyMcpConfigUpdate(before, name, "node", [scriptPath]);
|
|
409
|
+
if (!changed) {
|
|
410
|
+
stderr.write(`[remote] agy: MCP ${name} already in ${path} (unchanged)\n`);
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
if (needsBackup) {
|
|
414
|
+
const backup = `${path}.bak.${Date.now()}`;
|
|
415
|
+
writeFileSync(backup, before, "utf8");
|
|
416
|
+
stderr.write(`[remote] agy: backed up ${path} -> ${backup}\n`);
|
|
417
|
+
}
|
|
418
|
+
writeFileSync(path, next, "utf8");
|
|
419
|
+
stderr.write(`[remote] agy: MCP ${name} -> node ${scriptPath} in ~/.gemini/config/mcp_config.json\n`);
|
|
420
|
+
}
|
|
421
|
+
/** Idempotent `[mcp_servers.<name>]` upsert in ~/.codex/config.toml. */
|
|
422
|
+
function registerCodexLocal(name, scriptPath, stderr) {
|
|
423
|
+
const dir = join(homedir(), ".codex");
|
|
424
|
+
mkdirSync(dir, { recursive: true });
|
|
425
|
+
const path = join(dir, "config.toml");
|
|
426
|
+
writeFileSync(path, upsertCodexMcpServer(readFileIfExists(path), name, "node", [scriptPath]), "utf8");
|
|
427
|
+
stderr.write(`[remote] codex: [mcp_servers.${name}] -> node ${scriptPath} in ~/.codex/config.toml\n`);
|
|
428
|
+
}
|
|
429
|
+
// ---------------------------------------------------------------------------
|
|
430
|
+
// kubectl exec into session Pods (pattern: soft-refresh.ts)
|
|
431
|
+
// ---------------------------------------------------------------------------
|
|
432
|
+
function expandHome(p) {
|
|
433
|
+
return p.startsWith("~") ? join(homedir(), p.slice(1)) : p;
|
|
434
|
+
}
|
|
435
|
+
function kubeEnv(tunnel) {
|
|
436
|
+
const env = { ...process.env };
|
|
437
|
+
if (tunnel.kubeconfig)
|
|
438
|
+
env.KUBECONFIG = expandHome(tunnel.kubeconfig);
|
|
439
|
+
return env;
|
|
440
|
+
}
|
|
441
|
+
/** Exec a bash -lc script in the session-agent container. Throws on non-zero. */
|
|
442
|
+
function execPod(tunnel, pod, script, input) {
|
|
443
|
+
const base = ["-n", tunnel.namespace, "exec"];
|
|
444
|
+
if (input !== undefined)
|
|
445
|
+
base.push("-i");
|
|
446
|
+
const r = spawnSync("kubectl", [...base, pod, "-c", "session-agent", "--", "bash", "-lc", script], {
|
|
447
|
+
encoding: "utf8",
|
|
448
|
+
env: kubeEnv(tunnel),
|
|
449
|
+
...(input !== undefined ? { input } : {}),
|
|
450
|
+
});
|
|
451
|
+
if (r.status !== 0) {
|
|
452
|
+
throw new Error(`kubectl exec ${pod} failed: ${(r.stderr || r.stdout || "").trim()}`);
|
|
453
|
+
}
|
|
454
|
+
return r.stdout;
|
|
455
|
+
}
|
|
456
|
+
/**
|
|
457
|
+
* Exec an ARGV directly in the session-agent container — NO `bash -lc`
|
|
458
|
+
* string-concat (mirrors soft-refresh.ts execPodArgv, the slice-2 argv-safe pod
|
|
459
|
+
* exec). NEVER throws: a non-zero exit is the SIGNAL (e.g. `npm ls -g` exits
|
|
460
|
+
* non-zero on extraneous/missing deps but still prints the JSON we parse).
|
|
461
|
+
* Returns the exit code + stdout. Used by the read-only drift probe.
|
|
462
|
+
*/
|
|
463
|
+
function execPodArgv(tunnel, pod, argv) {
|
|
464
|
+
const r = spawnSync("kubectl", ["-n", tunnel.namespace, "exec", pod, "-c", "session-agent", "--", ...argv], { encoding: "utf8", env: kubeEnv(tunnel) });
|
|
465
|
+
return { status: r.status ?? 1, stdout: r.stdout ?? "" };
|
|
466
|
+
}
|
|
467
|
+
// ---------------------------------------------------------------------------
|
|
468
|
+
// Slice 3 — desired-state manifest sidecar + drift probe + reconcile (ADDITIVE).
|
|
469
|
+
//
|
|
470
|
+
// A NEW sidecar artifact, gated EXACTLY like CREDS_HASH_FILE (skip the write
|
|
471
|
+
// when the Pod's recorded hash already matches). The reconcile TRIGGER re-runs
|
|
472
|
+
// the EXISTING buildPodSyncScript — it is NOT a new push path. The probe is
|
|
473
|
+
// read-only (argv-safe execPodArgv). The plain `plugin sync`/`plugin add` push
|
|
474
|
+
// below is untouched.
|
|
475
|
+
// ---------------------------------------------------------------------------
|
|
476
|
+
/** HOME-relative manifest sidecar in the Pod (the desired-state JSON). */
|
|
477
|
+
export const MANIFEST_FILE = ".remote-manifest.json";
|
|
478
|
+
/** HOME-relative file recording the sha256 of the last pushed manifest. */
|
|
479
|
+
export const MANIFEST_HASH_FILE = ".remote-manifest.sha256";
|
|
480
|
+
/** Render the desired-state manifest for the configured plugins (no tracked skills today). */
|
|
481
|
+
export function desiredManifest() {
|
|
482
|
+
return renderManifest(getPlugins());
|
|
483
|
+
}
|
|
484
|
+
/** Production wiring of `ManifestPodIo` against a live Pod (real kubectl exec). */
|
|
485
|
+
export function manifestPodIo(tunnel, pod, profile) {
|
|
486
|
+
return {
|
|
487
|
+
readHash: () => {
|
|
488
|
+
try {
|
|
489
|
+
return execPod(tunnel, pod, `cat "$HOME/${MANIFEST_HASH_FILE}" 2>/dev/null || true`).trim();
|
|
490
|
+
}
|
|
491
|
+
catch {
|
|
492
|
+
return "";
|
|
493
|
+
}
|
|
494
|
+
},
|
|
495
|
+
writeSidecar: (json, hash) => {
|
|
496
|
+
// Write the canonical JSON via stdin (argv-safe: no manifest content in
|
|
497
|
+
// the command string) then record the hash. SAFE_PKG/SAFE_NAME bound the
|
|
498
|
+
// JSON to tame chars; it still rides stdin so nothing is shell-interpolated.
|
|
499
|
+
execPod(tunnel, pod, `cat > "$HOME/${MANIFEST_FILE}"`, json);
|
|
500
|
+
execPod(tunnel, pod, `printf %s '${hash}' > "$HOME/${MANIFEST_HASH_FILE}"`);
|
|
501
|
+
},
|
|
502
|
+
runSync: (plugin) => execPod(tunnel, pod, buildPodSyncScript(plugin, profile)),
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
/**
|
|
506
|
+
* Push the desired-state manifest into a Pod as ~/.remote-manifest.json +
|
|
507
|
+
* ~/.remote-manifest.sha256, GATED exactly like CREDS_HASH_FILE: read the Pod's
|
|
508
|
+
* recorded hash first and SKIP the write when it already matches (so a steady
|
|
509
|
+
* Pod is a single `cat`, no churn). Mirrors soft-refresh's hash-sidecar pattern.
|
|
510
|
+
* Returns whether the sidecar was (re)written. Best-effort: caller logs errors.
|
|
511
|
+
*/
|
|
512
|
+
export function pushManifestSidecar(tunnel, pod, manifest) {
|
|
513
|
+
return pushManifestSidecarVia(manifestPodIo(tunnel, pod, ""), manifest);
|
|
514
|
+
}
|
|
515
|
+
/** Sidecar push over an injected `ManifestPodIo` (the testable core). PURE-ish. */
|
|
516
|
+
export function pushManifestSidecarVia(io, manifest) {
|
|
517
|
+
const hash = manifestHash(manifest);
|
|
518
|
+
let podHash = "";
|
|
519
|
+
try {
|
|
520
|
+
podHash = io.readHash();
|
|
521
|
+
}
|
|
522
|
+
catch {
|
|
523
|
+
// unreadable — fall through and (re)write; the write surfaces real errors.
|
|
524
|
+
}
|
|
525
|
+
if (podHash === hash)
|
|
526
|
+
return { wrote: false, hash };
|
|
527
|
+
io.writeSidecar(canonicalManifestJson(manifest), hash);
|
|
528
|
+
return { wrote: true, hash };
|
|
529
|
+
}
|
|
530
|
+
/** The Pod's recorded manifest hash, or "" when absent/unreadable. */
|
|
531
|
+
export function readPodManifestHash(tunnel, pod) {
|
|
532
|
+
return manifestPodIo(tunnel, pod, "").readHash();
|
|
533
|
+
}
|
|
534
|
+
/**
|
|
535
|
+
* Probe ONE Pod's actual plugin/MCP state into a `PodPluginState` (pure shape):
|
|
536
|
+
* installed versions for the desired pkgs (`npm ls -g --json` → parsed) + the
|
|
537
|
+
* registered MCP server names (claude/agy JSON + codex TOML → parsed). PURE-ish:
|
|
538
|
+
* all IO is injected via `deps` (tests never shell out). Exported for tests.
|
|
539
|
+
*/
|
|
540
|
+
export function probePodPluginState(desiredPkgs, deps) {
|
|
541
|
+
const pluginVersions = parseNpmLsVersions(deps.npmLs(desiredPkgs), desiredPkgs);
|
|
542
|
+
const { json, toml } = deps.readMcpConfigs();
|
|
543
|
+
const mcpRegistered = parseRegisteredMcpServers({ json, toml });
|
|
544
|
+
return { pluginVersions, mcpRegistered };
|
|
545
|
+
}
|
|
546
|
+
/** Production wiring of `PodProbeDeps` against a live Pod (real kubectl exec). */
|
|
547
|
+
function podProbeDeps(tunnel, pod) {
|
|
548
|
+
return {
|
|
549
|
+
npmLs: (pkgs) =>
|
|
550
|
+
// `npm ls -g --json <pkg…>`: argv-safe (pkgs are SAFE_PKG-validated by the
|
|
551
|
+
// caller). npm exits non-zero on extraneous deps but still prints the JSON.
|
|
552
|
+
execPodArgv(tunnel, pod, [
|
|
553
|
+
"npm",
|
|
554
|
+
"ls",
|
|
555
|
+
"-g",
|
|
556
|
+
"--json",
|
|
557
|
+
"--depth=0",
|
|
558
|
+
...pkgs,
|
|
559
|
+
]).stdout,
|
|
560
|
+
readMcpConfigs: () => {
|
|
561
|
+
// One bash read of the three config bodies, each delimited by a marker so
|
|
562
|
+
// we can split them back apart. Static markers — no untrusted interpolation.
|
|
563
|
+
const out = execPod(tunnel, pod, [
|
|
564
|
+
'echo "===CLAUDE==="',
|
|
565
|
+
'cat "$HOME/.claude.json" 2>/dev/null || true',
|
|
566
|
+
'echo "===AGY==="',
|
|
567
|
+
'cat "$HOME/.gemini/config/mcp_config.json" 2>/dev/null || true',
|
|
568
|
+
'echo "===CODEX==="',
|
|
569
|
+
'cat "$HOME/.codex/config.toml" 2>/dev/null || true',
|
|
570
|
+
].join("; "));
|
|
571
|
+
const claude = sliceBetween(out, "===CLAUDE===", "===AGY===");
|
|
572
|
+
const agy = sliceBetween(out, "===AGY===", "===CODEX===");
|
|
573
|
+
const toml = sliceBetween(out, "===CODEX===", undefined);
|
|
574
|
+
return { json: [claude, agy], toml };
|
|
575
|
+
},
|
|
576
|
+
};
|
|
577
|
+
}
|
|
578
|
+
/** Extract the text between two markers (start-exclusive, end-exclusive). */
|
|
579
|
+
function sliceBetween(text, start, end) {
|
|
580
|
+
const s = text.indexOf(start);
|
|
581
|
+
if (s === -1)
|
|
582
|
+
return "";
|
|
583
|
+
const from = s + start.length;
|
|
584
|
+
const e = end ? text.indexOf(end, from) : -1;
|
|
585
|
+
return (e === -1 ? text.slice(from) : text.slice(from, e))
|
|
586
|
+
.replace(/^\n/, "")
|
|
587
|
+
.trim();
|
|
588
|
+
}
|
|
589
|
+
/**
|
|
590
|
+
* Watch-pass RECONCILE for ONE live Pod (an EXTRA trigger, NOT a new push path):
|
|
591
|
+
* compare the local desired-state hash with the Pod's recorded
|
|
592
|
+
* ~/.remote-manifest.sha256 (one `cat`); on a MISMATCH, re-run the EXISTING
|
|
593
|
+
* buildPodSyncScript for every plugin (the same mechanism `plugin sync` uses)
|
|
594
|
+
* and re-push the manifest sidecar. A match is a zero-work no-op. Mirrors the
|
|
595
|
+
* slice-2 probe→push advisory structure. Best-effort: a per-plugin failure is
|
|
596
|
+
* logged; the others proceed. Returns what happened. Used by the supervise pass.
|
|
597
|
+
*/
|
|
598
|
+
export function reconcilePodManifest(tunnel, pod, profile, manifest, plugins, stderr = process.stderr) {
|
|
599
|
+
return reconcilePodManifestVia(manifestPodIo(tunnel, pod, profile), pod, manifest, plugins, stderr);
|
|
600
|
+
}
|
|
601
|
+
/**
|
|
602
|
+
* Reconcile core over an injected `ManifestPodIo` (the testable seam). PURE-ish:
|
|
603
|
+
* all Pod IO goes through `io`, so tests drive a stateful fake Pod and assert the
|
|
604
|
+
* gate — a matching hash is a zero-work no-op; an absent/mismatched hash resyncs
|
|
605
|
+
* ONCE then writes the sidecar so the NEXT pass converges (no re-sync).
|
|
606
|
+
*
|
|
607
|
+
* PERSISTENCE FIX (slice-3 regression): the sidecar is written via the SAME `io`
|
|
608
|
+
* (no second pod read, no `pushManifestSidecar` re-gate that could skip it) and
|
|
609
|
+
* is written even when some plugin syncs FAILED, so the Pod's recorded hash
|
|
610
|
+
* always converges to local after a reconcile — otherwise the next pass re-reads
|
|
611
|
+
* an absent hash and re-reports drift forever. (The dead-pod guard upstream keeps
|
|
612
|
+
* us off Evicted Pods where the write would throw and never persist at all.)
|
|
613
|
+
*/
|
|
614
|
+
export function reconcilePodManifestVia(io, pod, manifest, plugins, stderr = process.stderr) {
|
|
615
|
+
const localHash = manifestHash(manifest);
|
|
616
|
+
const podHash = io.readHash();
|
|
617
|
+
if (podHash === localHash) {
|
|
618
|
+
return { reconciled: false, localHash, podHash, failures: 0 };
|
|
619
|
+
}
|
|
620
|
+
// Drift detected (hash only — never a secret). Re-run the EXISTING sync script.
|
|
621
|
+
stderr.write(`[remote] ${pod}: plugin/MCP drift (manifest ${podHash ? "mismatch" : "absent"}) — reconciling via plugin sync\n`);
|
|
622
|
+
let failures = 0;
|
|
623
|
+
for (const plugin of plugins) {
|
|
624
|
+
try {
|
|
625
|
+
const out = io.runSync(plugin);
|
|
626
|
+
for (const line of out.trim().split("\n").filter(Boolean)) {
|
|
627
|
+
stderr.write(` ${line}\n`);
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
catch (error) {
|
|
631
|
+
failures++;
|
|
632
|
+
stderr.write(` ${plugin.pkg}@${plugin.version} reconcile FAILED: ${String(error).slice(0, 200)}\n`);
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
// Re-push the sidecar so the Pod's recorded hash converges to local — through
|
|
636
|
+
// the SAME io and UNCONDITIONALLY (we already know podHash != localHash), so a
|
|
637
|
+
// converged Pod reports NO drift next pass.
|
|
638
|
+
try {
|
|
639
|
+
io.writeSidecar(canonicalManifestJson(manifest), localHash);
|
|
640
|
+
}
|
|
641
|
+
catch (error) {
|
|
642
|
+
stderr.write(` manifest sidecar re-push failed: ${String(error).slice(0, 120)}\n`);
|
|
643
|
+
}
|
|
644
|
+
return { reconciled: failures === 0, localHash, podHash, failures };
|
|
645
|
+
}
|
|
646
|
+
/**
|
|
647
|
+
* Production wiring used by the supervise/watch pass: reconcile manifest drift
|
|
648
|
+
* for ONE session via the configured tunnel. Thin — the decision + the reused
|
|
649
|
+
* buildPodSyncScript live in `reconcilePodManifest`. No-op (and no error) when
|
|
650
|
+
* no plugins are configured. Best-effort; never throws into the caller.
|
|
651
|
+
*/
|
|
652
|
+
export function reconcileSessionPlugins(sessionId, profile, stderr = process.stderr) {
|
|
653
|
+
const plugins = getPlugins();
|
|
654
|
+
if (plugins.length === 0)
|
|
655
|
+
return { reconciled: false };
|
|
656
|
+
const tunnel = getTunnel();
|
|
657
|
+
if (!tunnel)
|
|
658
|
+
return { reconciled: false };
|
|
659
|
+
const pod = `session-${sessionId}`;
|
|
660
|
+
const manifest = renderManifest(plugins);
|
|
661
|
+
const r = reconcilePodManifest(tunnel, pod, profile, manifest, plugins, stderr);
|
|
662
|
+
return { reconciled: r.reconciled };
|
|
663
|
+
}
|
|
664
|
+
// ---------------------------------------------------------------------------
|
|
665
|
+
// Commands
|
|
666
|
+
// ---------------------------------------------------------------------------
|
|
667
|
+
/**
|
|
668
|
+
* `remote plugin add <npmPkg> [--mcp name=bin]...` — npm i -g, register the
|
|
669
|
+
* MCP server(s) with claude + codex + agy, persist in the config.
|
|
670
|
+
*/
|
|
671
|
+
export function pluginAdd(npmSpec, mcpSpecs, stderr = process.stderr) {
|
|
672
|
+
const { pkg, version: requestedVersion } = splitNpmSpec(npmSpec);
|
|
673
|
+
assertSafePkg(pkg);
|
|
674
|
+
if (requestedVersion !== undefined)
|
|
675
|
+
assertSafeVersion(requestedVersion);
|
|
676
|
+
installGlobally(npmSpec, stderr);
|
|
677
|
+
const pkgDir = join(npmGlobalRoot(), pkg);
|
|
678
|
+
const pkgJson = JSON.parse(readFileSync(join(pkgDir, "package.json"), "utf8"));
|
|
679
|
+
const version = pkgJson.version ?? requestedVersion ?? "latest";
|
|
680
|
+
assertSafeVersion(version);
|
|
681
|
+
const bins = normalizeBins(pkg, pkgJson.bin);
|
|
682
|
+
const requests = mcpSpecs.length > 0 ? parseMcpSpecs(mcpSpecs) : detectMcpBins(bins);
|
|
683
|
+
if (requests.length === 0) {
|
|
684
|
+
stderr.write(`[remote] no MCP bin detected for ${pkg} (none ends in -mcp; bins: ${Object.keys(bins).join(", ") || "none"}) — pass --mcp <name>=<bin>\n`);
|
|
685
|
+
}
|
|
686
|
+
const mcp = [];
|
|
687
|
+
for (const { name, bin } of requests) {
|
|
688
|
+
const rel = bins[bin];
|
|
689
|
+
if (!rel) {
|
|
690
|
+
throw new Error(`package ${pkg} has no bin "${bin}" (bins: ${Object.keys(bins).join(", ") || "none"})`);
|
|
691
|
+
}
|
|
692
|
+
// realpathSync: NEVER register the bare bin — the npm-global symlink breaks
|
|
693
|
+
// some packages' entrypoint guard (track@0.2.0). See the module header.
|
|
694
|
+
const scriptPath = realpathSync(join(pkgDir, rel));
|
|
695
|
+
const scriptRel = rel.replace(/^\.\//, "");
|
|
696
|
+
registerClaudeLocal(name, scriptPath, stderr);
|
|
697
|
+
registerCodexLocal(name, scriptPath, stderr);
|
|
698
|
+
registerAgyLocal(name, scriptPath, stderr);
|
|
699
|
+
mcp.push({ name, command: "node", args: [scriptPath], scriptRel });
|
|
700
|
+
}
|
|
701
|
+
const entry = { pkg, version, mcp };
|
|
702
|
+
setPlugins([...getPlugins().filter((p) => p.pkg !== pkg), entry]);
|
|
703
|
+
stderr.write(`[remote] plugin ${pkg}@${version} installed — ${mcp.length} MCP server(s) registered; persisted in remote config\n`);
|
|
704
|
+
stderr.write(`[remote] propagate to live remote sessions with: remote plugin sync\n`);
|
|
705
|
+
return entry;
|
|
706
|
+
}
|
|
707
|
+
/**
|
|
708
|
+
* `remote plugin add <name> --curl <url>` / `--install "<shell>"` — register a
|
|
709
|
+
* NON-npm plugin (a tool installed by piping an https script, or an arbitrary
|
|
710
|
+
* shell command). No local install is run (unlike the npm path, which must read
|
|
711
|
+
* the package to detect MCP bins): the installer runs in each Pod on
|
|
712
|
+
* `remote plugin sync`. Validates the command up front so a bad URL fails now.
|
|
713
|
+
*/
|
|
714
|
+
export function pluginAddInstaller(name, install, stderr = process.stderr) {
|
|
715
|
+
assertSafePkg(name);
|
|
716
|
+
const entry = {
|
|
717
|
+
pkg: name,
|
|
718
|
+
version: "installer",
|
|
719
|
+
mcp: [],
|
|
720
|
+
install,
|
|
721
|
+
};
|
|
722
|
+
buildInstallCommand(entry); // throws on a bad curl url / empty script
|
|
723
|
+
setPlugins([...getPlugins().filter((p) => p.pkg !== name), entry]);
|
|
724
|
+
stderr.write(`[remote] plugin ${name} (${install.method}) persisted — installs in Pods on \`remote plugin sync\`\n`);
|
|
725
|
+
return entry;
|
|
726
|
+
}
|
|
727
|
+
/**
|
|
728
|
+
* `remote plugin ls` — pkg / version / MCPs / where (local ok/missing, remote).
|
|
729
|
+
*
|
|
730
|
+
* The REMOTE column is now REAL per-Pod drift status (slice 3), not the old
|
|
731
|
+
* guess-`?`: when a `url` is passed (the command is connected to the control-
|
|
732
|
+
* plane) we probe each live Pod via the same drift mechanism `--check` uses and
|
|
733
|
+
* summarize each plugin's worst per-Pod status across all Pods
|
|
734
|
+
* (ok / version-drift / missing). Without a url (offline) we still print `?`
|
|
735
|
+
* with the documented hint — the `?` only remains when we genuinely can't reach
|
|
736
|
+
* the cluster, never as guesswork when we can.
|
|
737
|
+
*/
|
|
738
|
+
export async function pluginLs(stdout = process.stdout, url) {
|
|
739
|
+
const plugins = getPlugins();
|
|
740
|
+
if (plugins.length === 0) {
|
|
741
|
+
stdout.write("[remote] no plugins configured (remote plugin add <npmPkg>)\n");
|
|
742
|
+
return;
|
|
743
|
+
}
|
|
744
|
+
let root;
|
|
745
|
+
try {
|
|
746
|
+
root = npmGlobalRoot();
|
|
747
|
+
}
|
|
748
|
+
catch {
|
|
749
|
+
root = undefined;
|
|
750
|
+
}
|
|
751
|
+
// REAL per-Pod status (replaces the `?` guess) when connected: pkg -> worst
|
|
752
|
+
// status across all live Pods. Best-effort — any probe failure falls back to `?`.
|
|
753
|
+
let remoteByPkg;
|
|
754
|
+
const tunnel = getTunnel();
|
|
755
|
+
if (url && tunnel) {
|
|
756
|
+
try {
|
|
757
|
+
const manifest = renderManifest(plugins);
|
|
758
|
+
const desiredPkgs = manifest.plugins.map((p) => p.pkg);
|
|
759
|
+
const sessions = await listRemoteSessions(url);
|
|
760
|
+
const worst = new Map();
|
|
761
|
+
for (const session of sessions) {
|
|
762
|
+
const pod = `session-${session.id}`;
|
|
763
|
+
const state = probePodPluginState(desiredPkgs, podProbeDeps(tunnel, pod));
|
|
764
|
+
for (const r of diffManifest(manifest, pod, state)) {
|
|
765
|
+
if (!r.item.startsWith("plugin:"))
|
|
766
|
+
continue;
|
|
767
|
+
const pkg = r.item.slice("plugin:".length);
|
|
768
|
+
worst.set(pkg, worseStatus(worst.get(pkg), r.status));
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
if (sessions.length > 0)
|
|
772
|
+
remoteByPkg = worst;
|
|
773
|
+
}
|
|
774
|
+
catch {
|
|
775
|
+
remoteByPkg = undefined; // unreachable cluster → fall back to `?`
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
const w = (s, n) => s.padEnd(n);
|
|
779
|
+
stdout.write(`${w("PKG", 28)} ${w("VERSION", 9)} ${w("MCPS", 20)} ${w("LOCAL", 8)} REMOTE\n`);
|
|
780
|
+
for (const p of plugins) {
|
|
781
|
+
const local = root && existsSync(join(root, p.pkg, "package.json")) ? "ok" : "missing";
|
|
782
|
+
const mcps = p.mcp.map((m) => m.name).join(",") || "-";
|
|
783
|
+
const remote = remoteByPkg ? (remoteByPkg.get(p.pkg) ?? "ok") : "?";
|
|
784
|
+
stdout.write(`${w(p.pkg, 28)} ${w(p.version, 9)} ${w(mcps, 20)} ${w(local, 8)} ${remote}\n`);
|
|
785
|
+
}
|
|
786
|
+
stdout.write(remoteByPkg
|
|
787
|
+
? "\n(REMOTE: real per-Pod status, worst across live sessions — `remote plugin sync --check` for the full per-Pod table.)\n"
|
|
788
|
+
: '\n(REMOTE "?": not connected to the control-plane — `remote plugin sync --check` (with the tunnel up) shows real per-Pod status.)\n');
|
|
789
|
+
}
|
|
790
|
+
/** Severity-rank two drift statuses; returns the worse (missing > version-drift > ok). */
|
|
791
|
+
function worseStatus(a, b) {
|
|
792
|
+
const rank = {
|
|
793
|
+
ok: 0,
|
|
794
|
+
"mcp-unregistered": 1,
|
|
795
|
+
"version-drift": 2,
|
|
796
|
+
missing: 3,
|
|
797
|
+
};
|
|
798
|
+
if (a === undefined)
|
|
799
|
+
return b;
|
|
800
|
+
return (rank[b] ?? 0) > (rank[a] ?? 0) ? b : a;
|
|
801
|
+
}
|
|
802
|
+
/**
|
|
803
|
+
* `remote plugin sync` — for every live remote session: kubectl exec into the
|
|
804
|
+
* Pod, `npm i -g <pkg>@<version>`, then register the MCP servers for the
|
|
805
|
+
* Pod's profile. Prints a per-session recap. Needs the configured tunnel.
|
|
806
|
+
*/
|
|
807
|
+
export async function pluginSync(url, stderr = process.stderr) {
|
|
808
|
+
const plugins = getPlugins();
|
|
809
|
+
if (plugins.length === 0) {
|
|
810
|
+
stderr.write("[remote] no plugins configured (remote plugin add <npmPkg>)\n");
|
|
811
|
+
return;
|
|
812
|
+
}
|
|
813
|
+
const tunnel = getTunnel();
|
|
814
|
+
if (!tunnel) {
|
|
815
|
+
throw new Error("plugin sync needs a tunnel configured (remote config tunnel …)");
|
|
816
|
+
}
|
|
817
|
+
const sessions = await listRemoteSessions(url);
|
|
818
|
+
if (sessions.length === 0) {
|
|
819
|
+
stderr.write("[remote] no live remote sessions to sync\n");
|
|
820
|
+
return;
|
|
821
|
+
}
|
|
822
|
+
const manifest = renderManifest(plugins);
|
|
823
|
+
let failures = 0;
|
|
824
|
+
for (const session of sessions) {
|
|
825
|
+
const pod = `session-${session.id}`;
|
|
826
|
+
stderr.write(`[remote] ${session.id} (${session.profile}):\n`);
|
|
827
|
+
for (const plugin of plugins) {
|
|
828
|
+
try {
|
|
829
|
+
const out = execPod(tunnel, pod, buildPodSyncScript(plugin, session.profile));
|
|
830
|
+
for (const line of out.trim().split("\n").filter(Boolean)) {
|
|
831
|
+
stderr.write(` ${line}\n`);
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
catch (error) {
|
|
835
|
+
failures++;
|
|
836
|
+
stderr.write(` ${plugin.pkg}@${plugin.version} FAILED: ${String(error).slice(0, 200)}\n`);
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
// Additive: record the desired-state sidecar so drift detection (--check /
|
|
840
|
+
// the watch pass) has a hash to compare against. Best-effort — never fails
|
|
841
|
+
// the sync (a sidecar write error does not undo the converged plugins).
|
|
842
|
+
try {
|
|
843
|
+
pushManifestSidecar(tunnel, pod, manifest);
|
|
844
|
+
}
|
|
845
|
+
catch (error) {
|
|
846
|
+
stderr.write(` manifest sidecar push skipped: ${String(error).slice(0, 120)}\n`);
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
stderr.write(`[remote] plugin sync done: ${sessions.length} session(s), ${plugins.length} plugin(s)${failures > 0 ? `, ${failures} failure(s)` : ""}\n`);
|
|
850
|
+
if (failures > 0)
|
|
851
|
+
process.exitCode = 1;
|
|
852
|
+
}
|
|
853
|
+
/**
|
|
854
|
+
* `remote plugin sync --check` — a READ-ONLY drift report (converges NOTHING).
|
|
855
|
+
* For each live Pod it probes the actual installed state (npm ls -g --json for
|
|
856
|
+
* the desired pkgs + the registered MCP server names from the config files the
|
|
857
|
+
* existing sync writes) via the argv-safe pod exec, then the pure
|
|
858
|
+
* `diffManifest` rows it: ok / version-drift / missing / mcp-unregistered.
|
|
859
|
+
* Prints a table and EXITS 1 when ANY row is drift (via process.exitCode). This
|
|
860
|
+
* REPLACES the `plugin ls` `REMOTE ?` guesswork with REAL per-Pod status. Pure
|
|
861
|
+
* decisions live in plugin-manifest.ts; this is the thin probe + printer.
|
|
862
|
+
*/
|
|
863
|
+
export async function pluginSyncCheck(url, stdout = process.stdout, stderr = process.stderr) {
|
|
864
|
+
const plugins = getPlugins();
|
|
865
|
+
if (plugins.length === 0) {
|
|
866
|
+
stderr.write("[remote] no plugins configured (remote plugin add <npmPkg>)\n");
|
|
867
|
+
return [];
|
|
868
|
+
}
|
|
869
|
+
const tunnel = getTunnel();
|
|
870
|
+
if (!tunnel) {
|
|
871
|
+
throw new Error("plugin sync --check needs a tunnel configured (remote config tunnel …)");
|
|
872
|
+
}
|
|
873
|
+
const sessions = await listRemoteSessions(url);
|
|
874
|
+
if (sessions.length === 0) {
|
|
875
|
+
stderr.write("[remote] no live remote sessions to check\n");
|
|
876
|
+
return [];
|
|
877
|
+
}
|
|
878
|
+
const manifest = renderManifest(plugins);
|
|
879
|
+
const desiredPkgs = manifest.plugins.map((p) => p.pkg);
|
|
880
|
+
const allRows = [];
|
|
881
|
+
for (const session of sessions) {
|
|
882
|
+
const pod = `session-${session.id}`;
|
|
883
|
+
try {
|
|
884
|
+
const state = probePodPluginState(desiredPkgs, podProbeDeps(tunnel, pod));
|
|
885
|
+
allRows.push(...diffManifest(manifest, pod, state));
|
|
886
|
+
}
|
|
887
|
+
catch (error) {
|
|
888
|
+
stderr.write(`[remote] ${pod}: drift probe failed: ${String(error).slice(0, 200)}\n`);
|
|
889
|
+
// A probe failure is itself drift we can't clear — surface every desired
|
|
890
|
+
// item as a probe-failure row (status missing/unregistered conservatively).
|
|
891
|
+
for (const p of manifest.plugins) {
|
|
892
|
+
allRows.push({
|
|
893
|
+
pod,
|
|
894
|
+
item: `plugin:${p.pkg}`,
|
|
895
|
+
status: "missing",
|
|
896
|
+
detail: "probe failed",
|
|
897
|
+
});
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
printDriftTable(allRows, stdout);
|
|
902
|
+
const code = checkExitCode(allRows);
|
|
903
|
+
if (code !== 0)
|
|
904
|
+
process.exitCode = code;
|
|
905
|
+
return allRows;
|
|
906
|
+
}
|
|
907
|
+
/** Render the drift rows as an aligned POD / ITEM / STATUS / DETAIL table. */
|
|
908
|
+
function printDriftTable(rows, stdout) {
|
|
909
|
+
const w = (s, n) => s.padEnd(n);
|
|
910
|
+
stdout.write(`${w("POD", 22)} ${w("ITEM", 30)} ${w("STATUS", 18)} DETAIL\n`);
|
|
911
|
+
for (const r of rows) {
|
|
912
|
+
stdout.write(`${w(r.pod, 22)} ${w(r.item, 30)} ${w(r.status, 18)} ${r.detail}\n`);
|
|
913
|
+
}
|
|
914
|
+
const drifted = rows.filter(isDrift).length;
|
|
915
|
+
stdout.write(drifted === 0
|
|
916
|
+
? `\n[remote] no drift — every live Pod matches the desired manifest.\n`
|
|
917
|
+
: `\n[remote] ${drifted} drift row(s) — run \`remote plugin sync\` to converge (exit 1).\n`);
|
|
918
|
+
}
|
|
919
|
+
//# sourceMappingURL=plugin.js.map
|