@latitude-data/openclaw-telemetry 0.0.2 → 0.0.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.
- package/README.md +54 -26
- package/dist/cli.js +334 -113
- package/dist/cli.js.map +1 -1
- package/dist/plugin.js +21 -1
- package/dist/plugin.js.map +1 -1
- package/openclaw.plugin.json +1 -1
- package/package.json +6 -1
package/dist/cli.js
CHANGED
|
@@ -1,15 +1,141 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { copyFileSync,
|
|
2
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { dirname, join, resolve } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
4
5
|
import { cancel, confirm, intro, isCancel, log, note, outro, password, spinner, text } from "@clack/prompts";
|
|
5
6
|
import pc from "picocolors";
|
|
6
|
-
import {
|
|
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.
|
|
33
|
-
*
|
|
34
|
-
*
|
|
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
|
|
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
|
-
...
|
|
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
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
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,62 +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(PLUGIN_INSTALL_DIR)) rmSync(PLUGIN_INSTALL_DIR, {
|
|
132
|
-
recursive: true,
|
|
133
|
-
force: true
|
|
134
|
-
});
|
|
135
|
-
mkdirSync(PLUGIN_INSTALL_DIR, { recursive: true });
|
|
136
|
-
copyFileSync(manifestSrc, join(PLUGIN_INSTALL_DIR, "openclaw.plugin.json"));
|
|
137
|
-
cpSync(distSrc, join(PLUGIN_INSTALL_DIR, "dist"), { recursive: true });
|
|
138
|
-
if (existsSync(pkgSrc)) {
|
|
139
|
-
const sourcePkg = JSON.parse(readFileSync(pkgSrc, "utf-8"));
|
|
140
|
-
const minimalPkg = {
|
|
141
|
-
name: sourcePkg.name,
|
|
142
|
-
version: sourcePkg.version,
|
|
143
|
-
type: sourcePkg.type ?? "module",
|
|
144
|
-
main: sourcePkg.main ?? "./dist/plugin.js",
|
|
145
|
-
private: true
|
|
146
|
-
};
|
|
147
|
-
writeFileSync(join(PLUGIN_INSTALL_DIR, "package.json"), `${JSON.stringify(minimalPkg, null, 2)}\n`, "utf-8");
|
|
148
|
-
}
|
|
149
|
-
return {
|
|
150
|
-
destination: PLUGIN_INSTALL_DIR,
|
|
151
|
-
entryPath: join(PLUGIN_INSTALL_DIR, "dist", "plugin.js")
|
|
152
|
-
};
|
|
153
|
-
}
|
|
154
|
-
/** Remove the materialized plugin directory under `~/.openclaw/extensions/`. */
|
|
155
|
-
function removePluginFiles() {
|
|
156
|
-
if (!existsSync(PLUGIN_INSTALL_DIR)) return false;
|
|
157
|
-
rmSync(PLUGIN_INSTALL_DIR, {
|
|
158
|
-
recursive: true,
|
|
159
|
-
force: true
|
|
160
|
-
});
|
|
161
|
-
return true;
|
|
162
|
-
}
|
|
163
|
-
//#endregion
|
|
164
288
|
//#region src/setup.ts
|
|
165
289
|
const DOCS_URL = "https://docs.latitude.so/openclaw-telemetry";
|
|
166
290
|
const PRODUCTION_ENV = {
|
|
@@ -217,6 +341,7 @@ function normalizeInstallFlags(flags) {
|
|
|
217
341
|
project: typeof flags.project === "string" ? flags.project : void 0,
|
|
218
342
|
environment,
|
|
219
343
|
allowConversationAccess,
|
|
344
|
+
noTrust: flags["no-trust"] === true,
|
|
220
345
|
noPrompt: flags["no-prompt"] === true || flags.yes === true,
|
|
221
346
|
yes: flags.yes === true
|
|
222
347
|
};
|
|
@@ -227,6 +352,7 @@ async function runInstall(flags = {}) {
|
|
|
227
352
|
}
|
|
228
353
|
async function runInteractiveInstall(flags) {
|
|
229
354
|
intro(pc.bgCyan(pc.black(" Latitude · OpenClaw telemetry ")));
|
|
355
|
+
ensureOpenclawIsCompatible();
|
|
230
356
|
const existingConfig = readSettings().plugins?.entries?.["@latitude-data/openclaw-telemetry"]?.config ?? void 0;
|
|
231
357
|
const envConfig = flags.environment ?? PRODUCTION_ENV;
|
|
232
358
|
const urls = urlsFor(envConfig);
|
|
@@ -247,7 +373,8 @@ async function runInteractiveInstall(flags) {
|
|
|
247
373
|
apiKey,
|
|
248
374
|
project,
|
|
249
375
|
envConfig,
|
|
250
|
-
allowConversationAccess: flags.allowConversationAccess
|
|
376
|
+
allowConversationAccess: flags.allowConversationAccess,
|
|
377
|
+
noTrust: flags.noTrust === true
|
|
251
378
|
});
|
|
252
379
|
note([
|
|
253
380
|
"Restart the OpenClaw gateway for the plugin to load:",
|
|
@@ -258,6 +385,7 @@ async function runInteractiveInstall(flags) {
|
|
|
258
385
|
outro(pc.green("✓ Installed"));
|
|
259
386
|
}
|
|
260
387
|
async function runFlagDrivenInstall(flags) {
|
|
388
|
+
ensureOpenclawIsCompatible();
|
|
261
389
|
const apiKey = flags.apiKey;
|
|
262
390
|
const project = flags.project;
|
|
263
391
|
if (!apiKey || !project) throw new Error("Non-interactive install requires --api-key=... and --project=... (or run in a TTY).");
|
|
@@ -265,10 +393,10 @@ async function runFlagDrivenInstall(flags) {
|
|
|
265
393
|
apiKey,
|
|
266
394
|
project,
|
|
267
395
|
envConfig: flags.environment ?? PRODUCTION_ENV,
|
|
268
|
-
allowConversationAccess: flags.allowConversationAccess
|
|
396
|
+
allowConversationAccess: flags.allowConversationAccess,
|
|
397
|
+
noTrust: flags.noTrust === true
|
|
269
398
|
});
|
|
270
399
|
process.stdout.write(`Installed Latitude plugin in ${SETTINGS_PATH}\n`);
|
|
271
|
-
process.stdout.write(`Plugin files at ${PLUGIN_INSTALL_DIR}\n`);
|
|
272
400
|
}
|
|
273
401
|
async function promptApiKey(_existing, flag) {
|
|
274
402
|
if (flag) return flag;
|
|
@@ -295,48 +423,92 @@ function onCancel() {
|
|
|
295
423
|
cancel("Cancelled — nothing was changed");
|
|
296
424
|
process.exit(1);
|
|
297
425
|
}
|
|
298
|
-
async function applyChanges({ apiKey, project, envConfig, allowConversationAccess }) {
|
|
299
|
-
const filesSpinner = spinner();
|
|
300
|
-
filesSpinner.start("Installing plugin files");
|
|
301
|
-
const { destination } = installPluginFiles();
|
|
302
|
-
filesSpinner.stop(`Plugin files installed at ${destination}`);
|
|
303
|
-
const settingsSpinner = spinner();
|
|
304
|
-
settingsSpinner.start("Updating openclaw.json");
|
|
426
|
+
async function applyChanges({ apiKey, project, envConfig, allowConversationAccess, noTrust }) {
|
|
305
427
|
ensureSettingsDir();
|
|
306
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");
|
|
307
448
|
const settings = readSettings();
|
|
308
449
|
migrateLegacyEntries(settings);
|
|
309
|
-
const existingConfig = settings.plugins?.entries?.["@latitude-data/openclaw-telemetry"]?.config ?? {};
|
|
310
|
-
const finalAllowConversationAccess = allowConversationAccess ?? existingConfig.allowConversationAccess ?? true;
|
|
311
450
|
setPluginEntry(settings, {
|
|
312
451
|
apiKey,
|
|
313
452
|
project,
|
|
314
453
|
baseUrl: envConfig.name === "production" ? void 0 : envConfig.ingest,
|
|
315
|
-
allowConversationAccess
|
|
454
|
+
allowConversationAccess
|
|
316
455
|
});
|
|
456
|
+
if (!noTrust) addToPluginsAllow(settings);
|
|
317
457
|
writeSettings(settings);
|
|
318
458
|
settingsSpinner.stop(`Updated ${SETTINGS_PATH}`);
|
|
319
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.`);
|
|
320
461
|
}
|
|
321
462
|
function ensureSettingsDir() {
|
|
322
463
|
const dir = dirname(SETTINGS_PATH);
|
|
323
464
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
324
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
|
+
}
|
|
325
500
|
async function runUninstall(flags = {}) {
|
|
326
501
|
intro(pc.bgYellow(pc.black(" Latitude · OpenClaw telemetry — uninstall ")));
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
const hasFiles = existsSync(PLUGIN_INSTALL_DIR);
|
|
330
|
-
if (!hasEntry && !hasFiles) {
|
|
331
|
-
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");
|
|
332
504
|
outro(pc.dim("Nothing changed"));
|
|
333
505
|
return;
|
|
334
506
|
}
|
|
335
507
|
note([
|
|
336
|
-
|
|
337
|
-
|
|
508
|
+
`Run \`openclaw plugins uninstall ${PLUGIN_ID} --force\` (removes files, install record, and plugin entry)`,
|
|
509
|
+
`Sweep any leftover LATITUDE_* keys from settings.env`,
|
|
338
510
|
`Backup of openclaw.json saved at ${SETTINGS_BACKUP_PATH}`
|
|
339
|
-
].
|
|
511
|
+
].join("\n"), "Plan");
|
|
340
512
|
if (!flags.noPrompt && process.stdin.isTTY === true) {
|
|
341
513
|
const ok = await confirm({
|
|
342
514
|
message: "Proceed?",
|
|
@@ -344,22 +516,71 @@ async function runUninstall(flags = {}) {
|
|
|
344
516
|
});
|
|
345
517
|
if (isCancel(ok) || ok !== true) return onCancel();
|
|
346
518
|
}
|
|
519
|
+
backupSettings();
|
|
347
520
|
const s = spinner();
|
|
348
|
-
s.start("Reverting
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
}
|
|
355
|
-
if (
|
|
356
|
-
|
|
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");
|
|
357
544
|
outro(pc.green("✓ Uninstalled"));
|
|
358
545
|
}
|
|
359
546
|
//#endregion
|
|
360
547
|
//#region src/cli.ts
|
|
548
|
+
const USAGE = `usage: latitude-openclaw <command> [options]
|
|
549
|
+
|
|
550
|
+
commands:
|
|
551
|
+
install Install the plugin (interactive when stdin is a TTY)
|
|
552
|
+
uninstall Remove the plugin entry and files
|
|
553
|
+
--version, -v Print the package version
|
|
554
|
+
--help, -h Print this message
|
|
555
|
+
|
|
556
|
+
install options:
|
|
557
|
+
--api-key=<key> Pass the API key non-interactively
|
|
558
|
+
--project=<slug> Pass the project slug non-interactively
|
|
559
|
+
--staging Target https://staging.latitude.so / staging-ingest
|
|
560
|
+
--dev Target http://localhost:3000 / 3002
|
|
561
|
+
--no-content Skip raw prompt/response/tool I/O capture
|
|
562
|
+
--allow-conversation Force conversation capture on (overrides existing config)
|
|
563
|
+
--yes / --no-prompt Skip all prompts (required for non-TTY / CI)
|
|
564
|
+
`;
|
|
565
|
+
function readVersion() {
|
|
566
|
+
const pkgPath = join(dirname(fileURLToPath(import.meta.url)), "..", "package.json");
|
|
567
|
+
try {
|
|
568
|
+
return JSON.parse(readFileSync(pkgPath, "utf-8")).version ?? "unknown";
|
|
569
|
+
} catch {
|
|
570
|
+
return "unknown";
|
|
571
|
+
}
|
|
572
|
+
}
|
|
361
573
|
async function main() {
|
|
362
|
-
const
|
|
574
|
+
const argv = process.argv.slice(2);
|
|
575
|
+
if (argv[0] === "--version" || argv[0] === "-v") {
|
|
576
|
+
process.stdout.write(`${readVersion()}\n`);
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
579
|
+
if (argv[0] === "--help" || argv[0] === "-h") {
|
|
580
|
+
process.stdout.write(USAGE);
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
const { subcommand, flags } = parseFlags(argv);
|
|
363
584
|
if (subcommand === "install" || subcommand === void 0) {
|
|
364
585
|
await runInstall(normalizeInstallFlags(flags));
|
|
365
586
|
return;
|
|
@@ -369,7 +590,7 @@ async function main() {
|
|
|
369
590
|
return;
|
|
370
591
|
}
|
|
371
592
|
process.stderr.write(`unknown subcommand: ${subcommand}\n`);
|
|
372
|
-
process.stderr.write(
|
|
593
|
+
process.stderr.write(USAGE);
|
|
373
594
|
process.exit(1);
|
|
374
595
|
}
|
|
375
596
|
main().catch((err) => {
|