@latitude-data/openclaw-telemetry 0.0.3 → 0.0.5

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/cli.js CHANGED
@@ -1,15 +1,141 @@
1
1
  #!/usr/bin/env node
2
- import { copyFileSync, cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
3
  import { dirname, join, resolve } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { cancel, confirm, intro, isCancel, log, note, outro, password, spinner, text } from "@clack/prompts";
6
6
  import pc from "picocolors";
7
+ import { spawnSync } from "node:child_process";
7
8
  import { homedir } from "node:os";
9
+ //#region src/openclaw-cli.ts
10
+ /**
11
+ * Lowest OpenClaw version we support. The reporter verified hook-dispatch
12
+ * gating works correctly here; older versions either reject
13
+ * `hooks.allowConversationAccess` outright (≤ 2026.4.21) or have unverified
14
+ * gating behaviour (2026.4.22 – 2026.4.24). Refusing to install on older
15
+ * versions is intentional — we'd rather fail loudly than ship a
16
+ * config the gateway will quarantine or hooks the dispatcher will block.
17
+ */
18
+ const MIN_OPENCLAW_VERSION = "2026.4.25";
19
+ const DEFAULT_TIMEOUT_MS = 1e4;
20
+ /**
21
+ * Spawn `openclaw <args>` synchronously. Reports failure modes structurally so
22
+ * callers can decide how to degrade (missing binary vs. timed out vs. exited
23
+ * non-zero). Never throws; ENOENT becomes `{ reason: "enoent" }`.
24
+ */
25
+ function runOpenclaw(args, opts = {}) {
26
+ const result = spawnSync("openclaw", args, {
27
+ encoding: "utf-8",
28
+ timeout: opts.timeoutMs ?? DEFAULT_TIMEOUT_MS,
29
+ input: opts.stdin,
30
+ stdio: [
31
+ "pipe",
32
+ "pipe",
33
+ "pipe"
34
+ ]
35
+ });
36
+ const err = result.error;
37
+ if (err?.code === "ENOENT") return {
38
+ ok: false,
39
+ reason: "enoent",
40
+ stdout: "",
41
+ stderr: "",
42
+ code: null
43
+ };
44
+ if (err?.code === "ETIMEDOUT" || result.signal === "SIGTERM" || result.signal === "SIGKILL") return {
45
+ ok: false,
46
+ reason: "timeout",
47
+ stdout: result.stdout ?? "",
48
+ stderr: result.stderr ?? "",
49
+ code: null
50
+ };
51
+ if (err) return {
52
+ ok: false,
53
+ reason: "exit",
54
+ stdout: result.stdout ?? "",
55
+ stderr: result.stderr ?? String(err),
56
+ code: typeof result.status === "number" ? result.status : 1
57
+ };
58
+ if (result.status === 0) return {
59
+ ok: true,
60
+ stdout: result.stdout ?? "",
61
+ stderr: result.stderr ?? "",
62
+ code: 0
63
+ };
64
+ return {
65
+ ok: false,
66
+ reason: "exit",
67
+ stdout: result.stdout ?? "",
68
+ stderr: result.stderr ?? "",
69
+ code: typeof result.status === "number" ? result.status : 1
70
+ };
71
+ }
72
+ /**
73
+ * Run `openclaw --version` and parse out the version string.
74
+ *
75
+ * Banner format (per OpenClaw `src/cli/banner.ts` `formatCliBannerLine`):
76
+ * `🦞 OpenClaw <version> (<commit-sha>)`
77
+ *
78
+ * The banner is normally suppressed when `--version` is the flag, but the
79
+ * version itself still goes to stdout. We accept either layout (with or
80
+ * without the lobster + commit sha) so we don't break if OpenClaw later
81
+ * prints just the bare version string.
82
+ */
83
+ function getOpenclawVersion() {
84
+ const result = runOpenclaw(["--version"], { timeoutMs: 5e3 });
85
+ if (!result.ok) {
86
+ if (result.reason === "enoent") return {
87
+ ok: false,
88
+ error: "missing"
89
+ };
90
+ return {
91
+ ok: false,
92
+ error: "unparseable",
93
+ raw: result.stdout || result.stderr
94
+ };
95
+ }
96
+ const raw = result.stdout.trim();
97
+ const match = raw.match(/(\d{4}\.\d+\.\d+)/);
98
+ if (!match) return {
99
+ ok: false,
100
+ error: "unparseable",
101
+ raw
102
+ };
103
+ return {
104
+ ok: true,
105
+ version: match[1],
106
+ raw
107
+ };
108
+ }
109
+ /**
110
+ * Compare two CalVer strings (`YYYY.M.PATCH`). Returns -1 if `a` < `b`,
111
+ * 0 if equal, 1 if `a` > `b`. Strings with extra components or non-numeric
112
+ * pieces fall back to a per-component string comparison so unexpected
113
+ * formats don't crash the installer.
114
+ */
115
+ function compareCalver(a, b) {
116
+ const ap = a.split(".");
117
+ const bp = b.split(".");
118
+ const len = Math.max(ap.length, bp.length);
119
+ for (let i = 0; i < len; i++) {
120
+ const ai = ap[i] ?? "0";
121
+ const bi = bp[i] ?? "0";
122
+ const an = Number(ai);
123
+ const bn = Number(bi);
124
+ if (Number.isFinite(an) && Number.isFinite(bn)) {
125
+ if (an < bn) return -1;
126
+ if (an > bn) return 1;
127
+ continue;
128
+ }
129
+ if (ai < bi) return -1;
130
+ if (ai > bi) return 1;
131
+ }
132
+ return 0;
133
+ }
134
+ //#endregion
8
135
  //#region src/settings-file.ts
9
136
  const CONFIG_DIR = join(homedir(), ".openclaw");
10
137
  const SETTINGS_PATH = join(CONFIG_DIR, "openclaw.json");
11
138
  const SETTINGS_BACKUP_PATH = join(CONFIG_DIR, "openclaw.json.latitude-bak");
12
- const PLUGIN_INSTALL_DIR = join(join(CONFIG_DIR, "extensions"), "latitude-telemetry");
13
139
  /** Plugin id used both as the npm package name and as the OpenClaw plugin id. */
14
140
  const PLUGIN_ID = "@latitude-data/openclaw-telemetry";
15
141
  function readSettings() {
@@ -29,22 +155,38 @@ function backupSettings() {
29
155
  if (existsSync(SETTINGS_PATH)) copyFileSync(SETTINGS_PATH, SETTINGS_BACKUP_PATH);
30
156
  }
31
157
  /**
32
- * Set the `plugins.entries[id]` block for our plugin. Writes credentials and
33
- * options into the `.config` bucket — never under `hooks` (strict zod) and
34
- * never as top-level `env` keys (root schema rejects them).
158
+ * Set the `plugins.entries[id]` block for our plugin.
159
+ *
160
+ * Two places in the entry get written:
161
+ *
162
+ * - `.config` (free-form `record(string, unknown)`): credentials, baseUrl,
163
+ * and our copy of `allowConversationAccess`. This is what the plugin
164
+ * runtime reads via `api.pluginConfig`.
165
+ * - `.hooks.allowConversationAccess`: controls whether OpenClaw's hook
166
+ * dispatcher actually forwards `llm_input` / `llm_output` / tool /
167
+ * `agent_end` events to our handlers. Without it set to `true` on
168
+ * OpenClaw 2026.4.25+, every typed hook is blocked at the dispatcher
169
+ * and the plugin's handlers never fire.
170
+ *
171
+ * The two flags mean different things — `hooks.*` is the dispatch gate,
172
+ * `config.*` is the payload-content gate — but for THIS plugin we always
173
+ * couple them: dispatch off + payload on is useless (no payloads to gate),
174
+ * and dispatch on + payload off is a legitimate "structural-only telemetry"
175
+ * mode (timing, tokens, ids, agent name; no message bodies). Always writing
176
+ * both from the same source keeps the operator's mental model simple.
35
177
  *
36
178
  * Re-install idempotency: only `apiKey` / `project` / `baseUrl` always
37
- * overwrite (these come from the install prompts). `enabled`, `debug`, and
38
- * `allowConversationAccess` are preserved when not provided in the patch,
39
- * so a user who hand-edited `enabled: false` or `debug: true` doesn't lose
40
- * their choice on a re-install.
179
+ * overwrite (these come from install prompts). `enabled`, `debug`, and
180
+ * `allowConversationAccess` are preserved when not provided in the patch.
41
181
  */
42
182
  function setPluginEntry(settings, patch) {
43
183
  const plugins = settings.plugins ?? {};
44
184
  const entries = plugins.entries ?? {};
45
185
  const existing = entries["@latitude-data/openclaw-telemetry"] ?? {};
186
+ const existingConfig = existing.config ?? {};
187
+ const existingHooks = existing.hooks ?? {};
46
188
  const nextConfig = {
47
- ...existing.config ?? {},
189
+ ...existingConfig,
48
190
  apiKey: patch.apiKey,
49
191
  project: patch.project
50
192
  };
@@ -52,16 +194,22 @@ function setPluginEntry(settings, patch) {
52
194
  else delete nextConfig.baseUrl;
53
195
  if (patch.allowConversationAccess !== void 0) nextConfig.allowConversationAccess = patch.allowConversationAccess;
54
196
  if (patch.debug !== void 0) nextConfig.debug = patch.debug;
197
+ const effectiveAccess = typeof nextConfig.allowConversationAccess === "boolean" ? nextConfig.allowConversationAccess : typeof existingHooks.allowConversationAccess === "boolean" ? existingHooks.allowConversationAccess : true;
198
+ const nextHooks = {
199
+ ...existingHooks,
200
+ allowConversationAccess: effectiveAccess
201
+ };
55
202
  const nextEnabled = patch.enabled ?? existing.enabled ?? true;
56
203
  entries[PLUGIN_ID] = {
57
204
  ...existing,
58
205
  enabled: nextEnabled,
206
+ hooks: nextHooks,
59
207
  config: nextConfig
60
208
  };
61
209
  plugins.entries = entries;
62
210
  settings.plugins = plugins;
63
211
  }
64
- /** Remove the plugin entry entirely. */
212
+ /** Remove the plugin entry entirely. Used by uninstall as defense-in-depth. */
65
213
  function removePluginEntry(settings) {
66
214
  const plugins = settings.plugins;
67
215
  if (!plugins?.entries) return false;
@@ -69,26 +217,58 @@ function removePluginEntry(settings) {
69
217
  delete plugins.entries[PLUGIN_ID];
70
218
  return true;
71
219
  }
220
+ /**
221
+ * Add the plugin id to `plugins.allow`. Idempotent — returns `true` only when
222
+ * the array changed. OpenClaw warns at every gateway start when a non-bundled
223
+ * plugin auto-loads without provenance via `plugins.allow` or an install
224
+ * record. We get one warning cleared by going through `openclaw plugins
225
+ * install` (provenance) and the other by adding ourselves to allow.
226
+ *
227
+ * Defensive against hand-edited non-array values: if `plugins.allow` is
228
+ * present but not an array (e.g. someone wrote a string), we replace it
229
+ * with a single-element array rather than spreading the bad value.
230
+ */
231
+ function addToPluginsAllow(settings) {
232
+ const plugins = settings.plugins ?? {};
233
+ const existing = plugins.allow;
234
+ const allow = Array.isArray(existing) ? existing : [];
235
+ if (allow.includes("@latitude-data/openclaw-telemetry")) return false;
236
+ plugins.allow = [...allow, PLUGIN_ID];
237
+ settings.plugins = plugins;
238
+ return true;
239
+ }
240
+ /**
241
+ * Inverse of `addToPluginsAllow`. Defense-in-depth — `openclaw plugins
242
+ * uninstall` already strips the entry, but the install path can be skipped
243
+ * (e.g. the user removed the plugin manually) and we want re-install/uninstall
244
+ * round-trips to be tidy regardless.
245
+ */
246
+ function removeFromPluginsAllow(settings) {
247
+ const allow = settings.plugins?.allow;
248
+ if (!Array.isArray(allow) || !allow.includes("@latitude-data/openclaw-telemetry")) return false;
249
+ if (settings.plugins) settings.plugins.allow = allow.filter((id) => id !== PLUGIN_ID);
250
+ return true;
251
+ }
72
252
  function hasLatitudePlugin(settings) {
73
253
  return Boolean(settings.plugins?.entries && "@latitude-data/openclaw-telemetry" in settings.plugins.entries);
74
254
  }
75
255
  /**
76
- * Strip any leftover top-level keys our older installer (<= 0.0.1) wrote. The
77
- * 0.0.1 installer wrote `hooks.allowConversationAccess` (rejected by strict
78
- * zod) and `LATITUDE_*` keys at root-level `env` (also rejected). Both are
79
- * cleaned up here so re-running install lands a config OpenClaw will validate.
256
+ * Strip leftover keys from older installers that the strict zod schema
257
+ * rejects on current OpenClaw versions.
258
+ *
259
+ * 0.0.1 wrote `LATITUDE_*` keys directly under `settings.env`. OpenClaw's
260
+ * root schema is strict; the `env` block accepts only `{shellEnv, vars}`,
261
+ * so those keys cause the gateway to quarantine the config as
262
+ * `clobbered.<ts>` and roll back. We sweep them on every install.
263
+ *
264
+ * Note: 0.0.1 also wrote `hooks.allowConversationAccess` (when that key was
265
+ * not yet in the schema). We deliberately do NOT strip it anymore — on
266
+ * OpenClaw 2026.4.25+ the key IS in the schema and IS load-bearing for
267
+ * dispatch. `setPluginEntry` overwrites it on every install with the right
268
+ * value, so any 0.0.1 leftover is reconciled there.
80
269
  */
81
270
  function migrateLegacyEntries(settings) {
82
271
  let changed = false;
83
- const entry = settings.plugins?.entries?.[PLUGIN_ID];
84
- if (entry && typeof entry === "object") {
85
- const hooks = entry.hooks;
86
- if (hooks && typeof hooks === "object" && "allowConversationAccess" in hooks) {
87
- delete hooks.allowConversationAccess;
88
- if (Object.keys(hooks).length === 0) delete entry.hooks;
89
- changed = true;
90
- }
91
- }
92
272
  const env = settings.env;
93
273
  if (env && typeof env === "object" && !Array.isArray(env)) {
94
274
  const envObj = env;
@@ -105,63 +285,6 @@ function migrateLegacyEntries(settings) {
105
285
  return { changed };
106
286
  }
107
287
  //#endregion
108
- //#region src/install-files.ts
109
- /**
110
- * Copies the plugin's runtime files into `~/.openclaw/extensions/latitude-telemetry/`
111
- * so OpenClaw's discovery (`<configDir>/extensions/<plugin>`) picks it up.
112
- *
113
- * `npx -y @latitude-data/openclaw-telemetry` runs the CLI from a temporary npm
114
- * location — relying on that path persisting across runs (or even across the
115
- * gateway restart that follows install) is unsafe. So we materialize a stable
116
- * copy under the user's OpenClaw config dir, the same path OpenClaw scans on
117
- * startup. Layout we produce:
118
- *
119
- * ~/.openclaw/extensions/latitude-telemetry/
120
- * openclaw.plugin.json <- the manifest, required by discovery
121
- * package.json <- minimal — keeps node module-resolution happy
122
- * dist/ <- compiled plugin entrypoint(s)
123
- */
124
- function installPluginFiles() {
125
- const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
126
- const manifestSrc = join(packageRoot, "openclaw.plugin.json");
127
- const distSrc = join(packageRoot, "dist");
128
- const pkgSrc = join(packageRoot, "package.json");
129
- if (!existsSync(manifestSrc)) throw new Error(`Cannot install: missing openclaw.plugin.json at ${manifestSrc}. This is a packaging bug — please file an issue.`);
130
- if (!existsSync(distSrc)) throw new Error(`Cannot install: missing compiled dist at ${distSrc}.`);
131
- if (!existsSync(pkgSrc)) throw new Error(`Cannot install: missing package.json at ${pkgSrc}. This is a packaging bug — please file an issue.`);
132
- if (existsSync(PLUGIN_INSTALL_DIR)) rmSync(PLUGIN_INSTALL_DIR, {
133
- recursive: true,
134
- force: true
135
- });
136
- mkdirSync(PLUGIN_INSTALL_DIR, { recursive: true });
137
- copyFileSync(manifestSrc, join(PLUGIN_INSTALL_DIR, "openclaw.plugin.json"));
138
- cpSync(distSrc, join(PLUGIN_INSTALL_DIR, "dist"), { recursive: true });
139
- const sourcePkg = JSON.parse(readFileSync(pkgSrc, "utf-8"));
140
- const extensions = sourcePkg.openclaw?.extensions ?? ["./dist/plugin.js"];
141
- const minimalPkg = {
142
- name: sourcePkg.name,
143
- version: sourcePkg.version,
144
- type: sourcePkg.type ?? "module",
145
- main: sourcePkg.main ?? "./dist/plugin.js",
146
- private: true,
147
- openclaw: { extensions }
148
- };
149
- writeFileSync(join(PLUGIN_INSTALL_DIR, "package.json"), `${JSON.stringify(minimalPkg, null, 2)}\n`, "utf-8");
150
- return {
151
- destination: PLUGIN_INSTALL_DIR,
152
- entryPath: resolve(PLUGIN_INSTALL_DIR, extensions[0] ?? "./dist/plugin.js")
153
- };
154
- }
155
- /** Remove the materialized plugin directory under `~/.openclaw/extensions/`. */
156
- function removePluginFiles() {
157
- if (!existsSync(PLUGIN_INSTALL_DIR)) return false;
158
- rmSync(PLUGIN_INSTALL_DIR, {
159
- recursive: true,
160
- force: true
161
- });
162
- return true;
163
- }
164
- //#endregion
165
288
  //#region src/setup.ts
166
289
  const DOCS_URL = "https://docs.latitude.so/openclaw-telemetry";
167
290
  const PRODUCTION_ENV = {
@@ -218,6 +341,7 @@ function normalizeInstallFlags(flags) {
218
341
  project: typeof flags.project === "string" ? flags.project : void 0,
219
342
  environment,
220
343
  allowConversationAccess,
344
+ noTrust: flags["no-trust"] === true,
221
345
  noPrompt: flags["no-prompt"] === true || flags.yes === true,
222
346
  yes: flags.yes === true
223
347
  };
@@ -228,6 +352,7 @@ async function runInstall(flags = {}) {
228
352
  }
229
353
  async function runInteractiveInstall(flags) {
230
354
  intro(pc.bgCyan(pc.black(" Latitude · OpenClaw telemetry ")));
355
+ ensureOpenclawIsCompatible();
231
356
  const existingConfig = readSettings().plugins?.entries?.["@latitude-data/openclaw-telemetry"]?.config ?? void 0;
232
357
  const envConfig = flags.environment ?? PRODUCTION_ENV;
233
358
  const urls = urlsFor(envConfig);
@@ -248,7 +373,8 @@ async function runInteractiveInstall(flags) {
248
373
  apiKey,
249
374
  project,
250
375
  envConfig,
251
- allowConversationAccess: flags.allowConversationAccess
376
+ allowConversationAccess: flags.allowConversationAccess,
377
+ noTrust: flags.noTrust === true
252
378
  });
253
379
  note([
254
380
  "Restart the OpenClaw gateway for the plugin to load:",
@@ -259,6 +385,7 @@ async function runInteractiveInstall(flags) {
259
385
  outro(pc.green("✓ Installed"));
260
386
  }
261
387
  async function runFlagDrivenInstall(flags) {
388
+ ensureOpenclawIsCompatible();
262
389
  const apiKey = flags.apiKey;
263
390
  const project = flags.project;
264
391
  if (!apiKey || !project) throw new Error("Non-interactive install requires --api-key=... and --project=... (or run in a TTY).");
@@ -266,10 +393,10 @@ async function runFlagDrivenInstall(flags) {
266
393
  apiKey,
267
394
  project,
268
395
  envConfig: flags.environment ?? PRODUCTION_ENV,
269
- allowConversationAccess: flags.allowConversationAccess
396
+ allowConversationAccess: flags.allowConversationAccess,
397
+ noTrust: flags.noTrust === true
270
398
  });
271
399
  process.stdout.write(`Installed Latitude plugin in ${SETTINGS_PATH}\n`);
272
- process.stdout.write(`Plugin files at ${PLUGIN_INSTALL_DIR}\n`);
273
400
  }
274
401
  async function promptApiKey(_existing, flag) {
275
402
  if (flag) return flag;
@@ -296,48 +423,92 @@ function onCancel() {
296
423
  cancel("Cancelled — nothing was changed");
297
424
  process.exit(1);
298
425
  }
299
- async function applyChanges({ apiKey, project, envConfig, allowConversationAccess }) {
300
- const filesSpinner = spinner();
301
- filesSpinner.start("Installing plugin files");
302
- const { destination } = installPluginFiles();
303
- filesSpinner.stop(`Plugin files installed at ${destination}`);
304
- const settingsSpinner = spinner();
305
- settingsSpinner.start("Updating openclaw.json");
426
+ async function applyChanges({ apiKey, project, envConfig, allowConversationAccess, noTrust }) {
306
427
  ensureSettingsDir();
307
428
  backupSettings();
429
+ const packageRoot = resolvePackageRoot();
430
+ const installSpinner = spinner();
431
+ installSpinner.start(`Installing plugin via openclaw plugins install ${packageRoot}`);
432
+ const installResult = runOpenclaw([
433
+ "plugins",
434
+ "install",
435
+ packageRoot,
436
+ "--force"
437
+ ], { timeoutMs: 6e4 });
438
+ if (!installResult.ok) {
439
+ installSpinner.stop("openclaw plugins install failed");
440
+ if (installResult.reason === "enoent") throw new Error("`openclaw` not found on PATH. Install OpenClaw first (https://openclaw.ai/install) and re-run.");
441
+ if (installResult.reason === "timeout") throw new Error("openclaw plugins install timed out after 60s. Try running it manually to see what's stuck.");
442
+ const detail = installResult.stderr.trim() || installResult.stdout.trim() || `exit code ${installResult.code}`;
443
+ throw new Error(`openclaw plugins install failed: ${detail}`);
444
+ }
445
+ installSpinner.stop("Plugin registered with OpenClaw");
446
+ const settingsSpinner = spinner();
447
+ settingsSpinner.start("Updating openclaw.json");
308
448
  const settings = readSettings();
309
449
  migrateLegacyEntries(settings);
310
- const existingConfig = settings.plugins?.entries?.["@latitude-data/openclaw-telemetry"]?.config ?? {};
311
- const finalAllowConversationAccess = allowConversationAccess ?? existingConfig.allowConversationAccess ?? true;
312
450
  setPluginEntry(settings, {
313
451
  apiKey,
314
452
  project,
315
453
  baseUrl: envConfig.name === "production" ? void 0 : envConfig.ingest,
316
- allowConversationAccess: finalAllowConversationAccess
454
+ allowConversationAccess
317
455
  });
456
+ if (!noTrust) addToPluginsAllow(settings);
318
457
  writeSettings(settings);
319
458
  settingsSpinner.stop(`Updated ${SETTINGS_PATH}`);
320
459
  if (existsSync(SETTINGS_BACKUP_PATH)) log.info(`Backup saved at ${pc.dim(SETTINGS_BACKUP_PATH)}`);
460
+ if (noTrust) log.warning(`--no-trust set; OpenClaw will warn at every gateway start that ${PLUGIN_ID} is untrusted. Add it to plugins.allow yourself when you're ready.`);
321
461
  }
322
462
  function ensureSettingsDir() {
323
463
  const dir = dirname(SETTINGS_PATH);
324
464
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
325
465
  }
466
+ /**
467
+ * Verify `openclaw` is on PATH AND its version is >= MIN_OPENCLAW_VERSION.
468
+ * Aborts with a clear upgrade message otherwise. Called before any user
469
+ * prompts so we don't waste their time collecting credentials we can't
470
+ * use.
471
+ */
472
+ function ensureOpenclawIsCompatible() {
473
+ const v = getOpenclawVersion();
474
+ if (!v.ok) {
475
+ if (v.error === "missing") {
476
+ cancel("OpenClaw CLI not found on PATH. Install or update via `npm install -g openclaw@latest` and re-run.");
477
+ process.exit(1);
478
+ }
479
+ cancel(`Couldn't parse OpenClaw version output${v.raw ? ` (got: ${pc.dim(v.raw)})` : ""}. Run \`openclaw --version\` and report the output.`);
480
+ process.exit(1);
481
+ }
482
+ if (compareCalver(v.version, "2026.4.25") < 0) {
483
+ cancel(`OpenClaw ${v.version} is older than the minimum supported version (${MIN_OPENCLAW_VERSION}). Run \`npm install -g openclaw@latest\` and re-run install.`);
484
+ process.exit(1);
485
+ }
486
+ log.info(`OpenClaw ${pc.dim(v.version)} (>= ${MIN_OPENCLAW_VERSION})`);
487
+ }
488
+ /**
489
+ * Resolve the absolute path to our package's root (the directory that
490
+ * contains `package.json` + `openclaw.plugin.json` + `dist/`). The compiled
491
+ * CLI lives at `<package-root>/dist/cli.js`, so `import.meta.url`'s parent
492
+ * directory's parent is our root.
493
+ *
494
+ * `openclaw plugins install <path>` copies files synchronously, so we
495
+ * don't have to keep this directory alive past the spawn return.
496
+ */
497
+ function resolvePackageRoot() {
498
+ return resolve(dirname(fileURLToPath(import.meta.url)), "..");
499
+ }
326
500
  async function runUninstall(flags = {}) {
327
501
  intro(pc.bgYellow(pc.black(" Latitude · OpenClaw telemetry — uninstall ")));
328
- const settings = readSettings();
329
- const hasEntry = hasLatitudePlugin(settings);
330
- const hasFiles = existsSync(PLUGIN_INSTALL_DIR);
331
- if (!hasEntry && !hasFiles) {
332
- note("No Latitude plugin entry or files found — nothing to remove.", "Status");
502
+ if (!hasLatitudePlugin(readSettings())) {
503
+ note("No Latitude plugin entry found — nothing to remove.", "Status");
333
504
  outro(pc.dim("Nothing changed"));
334
505
  return;
335
506
  }
336
507
  note([
337
- hasEntry ? `Remove "${PLUGIN_ID}" plugin entry from ${SETTINGS_PATH}` : null,
338
- hasFiles ? `Delete plugin files at ${PLUGIN_INSTALL_DIR}` : null,
508
+ `Run \`openclaw plugins uninstall ${PLUGIN_ID} --force\` (removes files, install record, and plugin entry)`,
509
+ `Sweep any leftover LATITUDE_* keys from settings.env`,
339
510
  `Backup of openclaw.json saved at ${SETTINGS_BACKUP_PATH}`
340
- ].filter(Boolean).join("\n"), "Plan");
511
+ ].join("\n"), "Plan");
341
512
  if (!flags.noPrompt && process.stdin.isTTY === true) {
342
513
  const ok = await confirm({
343
514
  message: "Proceed?",
@@ -345,16 +516,31 @@ async function runUninstall(flags = {}) {
345
516
  });
346
517
  if (isCancel(ok) || ok !== true) return onCancel();
347
518
  }
519
+ backupSettings();
348
520
  const s = spinner();
349
- s.start("Reverting settings");
350
- if (hasEntry) {
351
- backupSettings();
352
- removePluginEntry(settings);
353
- migrateLegacyEntries(settings);
354
- writeSettings(settings);
355
- }
356
- if (hasFiles) removePluginFiles();
357
- s.stop("Done");
521
+ s.start("Reverting via openclaw plugins uninstall");
522
+ const uninstallResult = runOpenclaw([
523
+ "plugins",
524
+ "uninstall",
525
+ PLUGIN_ID,
526
+ "--force"
527
+ ], { timeoutMs: 6e4 });
528
+ if (!uninstallResult.ok) {
529
+ s.stop("openclaw plugins uninstall failed");
530
+ if (uninstallResult.reason === "enoent") log.warning("`openclaw` not found on PATH. Falling back to local cleanup — files at ~/.openclaw/extensions/ may remain.");
531
+ else {
532
+ const detail = uninstallResult.stderr.trim() || uninstallResult.stdout.trim() || `exit code ${uninstallResult.code}`;
533
+ log.warning(`openclaw plugins uninstall reported: ${detail}. Continuing with local cleanup.`);
534
+ }
535
+ } else s.stop("Plugin removed by OpenClaw");
536
+ const cleanupSpinner = spinner();
537
+ cleanupSpinner.start("Reverting openclaw.json");
538
+ const post = readSettings();
539
+ removePluginEntry(post);
540
+ removeFromPluginsAllow(post);
541
+ migrateLegacyEntries(post);
542
+ writeSettings(post);
543
+ cleanupSpinner.stop("Done");
358
544
  outro(pc.green("✓ Uninstalled"));
359
545
  }
360
546
  //#endregion