lmnr-cli 0.1.9 → 0.1.11
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 +129 -14
- package/dist/index.cjs +1914 -1502
- package/dist/index.cjs.map +1 -1
- package/package.json +10 -19
package/dist/index.cjs
CHANGED
|
@@ -6,7 +6,7 @@ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
|
6
6
|
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
7
7
|
var __getProtoOf = Object.getPrototypeOf;
|
|
8
8
|
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
9
|
-
var __commonJSMin = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports);
|
|
9
|
+
var __commonJSMin = (cb, mod) => () => (mod || (cb((mod = { exports: {} }).exports, mod), cb = null), mod.exports);
|
|
10
10
|
var __copyProps = (to, from, except, desc) => {
|
|
11
11
|
if (from && typeof from === "object" || typeof from === "function") for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) {
|
|
12
12
|
key = keys[i];
|
|
@@ -25,11 +25,15 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
|
|
|
25
25
|
let commander = require("commander");
|
|
26
26
|
let fs = require("fs");
|
|
27
27
|
let path = require("path");
|
|
28
|
+
let path$2 = __toESM(path, 1);
|
|
28
29
|
path = __toESM(path);
|
|
29
30
|
let pino = require("pino");
|
|
31
|
+
let pino$3 = __toESM(pino, 1);
|
|
30
32
|
pino = __toESM(pino);
|
|
31
33
|
let pino_pretty = require("pino-pretty");
|
|
32
|
-
let
|
|
34
|
+
let node_fs_promises = require("node:fs/promises");
|
|
35
|
+
let node_path = require("node:path");
|
|
36
|
+
let node_os = require("node:os");
|
|
33
37
|
let csv_parser = require("csv-parser");
|
|
34
38
|
csv_parser = __toESM(csv_parser);
|
|
35
39
|
let export_to_csv = require("export-to-csv");
|
|
@@ -37,104 +41,34 @@ let fs_promises = require("fs/promises");
|
|
|
37
41
|
fs_promises = __toESM(fs_promises);
|
|
38
42
|
let cli_table3 = require("cli-table3");
|
|
39
43
|
cli_table3 = __toESM(cli_table3);
|
|
40
|
-
let
|
|
41
|
-
|
|
42
|
-
let
|
|
43
|
-
|
|
44
|
-
let
|
|
45
|
-
let
|
|
46
|
-
let
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
//#region package.json
|
|
50
|
-
var version$1 = "0.1.9";
|
|
44
|
+
let open = require("open");
|
|
45
|
+
open = __toESM(open);
|
|
46
|
+
let picocolors = require("picocolors");
|
|
47
|
+
let node_readline_promises = require("node:readline/promises");
|
|
48
|
+
let node_child_process = require("node:child_process");
|
|
49
|
+
let node_util = require("node:util");
|
|
50
|
+
let giget = require("giget");
|
|
51
|
+
//#region ../types/dist/index.mjs
|
|
52
|
+
const errorMessage = (error) => error instanceof Error ? error.message : String(error);
|
|
51
53
|
//#endregion
|
|
52
|
-
//#region
|
|
53
|
-
var
|
|
54
|
-
module.exports = {
|
|
55
|
-
"name": "dotenv",
|
|
56
|
-
"version": "17.2.3",
|
|
57
|
-
"description": "Loads environment variables from .env file",
|
|
58
|
-
"main": "lib/main.js",
|
|
59
|
-
"types": "lib/main.d.ts",
|
|
60
|
-
"exports": {
|
|
61
|
-
".": {
|
|
62
|
-
"types": "./lib/main.d.ts",
|
|
63
|
-
"require": "./lib/main.js",
|
|
64
|
-
"default": "./lib/main.js"
|
|
65
|
-
},
|
|
66
|
-
"./config": "./config.js",
|
|
67
|
-
"./config.js": "./config.js",
|
|
68
|
-
"./lib/env-options": "./lib/env-options.js",
|
|
69
|
-
"./lib/env-options.js": "./lib/env-options.js",
|
|
70
|
-
"./lib/cli-options": "./lib/cli-options.js",
|
|
71
|
-
"./lib/cli-options.js": "./lib/cli-options.js",
|
|
72
|
-
"./package.json": "./package.json"
|
|
73
|
-
},
|
|
74
|
-
"scripts": {
|
|
75
|
-
"dts-check": "tsc --project tests/types/tsconfig.json",
|
|
76
|
-
"lint": "standard",
|
|
77
|
-
"pretest": "npm run lint && npm run dts-check",
|
|
78
|
-
"test": "tap run tests/**/*.js --allow-empty-coverage --disable-coverage --timeout=60000",
|
|
79
|
-
"test:coverage": "tap run tests/**/*.js --show-full-coverage --timeout=60000 --coverage-report=text --coverage-report=lcov",
|
|
80
|
-
"prerelease": "npm test",
|
|
81
|
-
"release": "standard-version"
|
|
82
|
-
},
|
|
83
|
-
"repository": {
|
|
84
|
-
"type": "git",
|
|
85
|
-
"url": "git://github.com/motdotla/dotenv.git"
|
|
86
|
-
},
|
|
87
|
-
"homepage": "https://github.com/motdotla/dotenv#readme",
|
|
88
|
-
"funding": "https://dotenvx.com",
|
|
89
|
-
"keywords": [
|
|
90
|
-
"dotenv",
|
|
91
|
-
"env",
|
|
92
|
-
".env",
|
|
93
|
-
"environment",
|
|
94
|
-
"variables",
|
|
95
|
-
"config",
|
|
96
|
-
"settings"
|
|
97
|
-
],
|
|
98
|
-
"readmeFilename": "README.md",
|
|
99
|
-
"license": "BSD-2-Clause",
|
|
100
|
-
"devDependencies": {
|
|
101
|
-
"@types/node": "^18.11.3",
|
|
102
|
-
"decache": "^4.6.2",
|
|
103
|
-
"sinon": "^14.0.1",
|
|
104
|
-
"standard": "^17.0.0",
|
|
105
|
-
"standard-version": "^9.5.0",
|
|
106
|
-
"tap": "^19.2.0",
|
|
107
|
-
"typescript": "^4.8.4"
|
|
108
|
-
},
|
|
109
|
-
"engines": { "node": ">=12" },
|
|
110
|
-
"browser": { "fs": false }
|
|
111
|
-
};
|
|
112
|
-
}));
|
|
54
|
+
//#region package.json
|
|
55
|
+
var version$1 = "0.1.11";
|
|
113
56
|
//#endregion
|
|
114
|
-
//#region
|
|
115
|
-
var
|
|
57
|
+
//#region ../../node_modules/.pnpm/dotenv@17.4.2/node_modules/dotenv/lib/main.js
|
|
58
|
+
var require_main = /* @__PURE__ */ __commonJSMin(((exports, module) => {
|
|
116
59
|
const fs$1 = require("fs");
|
|
117
60
|
const path$1 = require("path");
|
|
118
61
|
const os = require("os");
|
|
119
62
|
const crypto$1 = require("crypto");
|
|
120
|
-
const version = require_package().version;
|
|
121
63
|
const TIPS = [
|
|
122
|
-
"
|
|
123
|
-
"
|
|
124
|
-
"
|
|
125
|
-
"
|
|
126
|
-
"
|
|
127
|
-
"
|
|
128
|
-
"
|
|
129
|
-
"
|
|
130
|
-
"🔑 add access controls to secrets: https://dotenvx.com/ops",
|
|
131
|
-
"🛠️ run anywhere with `dotenvx run -- yourcommand`",
|
|
132
|
-
"⚙️ specify custom .env file path with { path: '/custom/path/.env' }",
|
|
133
|
-
"⚙️ enable debug logging with { debug: true }",
|
|
134
|
-
"⚙️ override existing env vars with { override: true }",
|
|
135
|
-
"⚙️ suppress all logs with { quiet: true }",
|
|
136
|
-
"⚙️ write to custom object with { processEnv: myObject }",
|
|
137
|
-
"⚙️ load multiple .env files with { path: ['.env.local', '.env'] }"
|
|
64
|
+
"◈ encrypted .env [www.dotenvx.com]",
|
|
65
|
+
"◈ secrets for agents [www.dotenvx.com]",
|
|
66
|
+
"⌁ auth for agents [www.vestauth.com]",
|
|
67
|
+
"⌘ custom filepath { path: '/custom/path/.env' }",
|
|
68
|
+
"⌘ enable debugging { debug: true }",
|
|
69
|
+
"⌘ override existing { override: true }",
|
|
70
|
+
"⌘ suppress logs { quiet: true }",
|
|
71
|
+
"⌘ multiple files { path: ['.env.local', '.env'] }"
|
|
138
72
|
];
|
|
139
73
|
function _getRandomTip() {
|
|
140
74
|
return TIPS[Math.floor(Math.random() * TIPS.length)];
|
|
@@ -198,13 +132,13 @@ var import_main = (/* @__PURE__ */ __commonJSMin(((exports, module) => {
|
|
|
198
132
|
return DotenvModule.parse(decrypted);
|
|
199
133
|
}
|
|
200
134
|
function _warn(message) {
|
|
201
|
-
console.error(
|
|
135
|
+
console.error(`⚠ ${message}`);
|
|
202
136
|
}
|
|
203
137
|
function _debug(message) {
|
|
204
|
-
console.log(
|
|
138
|
+
console.log(`┆ ${message}`);
|
|
205
139
|
}
|
|
206
140
|
function _log(message) {
|
|
207
|
-
console.log(
|
|
141
|
+
console.log(`◇ ${message}`);
|
|
208
142
|
}
|
|
209
143
|
function _dotenvKey(options) {
|
|
210
144
|
if (options && options.DOTENV_KEY && options.DOTENV_KEY.length > 0) return options.DOTENV_KEY;
|
|
@@ -262,7 +196,7 @@ var import_main = (/* @__PURE__ */ __commonJSMin(((exports, module) => {
|
|
|
262
196
|
function _configVault(options) {
|
|
263
197
|
const debug = parseBoolean(process.env.DOTENV_CONFIG_DEBUG || options && options.debug);
|
|
264
198
|
const quiet = parseBoolean(process.env.DOTENV_CONFIG_QUIET || options && options.quiet);
|
|
265
|
-
if (debug || !quiet) _log("
|
|
199
|
+
if (debug || !quiet) _log("loading env from encrypted .env.vault");
|
|
266
200
|
const parsed = DotenvModule._parseVault(options);
|
|
267
201
|
let processEnv = process.env;
|
|
268
202
|
if (options && options.processEnv != null) processEnv = options.processEnv;
|
|
@@ -277,7 +211,7 @@ var import_main = (/* @__PURE__ */ __commonJSMin(((exports, module) => {
|
|
|
277
211
|
let debug = parseBoolean(processEnv.DOTENV_CONFIG_DEBUG || options && options.debug);
|
|
278
212
|
let quiet = parseBoolean(processEnv.DOTENV_CONFIG_QUIET || options && options.quiet);
|
|
279
213
|
if (options && options.encoding) encoding = options.encoding;
|
|
280
|
-
else if (debug) _debug("
|
|
214
|
+
else if (debug) _debug("no encoding is specified (UTF-8 is used by default)");
|
|
281
215
|
let optionPaths = [dotenvPath];
|
|
282
216
|
if (options && options.path) if (!Array.isArray(options.path)) optionPaths = [_resolveHome(options.path)];
|
|
283
217
|
else {
|
|
@@ -286,11 +220,11 @@ var import_main = (/* @__PURE__ */ __commonJSMin(((exports, module) => {
|
|
|
286
220
|
}
|
|
287
221
|
let lastError;
|
|
288
222
|
const parsedAll = {};
|
|
289
|
-
for (const path$
|
|
290
|
-
const parsed = DotenvModule.parse(fs$1.readFileSync(path$
|
|
223
|
+
for (const path$3 of optionPaths) try {
|
|
224
|
+
const parsed = DotenvModule.parse(fs$1.readFileSync(path$3, { encoding }));
|
|
291
225
|
DotenvModule.populate(parsedAll, parsed, options);
|
|
292
226
|
} catch (e) {
|
|
293
|
-
if (debug) _debug(`
|
|
227
|
+
if (debug) _debug(`failed to load ${path$3} ${e.message}`);
|
|
294
228
|
lastError = e;
|
|
295
229
|
}
|
|
296
230
|
const populated = DotenvModule.populate(processEnv, parsedAll, options);
|
|
@@ -303,10 +237,10 @@ var import_main = (/* @__PURE__ */ __commonJSMin(((exports, module) => {
|
|
|
303
237
|
const relative = path$1.relative(process.cwd(), filePath);
|
|
304
238
|
shortPaths.push(relative);
|
|
305
239
|
} catch (e) {
|
|
306
|
-
if (debug) _debug(`
|
|
240
|
+
if (debug) _debug(`failed to load ${filePath} ${e.message}`);
|
|
307
241
|
lastError = e;
|
|
308
242
|
}
|
|
309
|
-
_log(`
|
|
243
|
+
_log(`injected env (${keysCount}) from ${shortPaths.join(",")} ${dim(`// tip: ${_getRandomTip()}`)}`);
|
|
310
244
|
}
|
|
311
245
|
if (lastError) return {
|
|
312
246
|
parsed: parsedAll,
|
|
@@ -318,7 +252,7 @@ var import_main = (/* @__PURE__ */ __commonJSMin(((exports, module) => {
|
|
|
318
252
|
if (_dotenvKey(options).length === 0) return DotenvModule.configDotenv(options);
|
|
319
253
|
const vaultPath = _vaultPath(options);
|
|
320
254
|
if (!vaultPath) {
|
|
321
|
-
_warn(`
|
|
255
|
+
_warn(`you set DOTENV_KEY but you are missing a .env.vault file at ${vaultPath}`);
|
|
322
256
|
return DotenvModule.configDotenv(options);
|
|
323
257
|
}
|
|
324
258
|
return DotenvModule._configVault(options);
|
|
@@ -387,23 +321,65 @@ var import_main = (/* @__PURE__ */ __commonJSMin(((exports, module) => {
|
|
|
387
321
|
module.exports.parse = DotenvModule.parse;
|
|
388
322
|
module.exports.populate = DotenvModule.populate;
|
|
389
323
|
module.exports = DotenvModule;
|
|
390
|
-
}))
|
|
391
|
-
|
|
324
|
+
}));
|
|
325
|
+
//#endregion
|
|
326
|
+
//#region ../../node_modules/.pnpm/uuid@14.0.0/node_modules/uuid/dist-node/stringify.js
|
|
327
|
+
const byteToHex = [];
|
|
328
|
+
for (let i = 0; i < 256; ++i) byteToHex.push((i + 256).toString(16).slice(1));
|
|
329
|
+
function unsafeStringify(arr, offset = 0) {
|
|
330
|
+
return (byteToHex[arr[offset + 0]] + byteToHex[arr[offset + 1]] + byteToHex[arr[offset + 2]] + byteToHex[arr[offset + 3]] + "-" + byteToHex[arr[offset + 4]] + byteToHex[arr[offset + 5]] + "-" + byteToHex[arr[offset + 6]] + byteToHex[arr[offset + 7]] + "-" + byteToHex[arr[offset + 8]] + byteToHex[arr[offset + 9]] + "-" + byteToHex[arr[offset + 10]] + byteToHex[arr[offset + 11]] + byteToHex[arr[offset + 12]] + byteToHex[arr[offset + 13]] + byteToHex[arr[offset + 14]] + byteToHex[arr[offset + 15]]).toLowerCase();
|
|
331
|
+
}
|
|
332
|
+
//#endregion
|
|
333
|
+
//#region ../../node_modules/.pnpm/uuid@14.0.0/node_modules/uuid/dist-node/rng.js
|
|
334
|
+
const rnds8 = new Uint8Array(16);
|
|
335
|
+
function rng() {
|
|
336
|
+
return crypto.getRandomValues(rnds8);
|
|
337
|
+
}
|
|
338
|
+
//#endregion
|
|
339
|
+
//#region ../../node_modules/.pnpm/uuid@14.0.0/node_modules/uuid/dist-node/v4.js
|
|
340
|
+
function v4(options, buf, offset) {
|
|
341
|
+
if (!buf && !options && crypto.randomUUID) return crypto.randomUUID();
|
|
342
|
+
return _v4(options, buf, offset);
|
|
343
|
+
}
|
|
344
|
+
function _v4(options, buf, offset) {
|
|
345
|
+
options = options || {};
|
|
346
|
+
const rnds = options.random ?? options.rng?.() ?? rng();
|
|
347
|
+
if (rnds.length < 16) throw new Error("Random bytes length must be >= 16");
|
|
348
|
+
rnds[6] = rnds[6] & 15 | 64;
|
|
349
|
+
rnds[8] = rnds[8] & 63 | 128;
|
|
350
|
+
if (buf) {
|
|
351
|
+
offset = offset || 0;
|
|
352
|
+
if (offset < 0 || offset + 16 > buf.length) throw new RangeError(`UUID byte range ${offset}:${offset + 15} is out of buffer bounds`);
|
|
353
|
+
for (let i = 0; i < 16; ++i) buf[offset + i] = rnds[i];
|
|
354
|
+
return buf;
|
|
355
|
+
}
|
|
356
|
+
return unsafeStringify(rnds);
|
|
357
|
+
}
|
|
358
|
+
//#endregion
|
|
359
|
+
//#region ../client/dist/index.mjs
|
|
360
|
+
var import_main = require_main();
|
|
361
|
+
var version = "0.8.29";
|
|
392
362
|
function getLangVersion() {
|
|
393
363
|
if (typeof process !== "undefined" && process.versions && process.versions.node) return `node-${process.versions.node}`;
|
|
394
364
|
if (typeof navigator !== "undefined" && navigator.userAgent) return `browser-${navigator.userAgent}`;
|
|
395
365
|
return null;
|
|
396
366
|
}
|
|
397
367
|
var BaseResource = class {
|
|
398
|
-
constructor(baseHttpUrl,
|
|
368
|
+
constructor(baseHttpUrl, auth) {
|
|
399
369
|
this.baseHttpUrl = baseHttpUrl;
|
|
400
|
-
this.
|
|
370
|
+
this.auth = auth;
|
|
371
|
+
this.credential = auth.type === "apiKey" ? auth.key : auth.token;
|
|
372
|
+
}
|
|
373
|
+
/** API path prefix: `/v1/cli` for CLI user-token auth, `/v1` otherwise. */
|
|
374
|
+
get apiPrefix() {
|
|
375
|
+
return this.auth.type === "userToken" ? "/v1/cli" : "/v1";
|
|
401
376
|
}
|
|
402
377
|
headers() {
|
|
403
378
|
return {
|
|
404
|
-
Authorization: `Bearer ${this.
|
|
379
|
+
Authorization: `Bearer ${this.credential}`,
|
|
405
380
|
"Content-Type": "application/json",
|
|
406
|
-
Accept: "application/json"
|
|
381
|
+
Accept: "application/json",
|
|
382
|
+
...this.auth.type === "userToken" ? { "x-lmnr-project-id": this.auth.projectId } : {}
|
|
407
383
|
};
|
|
408
384
|
}
|
|
409
385
|
async handleError(response) {
|
|
@@ -412,8 +388,8 @@ var BaseResource = class {
|
|
|
412
388
|
}
|
|
413
389
|
};
|
|
414
390
|
var BrowserEventsResource = class extends BaseResource {
|
|
415
|
-
constructor(baseHttpUrl,
|
|
416
|
-
super(baseHttpUrl,
|
|
391
|
+
constructor(baseHttpUrl, auth) {
|
|
392
|
+
super(baseHttpUrl, auth);
|
|
417
393
|
}
|
|
418
394
|
async send({ sessionId, traceId, events }) {
|
|
419
395
|
const payload = {
|
|
@@ -437,37 +413,64 @@ var BrowserEventsResource = class extends BaseResource {
|
|
|
437
413
|
if (!response.ok) await this.handleError(response);
|
|
438
414
|
}
|
|
439
415
|
};
|
|
416
|
+
/**
|
|
417
|
+
* User-scoped CLI endpoints that don't target a specific project. Authed by the
|
|
418
|
+
* BetterAuth user JWT (the `credential`); deliberately does NOT send an
|
|
419
|
+
* `x-lmnr-project-id` header (these routes are project discovery, pre-selection).
|
|
420
|
+
*
|
|
421
|
+
* Discovery exception: this resource always hits `/v1/cli/projects` with the
|
|
422
|
+
* bare bearer and overrides `BaseResource.headers()`/`apiPrefix`, so it works
|
|
423
|
+
* even when constructed with a `userToken` auth that has no real project id yet.
|
|
424
|
+
*/
|
|
425
|
+
var CliResource = class extends BaseResource {
|
|
426
|
+
constructor(baseHttpUrl, auth) {
|
|
427
|
+
super(baseHttpUrl, auth);
|
|
428
|
+
}
|
|
429
|
+
/** Workspaces + projects the authenticated user can access. */
|
|
430
|
+
async listProjects() {
|
|
431
|
+
const response = await fetch(`${this.baseHttpUrl}/v1/cli/projects`, {
|
|
432
|
+
method: "GET",
|
|
433
|
+
headers: {
|
|
434
|
+
Authorization: `Bearer ${this.credential}`,
|
|
435
|
+
Accept: "application/json"
|
|
436
|
+
}
|
|
437
|
+
});
|
|
438
|
+
if (!response.ok) await this.handleError(response);
|
|
439
|
+
const body = await response.json();
|
|
440
|
+
return Array.isArray(body?.projects) ? body.projects : [];
|
|
441
|
+
}
|
|
442
|
+
};
|
|
440
443
|
function initializeLogger$1(options) {
|
|
441
444
|
const colorize = options?.colorize ?? true;
|
|
442
445
|
const level = options?.level ?? process.env.LMNR_LOG_LEVEL?.toLowerCase()?.trim() ?? "info";
|
|
443
|
-
return (0, pino.default)({ level }, (0, pino_pretty.PinoPretty)({
|
|
446
|
+
return (0, pino$3.default)({ level }, (0, pino_pretty.PinoPretty)({
|
|
444
447
|
colorize,
|
|
445
448
|
minimumLevel: level
|
|
446
449
|
}));
|
|
447
450
|
}
|
|
448
|
-
const logger$
|
|
451
|
+
const logger$4$1 = initializeLogger$1();
|
|
449
452
|
const isStringUUID = (id) => /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(id);
|
|
450
|
-
const newUUID
|
|
453
|
+
const newUUID = () => {
|
|
451
454
|
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") return crypto.randomUUID();
|
|
452
|
-
else return
|
|
455
|
+
else return v4();
|
|
453
456
|
};
|
|
454
457
|
const otelSpanIdToUUID = (spanId) => {
|
|
455
458
|
let id = spanId.toLowerCase();
|
|
456
459
|
if (id.startsWith("0x")) id = id.slice(2);
|
|
457
|
-
if (id.length !== 16) logger$
|
|
460
|
+
if (id.length !== 16) logger$4$1.warn(`Span ID ${spanId} is not 16 hex chars long. This is not a valid OpenTelemetry span ID.`);
|
|
458
461
|
if (!/^[0-9a-f]+$/.test(id)) {
|
|
459
|
-
logger$
|
|
460
|
-
return newUUID
|
|
462
|
+
logger$4$1.error(`Span ID ${spanId} is not a valid hex string. Generating a random UUID instead.`);
|
|
463
|
+
return newUUID();
|
|
461
464
|
}
|
|
462
465
|
return id.padStart(32, "0").replace(/^([0-9a-f]{8})([0-9a-f]{4})([0-9a-f]{4})([0-9a-f]{4})([0-9a-f]{12})$/, "$1-$2-$3-$4-$5");
|
|
463
466
|
};
|
|
464
467
|
const otelTraceIdToUUID = (traceId) => {
|
|
465
468
|
let id = traceId.toLowerCase();
|
|
466
469
|
if (id.startsWith("0x")) id = id.slice(2);
|
|
467
|
-
if (id.length !== 32) logger$
|
|
470
|
+
if (id.length !== 32) logger$4$1.warn(`Trace ID ${traceId} is not 32 hex chars long. This is not a valid OpenTelemetry trace ID.`);
|
|
468
471
|
if (!/^[0-9a-f]+$/.test(id)) {
|
|
469
|
-
logger$
|
|
470
|
-
return newUUID
|
|
472
|
+
logger$4$1.error(`Trace ID ${traceId} is not a valid hex string. Generating a random UUID instead.`);
|
|
473
|
+
return newUUID();
|
|
471
474
|
}
|
|
472
475
|
return id.replace(/^([0-9a-f]{8})([0-9a-f]{4})([0-9a-f]{4})([0-9a-f]{4})([0-9a-f]{12})$/, "$1-$2-$3-$4-$5");
|
|
473
476
|
};
|
|
@@ -490,16 +493,16 @@ const loadEnv = (options) => {
|
|
|
490
493
|
const verbose = ["debug", "trace"].includes(logLevel.trim().toLowerCase());
|
|
491
494
|
const quiet = options?.quiet ?? !verbose;
|
|
492
495
|
(0, import_main.config)({
|
|
493
|
-
path: options?.paths ?? envFiles.map((envFile) => path.resolve(envDir, envFile)),
|
|
496
|
+
path: options?.paths ?? envFiles.map((envFile) => path$2.resolve(envDir, envFile)),
|
|
494
497
|
quiet
|
|
495
498
|
});
|
|
496
499
|
};
|
|
497
|
-
const logger$
|
|
500
|
+
const logger$3$1 = initializeLogger$1();
|
|
498
501
|
const DEFAULT_DATASET_PULL_LIMIT = 100;
|
|
499
502
|
const DEFAULT_DATASET_PUSH_BATCH_SIZE$1 = 100;
|
|
500
503
|
var DatasetsResource = class extends BaseResource {
|
|
501
|
-
constructor(baseHttpUrl,
|
|
502
|
-
super(baseHttpUrl,
|
|
504
|
+
constructor(baseHttpUrl, auth) {
|
|
505
|
+
super(baseHttpUrl, auth);
|
|
503
506
|
}
|
|
504
507
|
/**
|
|
505
508
|
* List all datasets.
|
|
@@ -507,7 +510,7 @@ var DatasetsResource = class extends BaseResource {
|
|
|
507
510
|
* @returns {Promise<Dataset[]>} Array of datasets
|
|
508
511
|
*/
|
|
509
512
|
async listDatasets() {
|
|
510
|
-
const response = await fetch(this.baseHttpUrl + "/
|
|
513
|
+
const response = await fetch(this.baseHttpUrl + this.apiPrefix + "/datasets", {
|
|
511
514
|
method: "GET",
|
|
512
515
|
headers: this.headers()
|
|
513
516
|
});
|
|
@@ -522,7 +525,7 @@ var DatasetsResource = class extends BaseResource {
|
|
|
522
525
|
*/
|
|
523
526
|
async getDatasetByName(name) {
|
|
524
527
|
const params = new URLSearchParams({ name });
|
|
525
|
-
const response = await fetch(this.baseHttpUrl +
|
|
528
|
+
const response = await fetch(this.baseHttpUrl + `${this.apiPrefix}/datasets?${params.toString()}`, {
|
|
526
529
|
method: "GET",
|
|
527
530
|
headers: this.headers()
|
|
528
531
|
});
|
|
@@ -549,9 +552,9 @@ var DatasetsResource = class extends BaseResource {
|
|
|
549
552
|
let response;
|
|
550
553
|
for (let i = 0; i < points.length; i += batchSize) {
|
|
551
554
|
const batchNum = Math.floor(i / batchSize) + 1;
|
|
552
|
-
logger$
|
|
555
|
+
logger$3$1.debug(`Pushing batch ${batchNum} of ${totalBatches}`);
|
|
553
556
|
const batch = points.slice(i, i + batchSize);
|
|
554
|
-
const fetchResponse = await fetch(this.baseHttpUrl + "/
|
|
557
|
+
const fetchResponse = await fetch(this.baseHttpUrl + this.apiPrefix + "/datasets/datapoints", {
|
|
555
558
|
method: "POST",
|
|
556
559
|
headers: this.headers(),
|
|
557
560
|
body: JSON.stringify({
|
|
@@ -589,7 +592,7 @@ var DatasetsResource = class extends BaseResource {
|
|
|
589
592
|
if (name) paramsObj.name = name;
|
|
590
593
|
else paramsObj.datasetId = id;
|
|
591
594
|
const params = new URLSearchParams(paramsObj);
|
|
592
|
-
const response = await fetch(this.baseHttpUrl +
|
|
595
|
+
const response = await fetch(this.baseHttpUrl + `${this.apiPrefix}/datasets/datapoints?${params.toString()}`, {
|
|
593
596
|
method: "GET",
|
|
594
597
|
headers: this.headers()
|
|
595
598
|
});
|
|
@@ -597,11 +600,11 @@ var DatasetsResource = class extends BaseResource {
|
|
|
597
600
|
return response.json();
|
|
598
601
|
}
|
|
599
602
|
};
|
|
600
|
-
const logger$
|
|
603
|
+
const logger$2$1 = initializeLogger$1();
|
|
601
604
|
const INITIAL_EVALUATION_DATAPOINT_MAX_DATA_LENGTH = 16e6;
|
|
602
605
|
var EvalsResource = class extends BaseResource {
|
|
603
|
-
constructor(baseHttpUrl,
|
|
604
|
-
super(baseHttpUrl,
|
|
606
|
+
constructor(baseHttpUrl, auth) {
|
|
607
|
+
super(baseHttpUrl, auth);
|
|
605
608
|
}
|
|
606
609
|
/**
|
|
607
610
|
* Initialize an evaluation.
|
|
@@ -655,14 +658,14 @@ var EvalsResource = class extends BaseResource {
|
|
|
655
658
|
* @returns {Promise<StringUUID>} The datapoint ID
|
|
656
659
|
*/
|
|
657
660
|
async createDatapoint({ evalId, data, target, metadata, index, traceId }) {
|
|
658
|
-
const datapointId = newUUID
|
|
661
|
+
const datapointId = newUUID();
|
|
659
662
|
const partialDatapoint = {
|
|
660
663
|
id: datapointId,
|
|
661
664
|
data,
|
|
662
665
|
target,
|
|
663
666
|
index: index ?? 0,
|
|
664
|
-
traceId: traceId ?? newUUID
|
|
665
|
-
executorSpanId: newUUID
|
|
667
|
+
traceId: traceId ?? newUUID(),
|
|
668
|
+
executorSpanId: newUUID(),
|
|
666
669
|
metadata
|
|
667
670
|
};
|
|
668
671
|
await this.saveDatapoints({
|
|
@@ -733,7 +736,7 @@ var EvalsResource = class extends BaseResource {
|
|
|
733
736
|
* @returns {Promise<GetDatapointsResponse>} Response from the datapoint retrieval
|
|
734
737
|
*/
|
|
735
738
|
async getDatapoints({ datasetName, offset, limit }) {
|
|
736
|
-
logger$
|
|
739
|
+
logger$2$1.warn("evals.getDatapoints() is deprecated. Use client.datasets.pull() instead.");
|
|
737
740
|
const params = new URLSearchParams({
|
|
738
741
|
name: datasetName,
|
|
739
742
|
offset: offset.toString(),
|
|
@@ -750,7 +753,7 @@ var EvalsResource = class extends BaseResource {
|
|
|
750
753
|
let length = initialLength;
|
|
751
754
|
let lastResponse = null;
|
|
752
755
|
for (let i = 0; i < maxRetries; i++) {
|
|
753
|
-
logger$
|
|
756
|
+
logger$2$1.debug(`Retrying save datapoints... ${i + 1} of ${maxRetries}, length: ${length}`);
|
|
754
757
|
const response = await fetch(this.baseHttpUrl + `/v1/evals/${evalId}/datapoints`, {
|
|
755
758
|
method: "POST",
|
|
756
759
|
headers: this.headers(),
|
|
@@ -771,17 +774,12 @@ var EvalsResource = class extends BaseResource {
|
|
|
771
774
|
if (lastResponse && !lastResponse.ok) await this.handleError(lastResponse);
|
|
772
775
|
}
|
|
773
776
|
};
|
|
774
|
-
var EvaluatorScoreSourceType = /* @__PURE__ */ function(EvaluatorScoreSourceType) {
|
|
775
|
-
EvaluatorScoreSourceType["Evaluator"] = "Evaluator";
|
|
776
|
-
EvaluatorScoreSourceType["Code"] = "Code";
|
|
777
|
-
return EvaluatorScoreSourceType;
|
|
778
|
-
}(EvaluatorScoreSourceType || {});
|
|
779
777
|
/**
|
|
780
778
|
* Resource for creating evaluator scores
|
|
781
779
|
*/
|
|
782
780
|
var EvaluatorsResource = class extends BaseResource {
|
|
783
|
-
constructor(baseHttpUrl,
|
|
784
|
-
super(baseHttpUrl,
|
|
781
|
+
constructor(baseHttpUrl, auth) {
|
|
782
|
+
super(baseHttpUrl, auth);
|
|
785
783
|
}
|
|
786
784
|
/**
|
|
787
785
|
* Create a score for a span or trace
|
|
@@ -814,25 +812,21 @@ var EvaluatorsResource = class extends BaseResource {
|
|
|
814
812
|
async score(options) {
|
|
815
813
|
const { name, metadata, score } = options;
|
|
816
814
|
let payload;
|
|
817
|
-
if ("traceId" in options && options.traceId) {
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
source: EvaluatorScoreSourceType.Code,
|
|
833
|
-
spanId: formattedSpanId
|
|
834
|
-
};
|
|
835
|
-
} else throw new Error("Either 'traceId' or 'spanId' must be provided.");
|
|
815
|
+
if ("traceId" in options && options.traceId) payload = {
|
|
816
|
+
name,
|
|
817
|
+
metadata,
|
|
818
|
+
score,
|
|
819
|
+
source: "Code",
|
|
820
|
+
traceId: isStringUUID(options.traceId) ? options.traceId : otelTraceIdToUUID(options.traceId)
|
|
821
|
+
};
|
|
822
|
+
else if ("spanId" in options && options.spanId) payload = {
|
|
823
|
+
name,
|
|
824
|
+
metadata,
|
|
825
|
+
score,
|
|
826
|
+
source: "Code",
|
|
827
|
+
spanId: isStringUUID(options.spanId) ? options.spanId : otelSpanIdToUUID(options.spanId)
|
|
828
|
+
};
|
|
829
|
+
else throw new Error("Either 'traceId' or 'spanId' must be provided.");
|
|
836
830
|
const response = await fetch(this.baseHttpUrl + "/v1/evaluators/score", {
|
|
837
831
|
method: "POST",
|
|
838
832
|
headers: this.headers(),
|
|
@@ -841,70 +835,139 @@ var EvaluatorsResource = class extends BaseResource {
|
|
|
841
835
|
if (!response.ok) await this.handleError(response);
|
|
842
836
|
}
|
|
843
837
|
};
|
|
838
|
+
const logger$1$1 = initializeLogger$1();
|
|
839
|
+
/**
|
|
840
|
+
* Map the opaque HIT `response` payload onto a {@link CachedSpan} the provider
|
|
841
|
+
* wrappers can replay. The server-side shape of `response` is not yet frozen
|
|
842
|
+
* (app-server plan 01 leaves it as a `serde_json::Value`), so this stays
|
|
843
|
+
* deliberately tolerant: the whole payload is serialized into `output` (the only
|
|
844
|
+
* field the AI SDK wrapper's `parseCachedSpan` actually reads, via
|
|
845
|
+
* `JSON.parse`), and a `finishReason` is surfaced into `attributes` when the
|
|
846
|
+
* payload carries one. `name`/`input` are irrelevant to replay and left empty.
|
|
847
|
+
*/
|
|
848
|
+
const toCachedSpan = (response) => {
|
|
849
|
+
const output = typeof response === "string" ? response : JSON.stringify(response ?? null);
|
|
850
|
+
const attributes = {};
|
|
851
|
+
if (response !== null && typeof response === "object" && typeof response.finishReason === "string") attributes["ai.response.finishReason"] = response.finishReason;
|
|
852
|
+
return {
|
|
853
|
+
name: "",
|
|
854
|
+
input: "",
|
|
855
|
+
output,
|
|
856
|
+
attributes
|
|
857
|
+
};
|
|
858
|
+
};
|
|
844
859
|
var RolloutSessionsResource = class extends BaseResource {
|
|
845
|
-
constructor(baseHttpUrl,
|
|
846
|
-
super(baseHttpUrl,
|
|
860
|
+
constructor(baseHttpUrl, auth) {
|
|
861
|
+
super(baseHttpUrl, auth);
|
|
847
862
|
}
|
|
848
863
|
/**
|
|
849
|
-
*
|
|
850
|
-
*
|
|
864
|
+
* Idempotently register (upsert) a debug session on the backend, keyed on the
|
|
865
|
+
* SDK-supplied session id. The backend stores the row so the session is
|
|
866
|
+
* visible in the UI; a null/omitted name never clobbers a name set elsewhere.
|
|
867
|
+
*
|
|
868
|
+
* Returns the backend-resolved `projectId` (derived from the API key) so the
|
|
869
|
+
* caller can build the debugger URL; null if the body can't be parsed.
|
|
851
870
|
*/
|
|
852
|
-
async
|
|
853
|
-
const response = await fetch(`${this.baseHttpUrl}/
|
|
871
|
+
async register({ sessionId, name }) {
|
|
872
|
+
const response = await fetch(`${this.baseHttpUrl}${this.apiPrefix}/rollouts/${sessionId}`, {
|
|
854
873
|
method: "POST",
|
|
855
|
-
headers:
|
|
856
|
-
|
|
857
|
-
"Accept": "text/event-stream"
|
|
858
|
-
},
|
|
859
|
-
body: JSON.stringify({
|
|
860
|
-
name,
|
|
861
|
-
params
|
|
862
|
-
}),
|
|
863
|
-
signal
|
|
864
|
-
});
|
|
865
|
-
if (!response.ok) throw new Error(`SSE connection failed: ${response.status} ${response.statusText}`);
|
|
866
|
-
if (!response.body) throw new Error("No response body");
|
|
867
|
-
return response;
|
|
868
|
-
}
|
|
869
|
-
async delete({ sessionId }) {
|
|
870
|
-
const response = await fetch(`${this.baseHttpUrl}/v1/rollouts/${sessionId}`, {
|
|
871
|
-
method: "DELETE",
|
|
872
|
-
headers: this.headers()
|
|
874
|
+
headers: this.headers(),
|
|
875
|
+
body: JSON.stringify({ name })
|
|
873
876
|
});
|
|
874
877
|
if (!response.ok) await this.handleError(response);
|
|
878
|
+
try {
|
|
879
|
+
return (await response.json()).projectId ?? null;
|
|
880
|
+
} catch (e) {
|
|
881
|
+
logger$1$1.warn(`Failed to parse rollout register response: ${errorMessage(e)}`);
|
|
882
|
+
return null;
|
|
883
|
+
}
|
|
875
884
|
}
|
|
876
|
-
|
|
877
|
-
|
|
885
|
+
/**
|
|
886
|
+
* Rename an existing debug session. Update-only: the backend returns 404 (and
|
|
887
|
+
* this throws) when the session id is unknown for the project, so a mistyped
|
|
888
|
+
* id surfaces as an error rather than silently creating a session. Creation
|
|
889
|
+
* stays the SDK's job via {@link register}.
|
|
890
|
+
*/
|
|
891
|
+
async setName({ sessionId, name }) {
|
|
892
|
+
const response = await fetch(`${this.baseHttpUrl}${this.apiPrefix}/rollouts/${sessionId}/name`, {
|
|
878
893
|
method: "PATCH",
|
|
879
894
|
headers: this.headers(),
|
|
880
|
-
body: JSON.stringify({
|
|
895
|
+
body: JSON.stringify({ name })
|
|
881
896
|
});
|
|
882
897
|
if (!response.ok) await this.handleError(response);
|
|
883
898
|
}
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
899
|
+
/**
|
|
900
|
+
* Look up the debug-replay cache for a single LLM call (debug-replay v2).
|
|
901
|
+
*
|
|
902
|
+
* The server is keyed by `inputHash` (hex blake3 of the canonicalized,
|
|
903
|
+
* system-stripped input messages). It returns one of three outcomes:
|
|
904
|
+
* - `{ outcome: "hit", response }` — a cached response to replay.
|
|
905
|
+
* - `{ outcome: "miss" }` — no entry; caller latches live mode.
|
|
906
|
+
* - `{ outcome: "live" }` — run this call live (COLD degrade).
|
|
907
|
+
*
|
|
908
|
+
* Error posture: a non-OK response or a transport error degrades to
|
|
909
|
+
* `{ kind: "live" }` for THIS call only — it never throws and never latches
|
|
910
|
+
* the process-wide live flag (only a real MISS does that). This keeps a flaky
|
|
911
|
+
* cache backend from turning a replay into a crash.
|
|
912
|
+
*/
|
|
913
|
+
async cache({ sessionId, replayTraceId, cacheUntil, inputHash }) {
|
|
914
|
+
let response;
|
|
915
|
+
try {
|
|
916
|
+
response = await fetch(`${this.baseHttpUrl}${this.apiPrefix}/rollouts/${sessionId}/cache`, {
|
|
917
|
+
method: "POST",
|
|
918
|
+
headers: this.headers(),
|
|
919
|
+
body: JSON.stringify({
|
|
920
|
+
replayTraceId,
|
|
921
|
+
cacheUntil,
|
|
922
|
+
inputHash
|
|
923
|
+
})
|
|
924
|
+
});
|
|
925
|
+
} catch (e) {
|
|
926
|
+
logger$1$1.warn(`Debug cache lookup failed, running live: ${errorMessage(e)}`);
|
|
927
|
+
return { kind: "live" };
|
|
928
|
+
}
|
|
929
|
+
if (!response.ok) {
|
|
930
|
+
logger$1$1.warn(`Debug cache lookup returned ${response.status}, running live`);
|
|
931
|
+
return { kind: "live" };
|
|
932
|
+
}
|
|
933
|
+
let body;
|
|
934
|
+
try {
|
|
935
|
+
body = await response.json();
|
|
936
|
+
} catch (e) {
|
|
937
|
+
logger$1$1.warn(`Failed to parse debug cache response, running live: ${errorMessage(e)}`);
|
|
938
|
+
return { kind: "live" };
|
|
939
|
+
}
|
|
940
|
+
switch (body.outcome) {
|
|
941
|
+
case "hit":
|
|
942
|
+
if (body.response === null || body.response === void 0) {
|
|
943
|
+
logger$1$1.warn("Debug cache HIT had no response payload, running live");
|
|
944
|
+
return { kind: "live" };
|
|
945
|
+
}
|
|
946
|
+
return {
|
|
947
|
+
kind: "hit",
|
|
948
|
+
cached: toCachedSpan(body.response)
|
|
949
|
+
};
|
|
950
|
+
case "miss": return { kind: "miss" };
|
|
951
|
+
case "live": return { kind: "live" };
|
|
952
|
+
default:
|
|
953
|
+
logger$1$1.warn(`Unknown debug cache outcome "${body.outcome}", running live`);
|
|
954
|
+
return { kind: "live" };
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
async delete({ sessionId }) {
|
|
958
|
+
const response = await fetch(`${this.baseHttpUrl}${this.apiPrefix}/rollouts/${sessionId}`, {
|
|
959
|
+
method: "DELETE",
|
|
960
|
+
headers: this.headers()
|
|
898
961
|
});
|
|
899
962
|
if (!response.ok) await this.handleError(response);
|
|
900
963
|
}
|
|
901
964
|
};
|
|
902
965
|
var SqlResource = class extends BaseResource {
|
|
903
|
-
constructor(baseHttpUrl,
|
|
904
|
-
super(baseHttpUrl,
|
|
966
|
+
constructor(baseHttpUrl, auth) {
|
|
967
|
+
super(baseHttpUrl, auth);
|
|
905
968
|
}
|
|
906
969
|
async query(sql, parameters = {}) {
|
|
907
|
-
const response = await fetch(`${this.baseHttpUrl}/
|
|
970
|
+
const response = await fetch(`${this.baseHttpUrl}${this.apiPrefix}/sql/query`, {
|
|
908
971
|
method: "POST",
|
|
909
972
|
headers: { ...this.headers() },
|
|
910
973
|
body: JSON.stringify({
|
|
@@ -919,8 +982,8 @@ var SqlResource = class extends BaseResource {
|
|
|
919
982
|
/** Resource for tagging traces. */
|
|
920
983
|
var TagsResource = class extends BaseResource {
|
|
921
984
|
/** Resource for tagging traces. */
|
|
922
|
-
constructor(baseHttpUrl,
|
|
923
|
-
super(baseHttpUrl,
|
|
985
|
+
constructor(baseHttpUrl, auth) {
|
|
986
|
+
super(baseHttpUrl, auth);
|
|
924
987
|
}
|
|
925
988
|
/**
|
|
926
989
|
* Tag a trace with a list of tags. Note that the trace must be ended before
|
|
@@ -971,23 +1034,127 @@ var TagsResource = class extends BaseResource {
|
|
|
971
1034
|
return response.json();
|
|
972
1035
|
}
|
|
973
1036
|
};
|
|
974
|
-
|
|
975
|
-
|
|
1037
|
+
/** Resource for post-factum operations on existing traces. */
|
|
1038
|
+
const logger$5 = initializeLogger$1();
|
|
1039
|
+
var TracesResource = class extends BaseResource {
|
|
1040
|
+
/** Resource for post-factum operations on existing traces. */
|
|
1041
|
+
constructor(baseHttpUrl, auth) {
|
|
1042
|
+
super(baseHttpUrl, auth);
|
|
1043
|
+
}
|
|
1044
|
+
/**
|
|
1045
|
+
* Push a metadata patch to an existing trace.
|
|
1046
|
+
*
|
|
1047
|
+
* The patch is shallow-merged server-side into the trace's existing metadata
|
|
1048
|
+
* (`existing || patch`, last-write-wins per top-level key). Useful for
|
|
1049
|
+
* attaching post-factum signals — quality scores, human edits, triage labels —
|
|
1050
|
+
* to a trace that has already finished. The patch does NOT extend `endTime`
|
|
1051
|
+
* or change tokens / cost / top span / tags / span names. `numSpans` is
|
|
1052
|
+
* incremented by 1 (paid by the virtual span that carried the patch through
|
|
1053
|
+
* the ingestion queue) so the new ClickHouse row beats the prior version on
|
|
1054
|
+
* `ReplacingMergeTree(numSpans)`. No row is added to the `spans` table.
|
|
1055
|
+
*
|
|
1056
|
+
* Compared to `Laminar.setTraceMetadata` (which sets metadata on the
|
|
1057
|
+
* currently in-flight trace via OpenTelemetry attributes), this method
|
|
1058
|
+
* operates on a finished trace by trace id, so it must be called after the
|
|
1059
|
+
* trace has been flushed.
|
|
1060
|
+
*
|
|
1061
|
+
* A 404 response (the trace was not found in the project — typically because
|
|
1062
|
+
* it has not been flushed yet) is logged as a warning and the call returns
|
|
1063
|
+
* without throwing, since the 404 may be expected when pushing too soon
|
|
1064
|
+
* after the trace run. Pass `failOnNotFound: true` to throw instead (e.g.
|
|
1065
|
+
* CLI callers that must report the failure). Any other non-OK status throws.
|
|
1066
|
+
*
|
|
1067
|
+
* @param traceId - The trace id to push metadata to. Accepts a UUID string
|
|
1068
|
+
* or a 32-char OTel hex trace id.
|
|
1069
|
+
* @param metadata - The metadata patch. Top-level keys are merged into the
|
|
1070
|
+
* trace's existing metadata. Must be non-empty (the server rejects empty
|
|
1071
|
+
* patches with 400).
|
|
1072
|
+
* @param options - `failOnNotFound`: throw on 404 instead of warn-and-return.
|
|
1073
|
+
* @example
|
|
1074
|
+
* ```typescript
|
|
1075
|
+
* import { Laminar, observe, LaminarClient } from "@lmnr-ai/lmnr";
|
|
1076
|
+
* Laminar.initialize();
|
|
1077
|
+
* const client = new LaminarClient();
|
|
1078
|
+
*
|
|
1079
|
+
* let traceId: string | null = null;
|
|
1080
|
+
* await observe({ name: "generate" }, async () => {
|
|
1081
|
+
* traceId = await Laminar.getTraceId();
|
|
1082
|
+
* });
|
|
1083
|
+
* await Laminar.flush();
|
|
1084
|
+
*
|
|
1085
|
+
* if (traceId) {
|
|
1086
|
+
* await client.traces.pushMetadata(traceId, {
|
|
1087
|
+
* score: 0.85,
|
|
1088
|
+
* reviewer: "alice",
|
|
1089
|
+
* needsReview: false,
|
|
1090
|
+
* });
|
|
1091
|
+
* }
|
|
1092
|
+
* ```
|
|
1093
|
+
*/
|
|
1094
|
+
async pushMetadata(traceId, metadata, options) {
|
|
1095
|
+
if (!metadata || Object.keys(metadata).length === 0) throw new Error("metadata must be a non-empty object");
|
|
1096
|
+
const formattedTraceId = isStringUUID(traceId) ? traceId : otelTraceIdToUUID(traceId);
|
|
1097
|
+
const url = this.baseHttpUrl + this.apiPrefix + "/traces/metadata";
|
|
1098
|
+
const response = await fetch(url, {
|
|
1099
|
+
method: "POST",
|
|
1100
|
+
headers: this.headers(),
|
|
1101
|
+
body: JSON.stringify({
|
|
1102
|
+
traceId: formattedTraceId,
|
|
1103
|
+
metadata
|
|
1104
|
+
})
|
|
1105
|
+
});
|
|
1106
|
+
if (response.status === 404) {
|
|
1107
|
+
const message = `Trace ${formattedTraceId} not found. The trace may not have been flushed yet — call await Laminar.flush() and retry.`;
|
|
1108
|
+
if (options?.failOnNotFound) throw new Error(message);
|
|
1109
|
+
logger$5.warn(message);
|
|
1110
|
+
return;
|
|
1111
|
+
}
|
|
1112
|
+
if (!response.ok) await this.handleError(response);
|
|
1113
|
+
}
|
|
1114
|
+
};
|
|
1115
|
+
var LaminarClient = class LaminarClient {
|
|
1116
|
+
constructor({ baseUrl, port, auth, projectApiKey, cliUserProjectId } = {}) {
|
|
976
1117
|
loadEnv();
|
|
977
|
-
this.
|
|
1118
|
+
this.auth = LaminarClient.normalizeAuth(auth, projectApiKey, cliUserProjectId);
|
|
978
1119
|
const httpPort = port ?? (baseUrl?.match(/:\d{1,5}$/g) ? parseInt(baseUrl.match(/:\d{1,5}$/g)[0].slice(1)) : 443);
|
|
979
|
-
|
|
980
|
-
this.
|
|
981
|
-
this.
|
|
982
|
-
this.
|
|
983
|
-
this.
|
|
984
|
-
this.
|
|
985
|
-
this.
|
|
986
|
-
this.
|
|
1120
|
+
const baseUrlNoPort = (baseUrl ?? process.env.LMNR_BASE_URL)?.replace(/\/$/, "").replace(/:\d{1,5}$/g, "");
|
|
1121
|
+
this.baseUrl = `${baseUrlNoPort ?? "https://api.lmnr.ai"}:${httpPort}`;
|
|
1122
|
+
this._browserEvents = new BrowserEventsResource(this.baseUrl, this.auth);
|
|
1123
|
+
this._cli = new CliResource(this.baseUrl, this.auth);
|
|
1124
|
+
this._datasets = new DatasetsResource(this.baseUrl, this.auth);
|
|
1125
|
+
this._evals = new EvalsResource(this.baseUrl, this.auth);
|
|
1126
|
+
this._evaluators = new EvaluatorsResource(this.baseUrl, this.auth);
|
|
1127
|
+
this._rolloutSessions = new RolloutSessionsResource(this.baseUrl, this.auth);
|
|
1128
|
+
this._sql = new SqlResource(this.baseUrl, this.auth);
|
|
1129
|
+
this._tags = new TagsResource(this.baseUrl, this.auth);
|
|
1130
|
+
this._traces = new TracesResource(this.baseUrl, this.auth);
|
|
1131
|
+
}
|
|
1132
|
+
/**
|
|
1133
|
+
* Normalize the constructor's auth inputs into a {@link LaminarAuth} union.
|
|
1134
|
+
* Precedence: an explicit `auth` wins; otherwise the legacy
|
|
1135
|
+
* `projectApiKey` (+ optional `cliUserProjectId`) is mapped — a present
|
|
1136
|
+
* `cliUserProjectId` selects the user-token surface, otherwise the project
|
|
1137
|
+
* key surface. Falls back to `LMNR_PROJECT_API_KEY` as a project key.
|
|
1138
|
+
*/
|
|
1139
|
+
static normalizeAuth(auth, projectApiKey, cliUserProjectId) {
|
|
1140
|
+
if (auth) return auth;
|
|
1141
|
+
const key = projectApiKey ?? process.env.LMNR_PROJECT_API_KEY;
|
|
1142
|
+
if (cliUserProjectId) return {
|
|
1143
|
+
type: "userToken",
|
|
1144
|
+
token: key,
|
|
1145
|
+
projectId: cliUserProjectId
|
|
1146
|
+
};
|
|
1147
|
+
return {
|
|
1148
|
+
type: "apiKey",
|
|
1149
|
+
key
|
|
1150
|
+
};
|
|
987
1151
|
}
|
|
988
1152
|
get browserEvents() {
|
|
989
1153
|
return this._browserEvents;
|
|
990
1154
|
}
|
|
1155
|
+
get cli() {
|
|
1156
|
+
return this._cli;
|
|
1157
|
+
}
|
|
991
1158
|
get datasets() {
|
|
992
1159
|
return this._datasets;
|
|
993
1160
|
}
|
|
@@ -1006,6 +1173,9 @@ var LaminarClient = class {
|
|
|
1006
1173
|
get tags() {
|
|
1007
1174
|
return this._tags;
|
|
1008
1175
|
}
|
|
1176
|
+
get traces() {
|
|
1177
|
+
return this._traces;
|
|
1178
|
+
}
|
|
1009
1179
|
};
|
|
1010
1180
|
//#endregion
|
|
1011
1181
|
//#region src/utils/logger.ts
|
|
@@ -1019,94 +1189,544 @@ function initializeLogger(options) {
|
|
|
1019
1189
|
}));
|
|
1020
1190
|
}
|
|
1021
1191
|
//#endregion
|
|
1022
|
-
//#region src/utils/
|
|
1023
|
-
const logger$5 = initializeLogger();
|
|
1192
|
+
//#region src/utils/output.ts
|
|
1024
1193
|
/**
|
|
1025
|
-
*
|
|
1194
|
+
* Write structured JSON to stdout. Use this for machine-readable output
|
|
1195
|
+
* when --json is set.
|
|
1026
1196
|
*/
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
".json",
|
|
1031
|
-
".csv",
|
|
1032
|
-
".jsonl"
|
|
1033
|
-
].includes(ext);
|
|
1034
|
-
};
|
|
1197
|
+
function outputJson(data) {
|
|
1198
|
+
console.log(JSON.stringify(data));
|
|
1199
|
+
}
|
|
1035
1200
|
/**
|
|
1036
|
-
*
|
|
1037
|
-
*
|
|
1201
|
+
* Write a JSON error to stdout and exit with code 1.
|
|
1202
|
+
* Use this in --json mode so agents can parse the failure.
|
|
1038
1203
|
*/
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
else if (recursive && entryStats.isDirectory()) {
|
|
1052
|
-
const subFiles = await collectFiles([fullPath], true);
|
|
1053
|
-
collectedFiles.push(...subFiles);
|
|
1054
|
-
}
|
|
1055
|
-
}
|
|
1056
|
-
}
|
|
1057
|
-
} catch (error) {
|
|
1058
|
-
logger$5.warn(`Path does not exist or is not accessible: ${filepath}. Error: ${error instanceof Error ? error.message : String(error)}`);
|
|
1059
|
-
}
|
|
1060
|
-
return collectedFiles;
|
|
1061
|
-
};
|
|
1204
|
+
function outputJsonError(error, exitCode = 1) {
|
|
1205
|
+
console.log(JSON.stringify({ error: errorMessage(error) }));
|
|
1206
|
+
process.exit(exitCode);
|
|
1207
|
+
}
|
|
1208
|
+
//#endregion
|
|
1209
|
+
//#region src/constants.ts
|
|
1210
|
+
const DEFAULT_FRONTEND_URL$1 = "https://www.laminar.sh";
|
|
1211
|
+
const DEFAULT_BASE_URL$1 = "https://api.lmnr.ai";
|
|
1212
|
+
//#endregion
|
|
1213
|
+
//#region src/utils/project-link.ts
|
|
1214
|
+
const LINK_DIR = ".lmnr";
|
|
1215
|
+
const LINK_FILE = "project.json";
|
|
1062
1216
|
/**
|
|
1063
|
-
*
|
|
1217
|
+
* Find the nearest `.lmnr/project.json`, walking up from `startDir` to the
|
|
1218
|
+
* filesystem root (so commands work from subdirectories of a linked project).
|
|
1219
|
+
* Returns null if none is found.
|
|
1064
1220
|
*/
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
const
|
|
1068
|
-
|
|
1069
|
-
|
|
1221
|
+
async function readProjectLink(startDir = process.cwd()) {
|
|
1222
|
+
let dir = startDir;
|
|
1223
|
+
const root = (0, node_path.parse)(dir).root;
|
|
1224
|
+
while (true) {
|
|
1225
|
+
const candidate = (0, node_path.join)(dir, LINK_DIR, LINK_FILE);
|
|
1226
|
+
try {
|
|
1227
|
+
const parsed = JSON.parse(await (0, node_fs_promises.readFile)(candidate, "utf8"));
|
|
1228
|
+
if (parsed && typeof parsed.projectId === "string" && parsed.projectId.length > 0) return parsed;
|
|
1229
|
+
} catch {}
|
|
1230
|
+
const parent = (0, node_path.dirname)(dir);
|
|
1231
|
+
if (parent === dir || dir === root) break;
|
|
1232
|
+
dir = parent;
|
|
1233
|
+
}
|
|
1234
|
+
return null;
|
|
1235
|
+
}
|
|
1236
|
+
/** Write `.lmnr/project.json` under `dir` (default cwd). Returns the file path. */
|
|
1237
|
+
async function writeProjectLink(link, dir = process.cwd()) {
|
|
1238
|
+
const linkDir = (0, node_path.join)(dir, LINK_DIR);
|
|
1239
|
+
await (0, node_fs_promises.mkdir)(linkDir, { recursive: true });
|
|
1240
|
+
const path = (0, node_path.join)(linkDir, LINK_FILE);
|
|
1241
|
+
await (0, node_fs_promises.writeFile)(path, JSON.stringify(link, null, 2) + "\n", "utf8");
|
|
1242
|
+
return path;
|
|
1243
|
+
}
|
|
1244
|
+
function globalLmnrDirectory() {
|
|
1245
|
+
const xdg = process.env.XDG_CONFIG_HOME?.trim();
|
|
1246
|
+
if (xdg && xdg.length > 0) return (0, node_path.join)(xdg, "lmnr");
|
|
1247
|
+
const appData = process.env.APPDATA?.trim();
|
|
1248
|
+
if (process.platform === "win32" && appData && appData.length > 0) return (0, node_path.join)(appData, "lmnr");
|
|
1249
|
+
return (0, node_path.join)((0, node_os.homedir)(), ".config", "lmnr");
|
|
1250
|
+
}
|
|
1251
|
+
function credentialsPath() {
|
|
1252
|
+
return (0, node_path.join)(globalLmnrDirectory(), "credentials.json");
|
|
1253
|
+
}
|
|
1070
1254
|
/**
|
|
1071
|
-
*
|
|
1255
|
+
* Read the credentials file. Returns null when missing or not the current flat
|
|
1256
|
+
* v1 shape (treated as "not logged in" — `lmnr-cli login` overwrites).
|
|
1072
1257
|
*/
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) return content;
|
|
1258
|
+
async function readCredentials() {
|
|
1259
|
+
const path = credentialsPath();
|
|
1260
|
+
let raw;
|
|
1077
1261
|
try {
|
|
1078
|
-
|
|
1079
|
-
} catch (
|
|
1080
|
-
|
|
1081
|
-
|
|
1262
|
+
raw = await (0, node_fs_promises.readFile)(path, "utf-8");
|
|
1263
|
+
} catch (e) {
|
|
1264
|
+
if (isNotFound(e)) return null;
|
|
1265
|
+
throw e;
|
|
1266
|
+
}
|
|
1267
|
+
let parsed;
|
|
1268
|
+
try {
|
|
1269
|
+
parsed = JSON.parse(raw);
|
|
1270
|
+
} catch {
|
|
1271
|
+
return null;
|
|
1272
|
+
}
|
|
1273
|
+
if (parsed.version === 1 && typeof parsed.sessionToken === "string") return parsed;
|
|
1274
|
+
return null;
|
|
1275
|
+
}
|
|
1276
|
+
async function writeCredentials(creds) {
|
|
1277
|
+
const path = credentialsPath();
|
|
1278
|
+
await (0, node_fs_promises.mkdir)((0, node_path.dirname)(path), {
|
|
1279
|
+
recursive: true,
|
|
1280
|
+
mode: 448
|
|
1281
|
+
});
|
|
1282
|
+
let mode = 384;
|
|
1283
|
+
try {
|
|
1284
|
+
mode = (await (0, node_fs_promises.stat)(path)).mode & 511;
|
|
1285
|
+
} catch (e) {
|
|
1286
|
+
if (!isNotFound(e)) throw e;
|
|
1287
|
+
}
|
|
1288
|
+
const tmp = `${path}.tmp.${process.pid}.${Date.now()}`;
|
|
1289
|
+
await (0, node_fs_promises.writeFile)(tmp, JSON.stringify(creds, null, 2), {
|
|
1290
|
+
mode,
|
|
1291
|
+
flag: "wx"
|
|
1292
|
+
});
|
|
1293
|
+
await (0, node_fs_promises.rename)(tmp, path);
|
|
1294
|
+
}
|
|
1295
|
+
async function deleteCredentials() {
|
|
1296
|
+
const path = credentialsPath();
|
|
1297
|
+
try {
|
|
1298
|
+
await (0, node_fs_promises.stat)(path);
|
|
1299
|
+
} catch (e) {
|
|
1300
|
+
if (isNotFound(e)) return false;
|
|
1301
|
+
throw e;
|
|
1302
|
+
}
|
|
1303
|
+
await (0, node_fs_promises.rm)(path, { force: true });
|
|
1304
|
+
return true;
|
|
1305
|
+
}
|
|
1306
|
+
function isNotFound(e) {
|
|
1307
|
+
return typeof e === "object" && e !== null && "code" in e && e.code === "ENOENT";
|
|
1308
|
+
}
|
|
1309
|
+
//#endregion
|
|
1310
|
+
//#region src/auth/device.ts
|
|
1311
|
+
const CLI_CLIENT_ID = "lmnr-cli";
|
|
1312
|
+
const CLI_SCOPE = "projects:rw";
|
|
1313
|
+
const DEVICE_CODE_ENDPOINT = "/api/auth/device/code";
|
|
1314
|
+
const DEVICE_TOKEN_ENDPOINT = "/api/auth/device/token";
|
|
1315
|
+
const TOKEN_ENDPOINT = "/api/auth/token";
|
|
1316
|
+
const SESSION_ENDPOINT = "/api/auth/get-session";
|
|
1317
|
+
var DeviceFlowError = class extends Error {
|
|
1318
|
+
constructor(code, message) {
|
|
1319
|
+
super(message);
|
|
1320
|
+
this.code = code;
|
|
1082
1321
|
}
|
|
1083
1322
|
};
|
|
1323
|
+
function trimSlash$3(url) {
|
|
1324
|
+
return url.replace(/\/+$/, "");
|
|
1325
|
+
}
|
|
1326
|
+
async function initiateDevice(issuer, scope = CLI_SCOPE) {
|
|
1327
|
+
const url = `${trimSlash$3(issuer)}${DEVICE_CODE_ENDPOINT}`;
|
|
1328
|
+
const res = await fetch(url, {
|
|
1329
|
+
method: "POST",
|
|
1330
|
+
headers: { "content-type": "application/json" },
|
|
1331
|
+
body: JSON.stringify({
|
|
1332
|
+
client_id: CLI_CLIENT_ID,
|
|
1333
|
+
scope
|
|
1334
|
+
})
|
|
1335
|
+
});
|
|
1336
|
+
if (!res.ok) {
|
|
1337
|
+
const body = await safeJson(res) ?? {};
|
|
1338
|
+
throw new DeviceFlowError(typeof body.error === "string" ? body.error : `http_${res.status}`, typeof body.error_description === "string" ? body.error_description : `Device authorization request failed (${res.status})`);
|
|
1339
|
+
}
|
|
1340
|
+
return await res.json();
|
|
1341
|
+
}
|
|
1084
1342
|
/**
|
|
1085
|
-
*
|
|
1086
|
-
|
|
1087
|
-
const parseCsvRow = (row) => {
|
|
1088
|
-
const parsed = {};
|
|
1089
|
-
for (const [key, value] of Object.entries(row)) parsed[key] = tryParseJson(value);
|
|
1090
|
-
return parsed;
|
|
1091
|
-
};
|
|
1092
|
-
/**
|
|
1093
|
-
* Read a CSV file and return its contents as an array of objects.
|
|
1343
|
+
* Poll BetterAuth's native token endpoint until the user approves. Returns the
|
|
1344
|
+
* full token response (whose `access_token` is the durable session token).
|
|
1094
1345
|
*/
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1346
|
+
async function pollDevice(issuer, deviceCode, opts = {}) {
|
|
1347
|
+
let intervalSeconds = Math.max(1, opts.intervalSeconds ?? 5);
|
|
1348
|
+
const timeoutMs = (opts.timeoutSeconds ?? 900) * 1e3;
|
|
1349
|
+
const deadline = Date.now() + timeoutMs;
|
|
1350
|
+
while (true) {
|
|
1351
|
+
if (Date.now() > deadline) throw new DeviceFlowError("expired_token", "Timed out waiting for authorization");
|
|
1352
|
+
const url = `${trimSlash$3(issuer)}${DEVICE_TOKEN_ENDPOINT}`;
|
|
1353
|
+
const res = await fetch(url, {
|
|
1354
|
+
method: "POST",
|
|
1355
|
+
headers: { "content-type": "application/json" },
|
|
1356
|
+
body: JSON.stringify({
|
|
1357
|
+
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
|
1358
|
+
device_code: deviceCode,
|
|
1359
|
+
client_id: CLI_CLIENT_ID
|
|
1360
|
+
})
|
|
1361
|
+
});
|
|
1362
|
+
if (res.ok) {
|
|
1363
|
+
const body = await res.json();
|
|
1364
|
+
if (!body.access_token) throw new DeviceFlowError("server_error", "Device token response missing access_token");
|
|
1365
|
+
body.metadata = res.headers.get("x-lmnr-metadata");
|
|
1366
|
+
return body;
|
|
1367
|
+
}
|
|
1368
|
+
const body = await safeJson(res) ?? {};
|
|
1369
|
+
const code = typeof body.error === "string" ? body.error : `http_${res.status}`;
|
|
1370
|
+
const description = typeof body.error_description === "string" ? body.error_description : code;
|
|
1371
|
+
if (code === "authorization_pending") {
|
|
1372
|
+
opts.onTick?.();
|
|
1373
|
+
await sleep(intervalSeconds * 1e3);
|
|
1374
|
+
continue;
|
|
1375
|
+
}
|
|
1376
|
+
if (code === "slow_down") {
|
|
1377
|
+
intervalSeconds += 5;
|
|
1378
|
+
await sleep(intervalSeconds * 1e3);
|
|
1379
|
+
continue;
|
|
1380
|
+
}
|
|
1381
|
+
throw new DeviceFlowError(code, description);
|
|
1382
|
+
}
|
|
1383
|
+
}
|
|
1099
1384
|
/**
|
|
1100
|
-
*
|
|
1385
|
+
* Mint a fresh 15m EdDSA JWT from a session token. Throws DeviceFlowError
|
|
1386
|
+
* "invalid_grant" on 401 (session expired/revoked).
|
|
1101
1387
|
*/
|
|
1102
|
-
async function
|
|
1103
|
-
|
|
1388
|
+
async function mintAccessJwt(issuer, sessionToken) {
|
|
1389
|
+
const url = `${trimSlash$3(issuer)}${TOKEN_ENDPOINT}`;
|
|
1390
|
+
const res = await fetch(url, {
|
|
1391
|
+
method: "GET",
|
|
1392
|
+
headers: { authorization: `Bearer ${sessionToken}` }
|
|
1393
|
+
});
|
|
1394
|
+
if (res.status === 401) throw new DeviceFlowError("invalid_grant", "Session expired or revoked");
|
|
1395
|
+
if (!res.ok) {
|
|
1396
|
+
const body = await safeJson(res) ?? {};
|
|
1397
|
+
throw new DeviceFlowError(typeof body.error === "string" ? body.error : `http_${res.status}`, `Failed to mint access token (${res.status})`);
|
|
1398
|
+
}
|
|
1399
|
+
const body = await res.json();
|
|
1400
|
+
if (!body.token) throw new DeviceFlowError("server_error", "Token endpoint response missing token");
|
|
1401
|
+
return body.token;
|
|
1402
|
+
}
|
|
1403
|
+
/** Fetch the BetterAuth session for profile metadata (userId, email). */
|
|
1404
|
+
async function fetchSession(issuer, sessionToken) {
|
|
1405
|
+
const url = `${trimSlash$3(issuer)}${SESSION_ENDPOINT}`;
|
|
1406
|
+
const res = await fetch(url, {
|
|
1407
|
+
method: "GET",
|
|
1408
|
+
headers: { authorization: `Bearer ${sessionToken}` }
|
|
1409
|
+
});
|
|
1410
|
+
if (!res.ok) throw new DeviceFlowError(`http_${res.status}`, `Failed to fetch session (${res.status})`);
|
|
1411
|
+
const user = (await res.json())?.user;
|
|
1412
|
+
if (!user?.id) throw new DeviceFlowError("server_error", "Session response missing user");
|
|
1413
|
+
return {
|
|
1414
|
+
id: user.id,
|
|
1415
|
+
email: user.email ?? ""
|
|
1416
|
+
};
|
|
1104
1417
|
}
|
|
1105
1418
|
/**
|
|
1106
|
-
*
|
|
1419
|
+
* Decode a JWT's `exp` (seconds since epoch) → ISO string. No signature
|
|
1420
|
+
* verification — the CLI trusts a token it just received over TLS. Returns null
|
|
1421
|
+
* when the token is malformed or carries no numeric `exp`.
|
|
1107
1422
|
*/
|
|
1108
|
-
|
|
1109
|
-
const
|
|
1423
|
+
function decodeJwtExp(jwt) {
|
|
1424
|
+
const parts = jwt.split(".");
|
|
1425
|
+
if (parts.length < 2) return null;
|
|
1426
|
+
try {
|
|
1427
|
+
const json = Buffer.from(parts[1], "base64url").toString("utf-8");
|
|
1428
|
+
const payload = JSON.parse(json);
|
|
1429
|
+
if (typeof payload.exp !== "number") return null;
|
|
1430
|
+
return (/* @__PURE__ */ new Date(payload.exp * 1e3)).toISOString();
|
|
1431
|
+
} catch {
|
|
1432
|
+
return null;
|
|
1433
|
+
}
|
|
1434
|
+
}
|
|
1435
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
1436
|
+
/**
|
|
1437
|
+
* Extract the browser-selected projectId from the device-token `x-lmnr-metadata`
|
|
1438
|
+
* response header — a JSON string, e.g. `{"projectId":"<uuid>"}`, forwarded by
|
|
1439
|
+
* the server's /device/token route wrapper. Returns null when absent or malformed.
|
|
1440
|
+
*/
|
|
1441
|
+
function parseProjectFromMetadata(metadata) {
|
|
1442
|
+
if (!metadata) return null;
|
|
1443
|
+
try {
|
|
1444
|
+
const pid = JSON.parse(metadata)?.projectId;
|
|
1445
|
+
return typeof pid === "string" && UUID_RE.test(pid) ? pid : null;
|
|
1446
|
+
} catch {
|
|
1447
|
+
return null;
|
|
1448
|
+
}
|
|
1449
|
+
}
|
|
1450
|
+
async function safeJson(res) {
|
|
1451
|
+
try {
|
|
1452
|
+
return await res.json();
|
|
1453
|
+
} catch {
|
|
1454
|
+
return null;
|
|
1455
|
+
}
|
|
1456
|
+
}
|
|
1457
|
+
function sleep(ms) {
|
|
1458
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
1459
|
+
}
|
|
1460
|
+
//#endregion
|
|
1461
|
+
//#region src/auth/resolve.ts
|
|
1462
|
+
const REFRESH_SKEW_MS = 3e4;
|
|
1463
|
+
/**
|
|
1464
|
+
* HTTP port from `LMNR_HTTP_PORT`. The Laminar convention is that `baseUrl`
|
|
1465
|
+
* carries NO port and the port is supplied separately (mirrors the SDK's
|
|
1466
|
+
* `LMNR_HTTP_PORT` / `LMNR_GRPC_PORT`). Returns undefined when unset/invalid so
|
|
1467
|
+
* the client keeps its 443 default for Cloud. An explicit `--port` flag still
|
|
1468
|
+
* wins (callers do `opts.port ?? envHttpPort()`).
|
|
1469
|
+
*/
|
|
1470
|
+
function envHttpPort() {
|
|
1471
|
+
const raw = process.env.LMNR_HTTP_PORT?.trim();
|
|
1472
|
+
if (!raw) return void 0;
|
|
1473
|
+
const n = Number.parseInt(raw, 10);
|
|
1474
|
+
return Number.isFinite(n) ? n : void 0;
|
|
1475
|
+
}
|
|
1476
|
+
/**
|
|
1477
|
+
* Resolve the data-API base URL: `--base-url` flag → `LMNR_BASE_URL` env →
|
|
1478
|
+
* default. Intentionally NOT read from credentials.json — the endpoint is not
|
|
1479
|
+
* persisted at login, so a self-host `.env` change applies to every command
|
|
1480
|
+
* without re-logging-in (and base URL behaves symmetrically with the port).
|
|
1481
|
+
*/
|
|
1482
|
+
function resolveBaseUrl(optBaseUrl) {
|
|
1483
|
+
return optBaseUrl?.trim() || process.env.LMNR_BASE_URL?.trim() || "https://api.lmnr.ai";
|
|
1484
|
+
}
|
|
1485
|
+
/**
|
|
1486
|
+
* CLI auth is **user-token only** — the CLI authenticates as the single
|
|
1487
|
+
* signed-in user via the stored BetterAuth session (refreshed access JWT),
|
|
1488
|
+
* never via a project API key.
|
|
1489
|
+
*
|
|
1490
|
+
* Project precedence (directory-scoped): `--project-id` flag > the nearest
|
|
1491
|
+
* `.lmnr/project.json` (written by `setup`). The project is NOT stored in
|
|
1492
|
+
* credentials.json — that holds only user auth.
|
|
1493
|
+
*/
|
|
1494
|
+
async function resolveAuth(opts) {
|
|
1495
|
+
const creds = await readCredentials();
|
|
1496
|
+
if (!creds) throw new Error("Not authenticated. Run `lmnr-cli login`.");
|
|
1497
|
+
let projectId = opts.projectId;
|
|
1498
|
+
if (!projectId || projectId.length === 0) projectId = (await readProjectLink())?.projectId;
|
|
1499
|
+
if (!projectId || projectId.length === 0) throw new Error("No project for this directory. Run `lmnr-cli setup` here, or pass --project-id <id>.");
|
|
1500
|
+
return {
|
|
1501
|
+
bearer: (await refreshIfNeeded(creds)).accessToken,
|
|
1502
|
+
baseUrl: resolveBaseUrl(opts.baseUrl),
|
|
1503
|
+
port: opts.port ?? envHttpPort(),
|
|
1504
|
+
projectId
|
|
1505
|
+
};
|
|
1506
|
+
}
|
|
1507
|
+
/**
|
|
1508
|
+
* Resolve only the user-scoped access token (no project) — for discovery
|
|
1509
|
+
* endpoints like listing projects, which run BEFORE a project is selected.
|
|
1510
|
+
*/
|
|
1511
|
+
async function resolveUserToken(opts) {
|
|
1512
|
+
const creds = await readCredentials();
|
|
1513
|
+
if (!creds) throw new Error("Not authenticated. Run `lmnr-cli login`.");
|
|
1514
|
+
return {
|
|
1515
|
+
bearer: (await refreshIfNeeded(creds)).accessToken,
|
|
1516
|
+
baseUrl: resolveBaseUrl(opts.baseUrl),
|
|
1517
|
+
port: opts.port ?? envHttpPort()
|
|
1518
|
+
};
|
|
1519
|
+
}
|
|
1520
|
+
/**
|
|
1521
|
+
* Re-mint the access JWT when it's near expiry and persist it. A 401 from the
|
|
1522
|
+
* token endpoint means the session is gone — surface a clear "run login" error.
|
|
1523
|
+
*
|
|
1524
|
+
* Logout race guard: we write credentials ONLY when we actually re-minted. The
|
|
1525
|
+
* old code unconditionally rewrote the file (just to bump lastUsedAt) on every
|
|
1526
|
+
* command, so a concurrent `logout` that deleted the file mid-flight could have
|
|
1527
|
+
* its delete clobbered by this in-flight atomic rename — logout would appear to
|
|
1528
|
+
* succeed while tokens remained on disk. For a fresh (not-near-expiry) token we
|
|
1529
|
+
* now do no write at all, eliminating that window for the common case.
|
|
1530
|
+
*/
|
|
1531
|
+
async function refreshIfNeeded(creds) {
|
|
1532
|
+
const expMs = new Date(creds.accessTokenExpiresAt).getTime();
|
|
1533
|
+
if (!(!Number.isFinite(expMs) || expMs - Date.now() <= REFRESH_SKEW_MS)) return creds;
|
|
1534
|
+
const next = { ...creds };
|
|
1535
|
+
try {
|
|
1536
|
+
const jwt = await mintAccessJwt(creds.issuer, creds.sessionToken);
|
|
1537
|
+
next.accessToken = jwt;
|
|
1538
|
+
next.accessTokenExpiresAt = decodeJwtExp(jwt) ?? (/* @__PURE__ */ new Date()).toISOString();
|
|
1539
|
+
} catch (e) {
|
|
1540
|
+
if (e instanceof DeviceFlowError && e.code === "invalid_grant") throw new Error("Session expired — run `lmnr-cli login`.");
|
|
1541
|
+
throw e;
|
|
1542
|
+
}
|
|
1543
|
+
next.lastUsedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1544
|
+
await writeCredentials(next);
|
|
1545
|
+
return next;
|
|
1546
|
+
}
|
|
1547
|
+
//#endregion
|
|
1548
|
+
//#region src/auth/client.ts
|
|
1549
|
+
/**
|
|
1550
|
+
* Build a LaminarClient for CLI commands. Auth is user-token only: the bearer
|
|
1551
|
+
* is the stored BetterAuth access JWT (auto-refreshed near expiry) and requests
|
|
1552
|
+
* route to `/v1/cli/*` with the resolved project in `x-lmnr-project-id`.
|
|
1553
|
+
*/
|
|
1554
|
+
async function buildLaminarClient(opts) {
|
|
1555
|
+
const auth = await resolveAuth(opts);
|
|
1556
|
+
return new LaminarClient({
|
|
1557
|
+
baseUrl: auth.baseUrl,
|
|
1558
|
+
port: auth.port,
|
|
1559
|
+
auth: {
|
|
1560
|
+
type: "userToken",
|
|
1561
|
+
token: auth.bearer,
|
|
1562
|
+
projectId: auth.projectId
|
|
1563
|
+
}
|
|
1564
|
+
});
|
|
1565
|
+
}
|
|
1566
|
+
//#endregion
|
|
1567
|
+
//#region src/auth/with-client.ts
|
|
1568
|
+
const logger$4 = initializeLogger();
|
|
1569
|
+
const defaultExitCode = () => 1;
|
|
1570
|
+
/**
|
|
1571
|
+
* Pull the commander positionals out of an `.action(...)` argument list.
|
|
1572
|
+
* Commander invokes the handler as `(arg1, ..., argN, options, command)`, so
|
|
1573
|
+
* the positionals are everything except the trailing `(options, command)`.
|
|
1574
|
+
*/
|
|
1575
|
+
function splitCommanderArgs(cmdArgs) {
|
|
1576
|
+
const command = cmdArgs.at(-1);
|
|
1577
|
+
return {
|
|
1578
|
+
positionals: cmdArgs.slice(0, -2),
|
|
1579
|
+
command,
|
|
1580
|
+
opts: command.optsWithGlobals()
|
|
1581
|
+
};
|
|
1582
|
+
}
|
|
1583
|
+
/**
|
|
1584
|
+
* The error envelope shared by both wrappers: in `--json` mode emit a structured
|
|
1585
|
+
* error line and exit with the mapped code; otherwise log and exit. Owning this
|
|
1586
|
+
* here lets handlers stay pure `(client, ...args) => work` with no try/catch.
|
|
1587
|
+
*/
|
|
1588
|
+
function runWithEnvelope(work, opts, exitCodeFor) {
|
|
1589
|
+
return work().catch((error) => {
|
|
1590
|
+
const code = exitCodeFor(error);
|
|
1591
|
+
if (opts.json) outputJsonError(error, code);
|
|
1592
|
+
logger$4.error(errorMessage(error));
|
|
1593
|
+
process.exit(code);
|
|
1594
|
+
});
|
|
1595
|
+
}
|
|
1596
|
+
/**
|
|
1597
|
+
* Wrap a project-scoped command handler. Resolves a user-token
|
|
1598
|
+
* {@link LaminarClient} (routes to `/v1/cli/*` with the resolved project),
|
|
1599
|
+
* threads the commander positionals + options through, and owns the error
|
|
1600
|
+
* envelope.
|
|
1601
|
+
*
|
|
1602
|
+
* @example
|
|
1603
|
+
* sqlCmd.command("query")
|
|
1604
|
+
* .argument("<query>")
|
|
1605
|
+
* .action(withProjectClient(handleSqlQuery)); // (client, query, opts) => work
|
|
1606
|
+
*/
|
|
1607
|
+
const withProjectClient = (action, exitCodeFor = defaultExitCode) => async (...cmdArgs) => {
|
|
1608
|
+
const { positionals, opts } = splitCommanderArgs(cmdArgs);
|
|
1609
|
+
await runWithEnvelope(async () => {
|
|
1610
|
+
await action(await buildLaminarClient({
|
|
1611
|
+
projectId: opts.projectId,
|
|
1612
|
+
baseUrl: opts.baseUrl,
|
|
1613
|
+
port: opts.port
|
|
1614
|
+
}), ...positionals, opts);
|
|
1615
|
+
}, opts, exitCodeFor);
|
|
1616
|
+
};
|
|
1617
|
+
/**
|
|
1618
|
+
* Wrap a discovery command handler. Resolves a user-token
|
|
1619
|
+
* {@link LaminarClient} with NO project (the discovery surface — e.g. listing
|
|
1620
|
+
* projects — runs before a project is selected), threads positionals +
|
|
1621
|
+
* options, and owns the error envelope.
|
|
1622
|
+
*/
|
|
1623
|
+
const withUserToken = (action, exitCodeFor = defaultExitCode) => async (...cmdArgs) => {
|
|
1624
|
+
const { positionals, opts } = splitCommanderArgs(cmdArgs);
|
|
1625
|
+
await runWithEnvelope(async () => {
|
|
1626
|
+
const token = await resolveUserToken({
|
|
1627
|
+
baseUrl: opts.baseUrl,
|
|
1628
|
+
port: opts.port
|
|
1629
|
+
});
|
|
1630
|
+
await action(new LaminarClient({
|
|
1631
|
+
baseUrl: token.baseUrl,
|
|
1632
|
+
port: token.port,
|
|
1633
|
+
auth: {
|
|
1634
|
+
type: "userToken",
|
|
1635
|
+
token: token.bearer,
|
|
1636
|
+
projectId: ""
|
|
1637
|
+
}
|
|
1638
|
+
}), ...positionals, opts);
|
|
1639
|
+
}, opts, exitCodeFor);
|
|
1640
|
+
};
|
|
1641
|
+
//#endregion
|
|
1642
|
+
//#region src/utils/file.ts
|
|
1643
|
+
const logger$3 = initializeLogger();
|
|
1644
|
+
/**
|
|
1645
|
+
* Check if a file has a supported extension.
|
|
1646
|
+
*/
|
|
1647
|
+
const isSupportedFile = (file) => {
|
|
1648
|
+
const ext = path.extname(file).toLowerCase();
|
|
1649
|
+
return [
|
|
1650
|
+
".json",
|
|
1651
|
+
".csv",
|
|
1652
|
+
".jsonl"
|
|
1653
|
+
].includes(ext);
|
|
1654
|
+
};
|
|
1655
|
+
/**
|
|
1656
|
+
* Collect all supported files from the given paths.
|
|
1657
|
+
* Handles both files and directories.
|
|
1658
|
+
*/
|
|
1659
|
+
const collectFiles = async (paths, recursive = false) => {
|
|
1660
|
+
const collectedFiles = [];
|
|
1661
|
+
for (const filepath of paths) try {
|
|
1662
|
+
const stats = await fs_promises.stat(filepath);
|
|
1663
|
+
if (stats.isFile()) if (isSupportedFile(filepath)) collectedFiles.push(filepath);
|
|
1664
|
+
else logger$3.warn(`Skipping unsupported file type: ${filepath}`);
|
|
1665
|
+
else if (stats.isDirectory()) {
|
|
1666
|
+
const entries = await fs_promises.readdir(filepath);
|
|
1667
|
+
for (const entry of entries) {
|
|
1668
|
+
const fullPath = path.join(filepath, entry);
|
|
1669
|
+
const entryStats = await fs_promises.stat(fullPath);
|
|
1670
|
+
if (entryStats.isFile() && isSupportedFile(fullPath)) collectedFiles.push(fullPath);
|
|
1671
|
+
else if (recursive && entryStats.isDirectory()) {
|
|
1672
|
+
const subFiles = await collectFiles([fullPath], true);
|
|
1673
|
+
collectedFiles.push(...subFiles);
|
|
1674
|
+
}
|
|
1675
|
+
}
|
|
1676
|
+
}
|
|
1677
|
+
} catch (error) {
|
|
1678
|
+
logger$3.warn(`Path does not exist or is not accessible: ${filepath}. Error: ${errorMessage(error)}`);
|
|
1679
|
+
}
|
|
1680
|
+
return collectedFiles;
|
|
1681
|
+
};
|
|
1682
|
+
/**
|
|
1683
|
+
* Read a JSON file and return its contents.
|
|
1684
|
+
*/
|
|
1685
|
+
const readJsonFile = async (filepath) => {
|
|
1686
|
+
const content = await fs_promises.readFile(filepath, "utf-8");
|
|
1687
|
+
const parsed = JSON.parse(content);
|
|
1688
|
+
return Array.isArray(parsed) ? parsed : [parsed];
|
|
1689
|
+
};
|
|
1690
|
+
/**
|
|
1691
|
+
* Try to parse a string as JSON. If it fails, return the original string.
|
|
1692
|
+
*/
|
|
1693
|
+
const tryParseJson = (content) => {
|
|
1694
|
+
if (typeof content !== "string") return content;
|
|
1695
|
+
const trimmed = content.trim();
|
|
1696
|
+
if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) return content;
|
|
1697
|
+
try {
|
|
1698
|
+
return JSON.parse(content);
|
|
1699
|
+
} catch (error) {
|
|
1700
|
+
logger$3.debug(`Error parsing JSON: ${errorMessage(error)}`);
|
|
1701
|
+
return content;
|
|
1702
|
+
}
|
|
1703
|
+
};
|
|
1704
|
+
/**
|
|
1705
|
+
* Parse each field in a CSV row, attempting to convert JSON strings back to objects.
|
|
1706
|
+
*/
|
|
1707
|
+
const parseCsvRow = (row) => {
|
|
1708
|
+
const parsed = {};
|
|
1709
|
+
for (const [key, value] of Object.entries(row)) parsed[key] = tryParseJson(value);
|
|
1710
|
+
return parsed;
|
|
1711
|
+
};
|
|
1712
|
+
/**
|
|
1713
|
+
* Read a CSV file and return its contents as an array of objects.
|
|
1714
|
+
*/
|
|
1715
|
+
const readCsvFile = async (filepath) => new Promise((resolve, reject) => {
|
|
1716
|
+
const results = [];
|
|
1717
|
+
(0, fs.createReadStream)(filepath).pipe((0, csv_parser.default)()).on("data", (data) => results.push(parseCsvRow(data))).on("end", () => resolve(results)).on("error", reject);
|
|
1718
|
+
});
|
|
1719
|
+
/**
|
|
1720
|
+
* Read a JSONL file and return its contents as an array of objects.
|
|
1721
|
+
*/
|
|
1722
|
+
async function readJsonlFile(filepath) {
|
|
1723
|
+
return (await fs_promises.readFile(filepath, "utf-8")).split("\n").filter((line) => line.trim()).map((line) => JSON.parse(line));
|
|
1724
|
+
}
|
|
1725
|
+
/**
|
|
1726
|
+
* Read a single file and return its contents.
|
|
1727
|
+
*/
|
|
1728
|
+
async function readFile$1(filepath) {
|
|
1729
|
+
const ext = path.extname(filepath).toLowerCase();
|
|
1110
1730
|
if (ext === ".json") return readJsonFile(filepath);
|
|
1111
1731
|
else if (ext === ".csv") return readCsvFile(filepath);
|
|
1112
1732
|
else if (ext === ".jsonl") return readJsonlFile(filepath);
|
|
@@ -1118,17 +1738,17 @@ async function readFile(filepath) {
|
|
|
1118
1738
|
const loadFromPaths = async (paths, recursive = false) => {
|
|
1119
1739
|
const files = await collectFiles(paths, recursive);
|
|
1120
1740
|
if (files.length === 0) {
|
|
1121
|
-
logger$
|
|
1741
|
+
logger$3.warn("No supported files found in the specified paths");
|
|
1122
1742
|
return [];
|
|
1123
1743
|
}
|
|
1124
|
-
logger$
|
|
1744
|
+
logger$3.info(`Found ${files.length} file(s) to read`);
|
|
1125
1745
|
const result = [];
|
|
1126
1746
|
for (const file of files) try {
|
|
1127
|
-
const data = await readFile(file);
|
|
1747
|
+
const data = await readFile$1(file);
|
|
1128
1748
|
result.push(...data);
|
|
1129
|
-
logger$
|
|
1749
|
+
logger$3.info(`Read ${data.length} record(s) from ${file}`);
|
|
1130
1750
|
} catch (error) {
|
|
1131
|
-
logger$
|
|
1751
|
+
logger$3.error(`Error reading file ${file}: ${errorMessage(error)}`);
|
|
1132
1752
|
throw error;
|
|
1133
1753
|
}
|
|
1134
1754
|
return result;
|
|
@@ -1168,7 +1788,7 @@ const writeToFile = async (filepath, data, format) => {
|
|
|
1168
1788
|
const dir = path.dirname(filepath);
|
|
1169
1789
|
await fs_promises.mkdir(dir, { recursive: true });
|
|
1170
1790
|
const ext = format ?? path.extname(filepath).slice(1);
|
|
1171
|
-
if (format && format !== path.extname(filepath).slice(1)) logger$
|
|
1791
|
+
if (format && format !== path.extname(filepath).slice(1)) logger$3.warn(`Output format ${format} does not match file extension ${path.extname(filepath).slice(1)}`);
|
|
1172
1792
|
if (ext === "json") await writeJsonFile(filepath, data);
|
|
1173
1793
|
else if (ext === "csv") await writeCsvFile(filepath, data);
|
|
1174
1794
|
else if (ext === "jsonl") await writeJsonlFile(filepath, data);
|
|
@@ -1193,7 +1813,7 @@ const printToConsole = (data, format = "json") => {
|
|
|
1193
1813
|
if (format === "json") console.log(JSON.stringify(data, null, 2));
|
|
1194
1814
|
else if (format === "csv") {
|
|
1195
1815
|
if (data.length === 0) {
|
|
1196
|
-
logger$
|
|
1816
|
+
logger$3.error("No data to print");
|
|
1197
1817
|
return;
|
|
1198
1818
|
}
|
|
1199
1819
|
console.log(formatCsv(data));
|
|
@@ -1201,23 +1821,6 @@ const printToConsole = (data, format = "json") => {
|
|
|
1201
1821
|
else throw new Error(`Unsupported output format: ${String(format)}. (supported formats: json, csv, jsonl)`);
|
|
1202
1822
|
};
|
|
1203
1823
|
//#endregion
|
|
1204
|
-
//#region src/utils/output.ts
|
|
1205
|
-
/**
|
|
1206
|
-
* Write structured JSON to stdout. Use this for machine-readable output
|
|
1207
|
-
* when --json is set.
|
|
1208
|
-
*/
|
|
1209
|
-
function outputJson(data) {
|
|
1210
|
-
console.log(JSON.stringify(data));
|
|
1211
|
-
}
|
|
1212
|
-
/**
|
|
1213
|
-
* Write a JSON error to stdout and exit with code 1.
|
|
1214
|
-
* Use this in --json mode so agents can parse the failure.
|
|
1215
|
-
*/
|
|
1216
|
-
function outputJsonError(error, exitCode = 1) {
|
|
1217
|
-
console.log(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }));
|
|
1218
|
-
process.exit(exitCode);
|
|
1219
|
-
}
|
|
1220
|
-
//#endregion
|
|
1221
1824
|
//#region src/utils/table.ts
|
|
1222
1825
|
const DEFAULT_TERMINAL_WIDTH = 80;
|
|
1223
1826
|
const PADDING_RIGHT = 2;
|
|
@@ -1280,9 +1883,14 @@ function renderTable(head, rows) {
|
|
|
1280
1883
|
}
|
|
1281
1884
|
//#endregion
|
|
1282
1885
|
//#region src/commands/dataset/index.ts
|
|
1283
|
-
const logger$
|
|
1886
|
+
const logger$2 = initializeLogger();
|
|
1284
1887
|
const DEFAULT_DATASET_PULL_BATCH_SIZE = 100;
|
|
1285
1888
|
const DEFAULT_DATASET_PUSH_BATCH_SIZE = 100;
|
|
1889
|
+
/** Throw on the name/id mutual-exclusion rule. The wrapper renders the error. */
|
|
1890
|
+
function requireSingleIdentifier(opts) {
|
|
1891
|
+
if (!opts.name && !opts.id) throw new Error("Either name or id must be provided");
|
|
1892
|
+
if (opts.name && opts.id) throw new Error("Only one of name or id must be provided");
|
|
1893
|
+
}
|
|
1286
1894
|
/**
|
|
1287
1895
|
* Pull all data from a dataset in batches.
|
|
1288
1896
|
*/
|
|
@@ -1307,1242 +1915,985 @@ const pullAllData = async (client, identifier, batchSize = DEFAULT_DATASET_PULL_
|
|
|
1307
1915
|
return result;
|
|
1308
1916
|
};
|
|
1309
1917
|
/**
|
|
1310
|
-
* Handle datasets list command.
|
|
1918
|
+
* Handle datasets list command. Pure handler — `withProjectClient` resolves the
|
|
1919
|
+
* client and owns the error envelope.
|
|
1311
1920
|
*/
|
|
1312
|
-
const handleDatasetsList = async (
|
|
1313
|
-
const
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1921
|
+
const handleDatasetsList = async (client, opts) => {
|
|
1922
|
+
const datasets = await client.datasets.listDatasets();
|
|
1923
|
+
if (opts.json) {
|
|
1924
|
+
outputJson(datasets);
|
|
1925
|
+
return;
|
|
1926
|
+
}
|
|
1927
|
+
if (datasets.length === 0) {
|
|
1928
|
+
console.log("No datasets found.");
|
|
1929
|
+
return;
|
|
1930
|
+
}
|
|
1931
|
+
const rows = datasets.map((dataset) => {
|
|
1932
|
+
const createdAtStr = new Date(dataset.createdAt).toISOString().replace("T", " ").substring(0, 19);
|
|
1933
|
+
return [
|
|
1934
|
+
dataset.id,
|
|
1935
|
+
createdAtStr,
|
|
1936
|
+
dataset.name
|
|
1937
|
+
];
|
|
1317
1938
|
});
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
if (datasets.length === 0) {
|
|
1325
|
-
console.log("No datasets found.");
|
|
1326
|
-
return;
|
|
1327
|
-
}
|
|
1328
|
-
const rows = datasets.map((dataset) => {
|
|
1329
|
-
const createdAtStr = new Date(dataset.createdAt).toISOString().replace("T", " ").substring(0, 19);
|
|
1330
|
-
return [
|
|
1331
|
-
dataset.id,
|
|
1332
|
-
createdAtStr,
|
|
1333
|
-
dataset.name
|
|
1334
|
-
];
|
|
1335
|
-
});
|
|
1336
|
-
console.log(renderTable([
|
|
1337
|
-
"ID",
|
|
1338
|
-
"Created At",
|
|
1339
|
-
"Name"
|
|
1340
|
-
], rows));
|
|
1341
|
-
console.log(`\nTotal: ${datasets.length} dataset(s)\n`);
|
|
1342
|
-
} catch (error) {
|
|
1343
|
-
if (options.json) outputJsonError(error);
|
|
1344
|
-
logger$4.error(`Failed to list datasets: ${error instanceof Error ? error.message : String(error)}`);
|
|
1345
|
-
process.exit(1);
|
|
1346
|
-
}
|
|
1939
|
+
console.log(renderTable([
|
|
1940
|
+
"ID",
|
|
1941
|
+
"Created At",
|
|
1942
|
+
"Name"
|
|
1943
|
+
], rows));
|
|
1944
|
+
console.log(`\nTotal: ${datasets.length} dataset(s)\n`);
|
|
1347
1945
|
};
|
|
1348
1946
|
/**
|
|
1349
1947
|
* Handle datasets push command.
|
|
1350
1948
|
*/
|
|
1351
|
-
const handleDatasetsPush = async (paths,
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
process.exit(1);
|
|
1361
|
-
}
|
|
1362
|
-
const client = new LaminarClient({
|
|
1363
|
-
projectApiKey: options.projectApiKey,
|
|
1364
|
-
baseUrl: options.baseUrl,
|
|
1365
|
-
port: options.port
|
|
1949
|
+
const handleDatasetsPush = async (client, paths, opts) => {
|
|
1950
|
+
requireSingleIdentifier(opts);
|
|
1951
|
+
const data = await loadFromPaths(paths, opts.recursive);
|
|
1952
|
+
if (data.length === 0) throw new Error("No data to push");
|
|
1953
|
+
const identifier = opts.name ? { name: opts.name } : { id: opts.id };
|
|
1954
|
+
const result = await client.datasets.push({
|
|
1955
|
+
points: data,
|
|
1956
|
+
...identifier,
|
|
1957
|
+
batchSize: opts.batchSize ?? DEFAULT_DATASET_PUSH_BATCH_SIZE
|
|
1366
1958
|
});
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
logger$4.error("No data to push. Skipping");
|
|
1372
|
-
process.exit(1);
|
|
1373
|
-
}
|
|
1374
|
-
const identifier = options.name ? { name: options.name } : { id: options.id };
|
|
1375
|
-
const result = await client.datasets.push({
|
|
1376
|
-
points: data,
|
|
1377
|
-
...identifier,
|
|
1378
|
-
batchSize: options.batchSize ?? DEFAULT_DATASET_PUSH_BATCH_SIZE
|
|
1959
|
+
if (opts.json) {
|
|
1960
|
+
outputJson({
|
|
1961
|
+
datasetId: result?.datasetId,
|
|
1962
|
+
count: data.length
|
|
1379
1963
|
});
|
|
1380
|
-
|
|
1381
|
-
outputJson({
|
|
1382
|
-
datasetId: result?.datasetId,
|
|
1383
|
-
count: data.length
|
|
1384
|
-
});
|
|
1385
|
-
return;
|
|
1386
|
-
}
|
|
1387
|
-
logger$4.info(`Pushed ${data.length} data points to dataset ${options.name || options.id}`);
|
|
1388
|
-
} catch (error) {
|
|
1389
|
-
if (options.json) outputJsonError(error);
|
|
1390
|
-
logger$4.error(`Failed to push dataset: ${error instanceof Error ? error.message : String(error)}`);
|
|
1391
|
-
process.exit(1);
|
|
1964
|
+
return;
|
|
1392
1965
|
}
|
|
1966
|
+
logger$2.info(`Pushed ${data.length} data points to dataset ${opts.name || opts.id}`);
|
|
1393
1967
|
};
|
|
1394
1968
|
/**
|
|
1395
1969
|
* Handle datasets pull command.
|
|
1396
1970
|
*/
|
|
1397
|
-
const handleDatasetsPull = async (outputPath,
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
}
|
|
1408
|
-
|
|
1409
|
-
projectApiKey: options.projectApiKey,
|
|
1410
|
-
baseUrl: options.baseUrl,
|
|
1411
|
-
port: options.port
|
|
1412
|
-
});
|
|
1413
|
-
const identifier = options.name ? { name: options.name } : { id: options.id };
|
|
1414
|
-
try {
|
|
1415
|
-
const result = await pullAllData(client, identifier, options.batchSize ?? DEFAULT_DATASET_PULL_BATCH_SIZE, options.offset ?? 0, options.limit);
|
|
1416
|
-
if (outputPath) {
|
|
1417
|
-
await writeToFile(outputPath, result, options.outputFormat);
|
|
1418
|
-
if (options.json) outputJson({
|
|
1419
|
-
path: outputPath,
|
|
1420
|
-
count: result.length
|
|
1421
|
-
});
|
|
1422
|
-
else logger$4.info(`Successfully pulled ${result.length} data points to ${outputPath}`);
|
|
1423
|
-
} else if (options.json) outputJson(result);
|
|
1424
|
-
else printToConsole(result, options.outputFormat ?? "json");
|
|
1425
|
-
} catch (error) {
|
|
1426
|
-
if (options.json) outputJsonError(error);
|
|
1427
|
-
logger$4.error(`Failed to pull dataset: ${error instanceof Error ? error.message : String(error)}`);
|
|
1428
|
-
process.exit(1);
|
|
1429
|
-
}
|
|
1971
|
+
const handleDatasetsPull = async (client, outputPath, opts) => {
|
|
1972
|
+
requireSingleIdentifier(opts);
|
|
1973
|
+
const result = await pullAllData(client, opts.name ? { name: opts.name } : { id: opts.id }, opts.batchSize ?? DEFAULT_DATASET_PULL_BATCH_SIZE, opts.offset ?? 0, opts.limit);
|
|
1974
|
+
if (outputPath) {
|
|
1975
|
+
await writeToFile(outputPath, result, opts.outputFormat);
|
|
1976
|
+
if (opts.json) outputJson({
|
|
1977
|
+
path: outputPath,
|
|
1978
|
+
count: result.length
|
|
1979
|
+
});
|
|
1980
|
+
else logger$2.info(`Successfully pulled ${result.length} data points to ${outputPath}`);
|
|
1981
|
+
} else if (opts.json) outputJson(result);
|
|
1982
|
+
else printToConsole(result, opts.outputFormat ?? "json");
|
|
1430
1983
|
};
|
|
1431
1984
|
/**
|
|
1432
1985
|
* Handle datasets create command.
|
|
1433
1986
|
*/
|
|
1434
|
-
const handleDatasetsCreate = async (name, paths,
|
|
1435
|
-
const
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1987
|
+
const handleDatasetsCreate = async (client, name, paths, opts) => {
|
|
1988
|
+
const data = await loadFromPaths(paths, opts.recursive);
|
|
1989
|
+
if (data.length === 0) throw new Error("No data to push");
|
|
1990
|
+
logger$2.info(`Pushing ${data.length} data points to dataset '${name}'...`);
|
|
1991
|
+
await client.datasets.push({
|
|
1992
|
+
points: data,
|
|
1993
|
+
name,
|
|
1994
|
+
batchSize: opts.batchSize ?? DEFAULT_DATASET_PUSH_BATCH_SIZE,
|
|
1995
|
+
createDataset: true
|
|
1439
1996
|
});
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1997
|
+
logger$2.info(`Successfully pushed ${data.length} data points to dataset '${name}'`);
|
|
1998
|
+
logger$2.info(`Pulling data from dataset '${name}'...`);
|
|
1999
|
+
const result = await pullAllData(client, { name }, opts.batchSize ?? DEFAULT_DATASET_PULL_BATCH_SIZE, 0, void 0);
|
|
2000
|
+
await writeToFile(opts.outputFile, result, opts.outputFormat);
|
|
2001
|
+
if (opts.json) outputJson({
|
|
2002
|
+
name,
|
|
2003
|
+
path: opts.outputFile,
|
|
2004
|
+
count: result.length
|
|
2005
|
+
});
|
|
2006
|
+
else logger$2.info(`Successfully created dataset '${name}' and saved ${result.length} datapoints to ${opts.outputFile}`);
|
|
2007
|
+
};
|
|
2008
|
+
//#endregion
|
|
2009
|
+
//#region src/utils/trace-note.ts
|
|
2010
|
+
const NOTE_METADATA_KEY = "rollout.note";
|
|
2011
|
+
/**
|
|
2012
|
+
* Normalize a user-supplied trace id (UUID or 32-char OTel hex, optionally
|
|
2013
|
+
* 0x-prefixed) to the dashed UUID form used by the SQL endpoint. Throws on
|
|
2014
|
+
* anything else so a typo fails loudly instead of querying nothing.
|
|
2015
|
+
*/
|
|
2016
|
+
const normalizeTraceId = (traceId) => {
|
|
2017
|
+
const id = traceId.trim().toLowerCase().replace(/^0x/, "");
|
|
2018
|
+
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(id)) return id;
|
|
2019
|
+
if (/^[0-9a-f]{32}$/.test(id)) return id.replace(/^([0-9a-f]{8})([0-9a-f]{4})([0-9a-f]{4})([0-9a-f]{4})([0-9a-f]{12})$/, "$1-$2-$3-$4-$5");
|
|
2020
|
+
throw new Error(`Invalid trace id "${traceId}". Expected a UUID or a 32-char OTel hex trace id.`);
|
|
2021
|
+
};
|
|
2022
|
+
/**
|
|
2023
|
+
* Extract the note from a trace's `metadata` column as returned by the SQL
|
|
2024
|
+
* endpoint (a JSON string; tolerate an already-parsed object too). Missing /
|
|
2025
|
+
* malformed metadata reads as "no note".
|
|
2026
|
+
*/
|
|
2027
|
+
const readNoteFromMetadata = (metadata) => {
|
|
2028
|
+
let parsed = metadata;
|
|
2029
|
+
if (typeof metadata === "string") {
|
|
2030
|
+
if (metadata === "") return "";
|
|
2031
|
+
try {
|
|
2032
|
+
parsed = JSON.parse(metadata);
|
|
2033
|
+
} catch {
|
|
2034
|
+
return "";
|
|
1446
2035
|
}
|
|
1447
|
-
logger$4.info(`Pushing ${data.length} data points to dataset '${name}'...`);
|
|
1448
|
-
await client.datasets.push({
|
|
1449
|
-
points: data,
|
|
1450
|
-
name,
|
|
1451
|
-
batchSize: options.batchSize ?? DEFAULT_DATASET_PUSH_BATCH_SIZE,
|
|
1452
|
-
createDataset: true
|
|
1453
|
-
});
|
|
1454
|
-
logger$4.info(`Successfully pushed ${data.length} data points to dataset '${name}'`);
|
|
1455
|
-
} catch (error) {
|
|
1456
|
-
if (options.json) outputJsonError(error);
|
|
1457
|
-
logger$4.error(`Failed to create dataset: ${error instanceof Error ? error.message : String(error)}`);
|
|
1458
|
-
process.exit(1);
|
|
1459
|
-
}
|
|
1460
|
-
logger$4.info(`Pulling data from dataset '${name}'...`);
|
|
1461
|
-
try {
|
|
1462
|
-
const result = await pullAllData(client, { name }, options.batchSize ?? DEFAULT_DATASET_PULL_BATCH_SIZE, 0, void 0);
|
|
1463
|
-
await writeToFile(options.outputFile, result, options.outputFormat);
|
|
1464
|
-
if (options.json) outputJson({
|
|
1465
|
-
name,
|
|
1466
|
-
path: options.outputFile,
|
|
1467
|
-
count: result.length
|
|
1468
|
-
});
|
|
1469
|
-
else logger$4.info(`Successfully created dataset '${name}' and saved ${result.length} datapoints to ${options.outputFile}`);
|
|
1470
|
-
} catch (error) {
|
|
1471
|
-
if (options.json) outputJsonError(error);
|
|
1472
|
-
logger$4.error(`Failed to pull dataset after creation: ${error instanceof Error ? error.message : String(error)}`);
|
|
1473
|
-
process.exit(1);
|
|
1474
2036
|
}
|
|
2037
|
+
if (typeof parsed !== "object" || parsed === null) return "";
|
|
2038
|
+
const note = parsed[NOTE_METADATA_KEY];
|
|
2039
|
+
return typeof note === "string" ? note : "";
|
|
1475
2040
|
};
|
|
1476
2041
|
//#endregion
|
|
1477
|
-
//#region src/
|
|
1478
|
-
const
|
|
2042
|
+
//#region src/commands/debug/index.ts
|
|
2043
|
+
const logger$1 = initializeLogger();
|
|
1479
2044
|
/**
|
|
1480
|
-
*
|
|
2045
|
+
* Upsert the display name of a debug session. Update-only on the backend: a
|
|
2046
|
+
* session id unknown to the project 404s rather than creating a ghost session.
|
|
2047
|
+
*
|
|
2048
|
+
* Pure handler: the command wrapper (`withProjectClient`) resolves a user-token
|
|
2049
|
+
* {@link LaminarClient} (routes to `/v1/cli/*` with the resolved project) and
|
|
2050
|
+
* owns the error envelope.
|
|
1481
2051
|
*/
|
|
1482
|
-
async
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
const port = server.address().port;
|
|
1487
|
-
server.close(() => resolve(port));
|
|
1488
|
-
});
|
|
1489
|
-
server.on("error", (err) => {
|
|
1490
|
-
if (err.code === "EADDRINUSE") resolve(findAvailablePort(startPort + 1));
|
|
1491
|
-
else reject(err);
|
|
1492
|
-
});
|
|
2052
|
+
const handleDebugSessionSetName = async (client, sessionId, name, opts) => {
|
|
2053
|
+
await client.rolloutSessions.setName({
|
|
2054
|
+
sessionId,
|
|
2055
|
+
name
|
|
1493
2056
|
});
|
|
1494
|
-
|
|
2057
|
+
if (opts.json) {
|
|
2058
|
+
outputJson({
|
|
2059
|
+
sessionId,
|
|
2060
|
+
name
|
|
2061
|
+
});
|
|
2062
|
+
return;
|
|
2063
|
+
}
|
|
2064
|
+
logger$1.info(`Set name of session ${sessionId} to "${name}".`);
|
|
2065
|
+
};
|
|
1495
2066
|
/**
|
|
1496
|
-
*
|
|
2067
|
+
* Print a per-trace summary of a debug session: every trace whose metadata
|
|
2068
|
+
* groups it to the session (`rollout.session_id`), oldest first, with the
|
|
2069
|
+
* agent-authored note (`rollout.note`) attached to each.
|
|
2070
|
+
*
|
|
2071
|
+
* Pure handler: the command wrapper (`withProjectClient`) resolves a user-token
|
|
2072
|
+
* {@link LaminarClient} (routes to `/v1/cli/*` with the resolved project) and
|
|
2073
|
+
* owns the error envelope.
|
|
1497
2074
|
*/
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
2075
|
+
const handleDebugSessionSummary = async (client, sessionId, opts) => {
|
|
2076
|
+
const traces = (await client.sql.query("SELECT id, formatDateTime(end_time, '%Y-%m-%dT%H:%i:%S.%fZ') AS end_time, metadata FROM traces WHERE simpleJSONExtractString(metadata, 'rollout.session_id') = {session_id:String} ORDER BY start_time", { session_id: sessionId })).map((row) => ({
|
|
2077
|
+
note: readNoteFromMetadata(row.metadata),
|
|
2078
|
+
traceId: String(row.id ?? ""),
|
|
2079
|
+
endTime: String(row.end_time ?? "")
|
|
2080
|
+
}));
|
|
2081
|
+
if (opts.json) {
|
|
2082
|
+
outputJson(traces);
|
|
2083
|
+
return;
|
|
2084
|
+
}
|
|
2085
|
+
if (traces.length === 0) {
|
|
2086
|
+
console.log(`No traces found for session ${sessionId}.`);
|
|
2087
|
+
return;
|
|
2088
|
+
}
|
|
2089
|
+
const blocks = traces.map((trace) => {
|
|
2090
|
+
const tag = `<trace id="${trace.traceId}" end-time="${trace.endTime}"/>`;
|
|
2091
|
+
return trace.note ? `${trace.note}\n${tag}` : tag;
|
|
1512
2092
|
});
|
|
2093
|
+
console.log(blocks.join("\n\n"));
|
|
2094
|
+
};
|
|
2095
|
+
//#endregion
|
|
2096
|
+
//#region src/utils/colors.ts
|
|
2097
|
+
function enabledFor(stream) {
|
|
2098
|
+
if ("NO_COLOR" in process.env) return false;
|
|
2099
|
+
if ("FORCE_COLOR" in process.env && process.env.FORCE_COLOR !== "0") return true;
|
|
2100
|
+
return Boolean(stream.isTTY);
|
|
1513
2101
|
}
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
const
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
res.writeHead(200);
|
|
1534
|
-
res.end();
|
|
1535
|
-
return;
|
|
1536
|
-
}
|
|
1537
|
-
if (req.method === "GET" && req.url === "/health") {
|
|
1538
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1539
|
-
res.end(JSON.stringify({ status: "ok" }));
|
|
1540
|
-
return;
|
|
1541
|
-
}
|
|
1542
|
-
if (req.method === "POST" && req.url === "/cached") {
|
|
1543
|
-
try {
|
|
1544
|
-
const { path, index } = await parseBody(req);
|
|
1545
|
-
if (typeof path !== "string" || typeof index !== "number") {
|
|
1546
|
-
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1547
|
-
res.end(JSON.stringify({ error: "Invalid request: path (string) and index (number) required" }));
|
|
1548
|
-
return;
|
|
1549
|
-
}
|
|
1550
|
-
const cacheKey = `${index}:${path}`;
|
|
1551
|
-
const response = {
|
|
1552
|
-
span: cache.get(cacheKey),
|
|
1553
|
-
pathToCount: metadata.pathToCount,
|
|
1554
|
-
overrides: metadata.overrides
|
|
1555
|
-
};
|
|
1556
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1557
|
-
res.end(JSON.stringify(response));
|
|
1558
|
-
} catch (err) {
|
|
1559
|
-
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1560
|
-
res.end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }));
|
|
1561
|
-
}
|
|
1562
|
-
return;
|
|
1563
|
-
}
|
|
1564
|
-
res.writeHead(404, { "Content-Type": "application/json" });
|
|
1565
|
-
res.end(JSON.stringify({ error: "Not found" }));
|
|
1566
|
-
})().catch((error) => {
|
|
1567
|
-
if (!res.headersSent) {
|
|
1568
|
-
res.writeHead(500, { "Content-Type": "application/json" });
|
|
1569
|
-
res.end(JSON.stringify({ error: error instanceof Error ? error.message : "Internal server error" }));
|
|
1570
|
-
}
|
|
1571
|
-
});
|
|
2102
|
+
const pc = (0, picocolors.createColors)(enabledFor(process.stderr));
|
|
2103
|
+
const pcOut = (0, picocolors.createColors)(enabledFor(process.stdout));
|
|
2104
|
+
const stderrColorEnabled = enabledFor(process.stderr);
|
|
2105
|
+
const orange = (text) => stderrColorEnabled ? `\x1b[38;2;208;117;78m${text}\x1b[39m` : text;
|
|
2106
|
+
//#endregion
|
|
2107
|
+
//#region src/commands/login/index.ts
|
|
2108
|
+
async function handleLogin(options) {
|
|
2109
|
+
const issuer = pick$1(options.frontendUrl, process.env.LMNR_FRONTEND_URL, DEFAULT_FRONTEND_URL$1);
|
|
2110
|
+
const da = await initiateDevice(issuer, CLI_SCOPE);
|
|
2111
|
+
const completeUri = da.verification_uri_complete ?? da.verification_uri;
|
|
2112
|
+
process.stderr.write(`\nOpen this URL in your browser to authorize:\n ${pc.cyan(completeUri)}\n`);
|
|
2113
|
+
if (da.user_code) process.stderr.write(`Code: ${pc.bold(pc.cyan(da.user_code))}\n\n`);
|
|
2114
|
+
if (!options.noBrowser) try {
|
|
2115
|
+
await (0, open.default)(completeUri);
|
|
2116
|
+
} catch {}
|
|
2117
|
+
process.stderr.write(pc.dim("Waiting for authorization...\n"));
|
|
2118
|
+
const token = await pollDevice(issuer, da.device_code, {
|
|
2119
|
+
intervalSeconds: da.interval,
|
|
2120
|
+
timeoutSeconds: da.expires_in
|
|
1572
2121
|
});
|
|
1573
|
-
const
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
2122
|
+
const sessionToken = token.access_token;
|
|
2123
|
+
const jwt = await mintAccessJwt(issuer, sessionToken);
|
|
2124
|
+
const session = await fetchSession(issuer, sessionToken);
|
|
2125
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
2126
|
+
await writeCredentials({
|
|
2127
|
+
version: 1,
|
|
2128
|
+
issuer,
|
|
2129
|
+
sessionToken,
|
|
2130
|
+
accessToken: jwt,
|
|
2131
|
+
accessTokenExpiresAt: decodeJwtExp(jwt) ?? now,
|
|
2132
|
+
sessionExpiresAt: typeof token.expires_in === "number" ? new Date(Date.now() + token.expires_in * 1e3).toISOString() : void 0,
|
|
2133
|
+
userEmail: session.email || void 0,
|
|
2134
|
+
userId: session.id,
|
|
2135
|
+
createdAt: now,
|
|
2136
|
+
lastUsedAt: now
|
|
1586
2137
|
});
|
|
2138
|
+
return {
|
|
2139
|
+
userId: session.id,
|
|
2140
|
+
userEmail: session.email || null,
|
|
2141
|
+
projectId: parseProjectFromMetadata(token.metadata)
|
|
2142
|
+
};
|
|
2143
|
+
}
|
|
2144
|
+
function pick$1(...candidates) {
|
|
2145
|
+
for (const c of candidates) if (c && c.length > 0) return c;
|
|
2146
|
+
return "";
|
|
1587
2147
|
}
|
|
1588
2148
|
//#endregion
|
|
1589
|
-
//#region src/
|
|
1590
|
-
const HEARTBEAT_INTERVAL = 5e3;
|
|
1591
|
-
const MAX_MISSED_HEARTBEATS = 3;
|
|
1592
|
-
/**
|
|
1593
|
-
* SSE client for rollout debugging sessions
|
|
1594
|
-
* Connects to the Laminar backend and listens for run events
|
|
1595
|
-
*/
|
|
1596
|
-
var SSEClient = class extends events.EventEmitter {
|
|
1597
|
-
constructor(options) {
|
|
1598
|
-
super();
|
|
1599
|
-
this.lastHeartbeat = Date.now();
|
|
1600
|
-
this.isShutdown = false;
|
|
1601
|
-
this.client = options.client;
|
|
1602
|
-
this.sessionId = options.sessionId;
|
|
1603
|
-
this.params = options.params;
|
|
1604
|
-
this.name = options.name;
|
|
1605
|
-
}
|
|
1606
|
-
/**
|
|
1607
|
-
* Connects to the SSE endpoint
|
|
1608
|
-
*/
|
|
1609
|
-
async connectAndListen() {
|
|
1610
|
-
if (this.isShutdown) return;
|
|
1611
|
-
this.abortController = new AbortController();
|
|
1612
|
-
this.lastHeartbeat = Date.now();
|
|
1613
|
-
try {
|
|
1614
|
-
const response = await this.client.rolloutSessions.connect({
|
|
1615
|
-
sessionId: this.sessionId,
|
|
1616
|
-
params: this.params,
|
|
1617
|
-
signal: this.abortController.signal,
|
|
1618
|
-
name: this.name
|
|
1619
|
-
});
|
|
1620
|
-
this.emit("connected");
|
|
1621
|
-
this.startHeartbeatCheck();
|
|
1622
|
-
await this.parseSSEStream(response.body);
|
|
1623
|
-
} catch (error) {
|
|
1624
|
-
if (error.name === "AbortError") return;
|
|
1625
|
-
this.emit("error", error);
|
|
1626
|
-
if (!this.isShutdown) this.scheduleReconnect();
|
|
1627
|
-
}
|
|
1628
|
-
}
|
|
1629
|
-
/**
|
|
1630
|
-
* Parses SSE stream and emits events
|
|
1631
|
-
*/
|
|
1632
|
-
async parseSSEStream(body) {
|
|
1633
|
-
const reader = body.getReader();
|
|
1634
|
-
const decoder = new TextDecoder();
|
|
1635
|
-
const parser = (0, eventsource_parser.createParser)({ onEvent: (event) => {
|
|
1636
|
-
this.processSSEEvent(event);
|
|
1637
|
-
} });
|
|
1638
|
-
try {
|
|
1639
|
-
while (true) {
|
|
1640
|
-
const { done, value } = await reader.read();
|
|
1641
|
-
if (done) break;
|
|
1642
|
-
const chunk = decoder.decode(value, { stream: true });
|
|
1643
|
-
parser.feed(chunk);
|
|
1644
|
-
}
|
|
1645
|
-
} finally {
|
|
1646
|
-
reader.releaseLock();
|
|
1647
|
-
}
|
|
1648
|
-
if (!this.isShutdown) this.scheduleReconnect();
|
|
1649
|
-
}
|
|
1650
|
-
/**
|
|
1651
|
-
* Processes a parsed SSE event
|
|
1652
|
-
*/
|
|
1653
|
-
processSSEEvent(event) {
|
|
1654
|
-
if (!event.data) return;
|
|
1655
|
-
try {
|
|
1656
|
-
if (event.event === "heartbeat") {
|
|
1657
|
-
this.lastHeartbeat = Date.now();
|
|
1658
|
-
this.emit("heartbeat");
|
|
1659
|
-
} else if (event.event === "run") {
|
|
1660
|
-
const runEvent = {
|
|
1661
|
-
event_type: "run",
|
|
1662
|
-
data: JSON.parse(event.data)
|
|
1663
|
-
};
|
|
1664
|
-
this.emit("run", runEvent);
|
|
1665
|
-
} else if (event.event === "handshake") {
|
|
1666
|
-
const handshakeEvent = {
|
|
1667
|
-
event_type: "handshake",
|
|
1668
|
-
data: JSON.parse(event.data)
|
|
1669
|
-
};
|
|
1670
|
-
this.emit("handshake", handshakeEvent);
|
|
1671
|
-
} else if (event.event === "stop") this.emit("stop");
|
|
1672
|
-
} catch (error) {
|
|
1673
|
-
this.emit("error", /* @__PURE__ */ new Error(`Failed to parse SSE event data: ${error}`));
|
|
1674
|
-
}
|
|
1675
|
-
}
|
|
1676
|
-
/**
|
|
1677
|
-
* Starts checking for missed heartbeats
|
|
1678
|
-
*/
|
|
1679
|
-
startHeartbeatCheck() {
|
|
1680
|
-
this.stopHeartbeatCheck();
|
|
1681
|
-
this.heartbeatCheckTimer = setInterval(() => {
|
|
1682
|
-
if (Date.now() - this.lastHeartbeat > HEARTBEAT_INTERVAL * MAX_MISSED_HEARTBEATS) {
|
|
1683
|
-
this.emit("heartbeat_timeout");
|
|
1684
|
-
this.reconnect();
|
|
1685
|
-
}
|
|
1686
|
-
}, HEARTBEAT_INTERVAL);
|
|
1687
|
-
}
|
|
1688
|
-
/**
|
|
1689
|
-
* Stops heartbeat checking
|
|
1690
|
-
*/
|
|
1691
|
-
stopHeartbeatCheck() {
|
|
1692
|
-
if (this.heartbeatCheckTimer) {
|
|
1693
|
-
clearInterval(this.heartbeatCheckTimer);
|
|
1694
|
-
this.heartbeatCheckTimer = void 0;
|
|
1695
|
-
}
|
|
1696
|
-
}
|
|
1697
|
-
/**
|
|
1698
|
-
* Schedules a reconnection attempt
|
|
1699
|
-
*/
|
|
1700
|
-
scheduleReconnect() {
|
|
1701
|
-
if (this.reconnectTimer || this.isShutdown) return;
|
|
1702
|
-
this.emit("reconnecting");
|
|
1703
|
-
this.reconnectTimer = setTimeout(() => {
|
|
1704
|
-
this.reconnectTimer = void 0;
|
|
1705
|
-
this.reconnect();
|
|
1706
|
-
}, 1e3);
|
|
1707
|
-
}
|
|
1708
|
-
/**
|
|
1709
|
-
* Reconnects to the SSE endpoint
|
|
1710
|
-
*/
|
|
1711
|
-
reconnect() {
|
|
1712
|
-
this.disconnect(true);
|
|
1713
|
-
this.connectAndListen().catch((error) => {
|
|
1714
|
-
this.emit("error", error);
|
|
1715
|
-
});
|
|
1716
|
-
}
|
|
1717
|
-
/**
|
|
1718
|
-
* Disconnects from the SSE endpoint
|
|
1719
|
-
*/
|
|
1720
|
-
disconnect(stopReconnect = true) {
|
|
1721
|
-
if (this.abortController) {
|
|
1722
|
-
this.abortController.abort();
|
|
1723
|
-
this.abortController = void 0;
|
|
1724
|
-
}
|
|
1725
|
-
this.stopHeartbeatCheck();
|
|
1726
|
-
if (stopReconnect && this.reconnectTimer) {
|
|
1727
|
-
clearTimeout(this.reconnectTimer);
|
|
1728
|
-
this.reconnectTimer = void 0;
|
|
1729
|
-
}
|
|
1730
|
-
}
|
|
1731
|
-
/**
|
|
1732
|
-
* Updates the function metadata (params, name) and reconnects
|
|
1733
|
-
*/
|
|
1734
|
-
updateMetadata(params, name) {
|
|
1735
|
-
this.params = params;
|
|
1736
|
-
this.name = name;
|
|
1737
|
-
this.reconnect();
|
|
1738
|
-
}
|
|
1739
|
-
/**
|
|
1740
|
-
* Shuts down the SSE client gracefully
|
|
1741
|
-
*/
|
|
1742
|
-
shutdown() {
|
|
1743
|
-
this.isShutdown = true;
|
|
1744
|
-
this.disconnect(true);
|
|
1745
|
-
this.emit("shutdown");
|
|
1746
|
-
this.removeAllListeners();
|
|
1747
|
-
}
|
|
1748
|
-
};
|
|
2149
|
+
//#region src/commands/logout/index.ts
|
|
1749
2150
|
/**
|
|
1750
|
-
*
|
|
1751
|
-
*
|
|
2151
|
+
* Best-effort server-side session revoke via POST /api/auth/sign-out with the
|
|
2152
|
+
* session token as Bearer. Logout MUST complete locally even if the server is
|
|
2153
|
+
* unreachable — the user expects "log me out" to remove the file. On any
|
|
2154
|
+
* failure we log to stderr and continue.
|
|
1752
2155
|
*/
|
|
1753
|
-
function
|
|
1754
|
-
|
|
2156
|
+
async function revokeSession(creds) {
|
|
2157
|
+
if (!creds.sessionToken || creds.sessionToken.length === 0) return;
|
|
2158
|
+
const url = `${trimSlash$2(creds.issuer)}/api/auth/sign-out`;
|
|
2159
|
+
try {
|
|
2160
|
+
const res = await fetch(url, {
|
|
2161
|
+
method: "POST",
|
|
2162
|
+
headers: {
|
|
2163
|
+
authorization: `Bearer ${creds.sessionToken}`,
|
|
2164
|
+
"content-type": "application/json"
|
|
2165
|
+
},
|
|
2166
|
+
body: "{}"
|
|
2167
|
+
});
|
|
2168
|
+
if (!res.ok) process.stderr.write(`warning: session revoke at ${url} returned ${res.status}; local credentials still removed.
|
|
2169
|
+
`);
|
|
2170
|
+
} catch (e) {
|
|
2171
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
2172
|
+
process.stderr.write(`warning: session revoke at ${url} failed (${msg}); local credentials still removed.\n`);
|
|
2173
|
+
}
|
|
2174
|
+
}
|
|
2175
|
+
async function handleLogout() {
|
|
2176
|
+
const creds = await readCredentials();
|
|
2177
|
+
if (!creds) {
|
|
2178
|
+
process.stderr.write("Already logged out.\n");
|
|
2179
|
+
return;
|
|
2180
|
+
}
|
|
2181
|
+
const label = creds.userEmail ?? creds.userId;
|
|
2182
|
+
await deleteCredentials();
|
|
2183
|
+
await revokeSession(creds);
|
|
2184
|
+
process.stderr.write(`Logged out of ${label}. Removed ${credentialsPath()}.\n`);
|
|
2185
|
+
}
|
|
2186
|
+
function trimSlash$2(url) {
|
|
2187
|
+
return url.replace(/\/+$/, "");
|
|
1755
2188
|
}
|
|
1756
2189
|
//#endregion
|
|
1757
|
-
//#region src/
|
|
1758
|
-
const logger$3 = initializeLogger();
|
|
2190
|
+
//#region src/commands/project/index.ts
|
|
1759
2191
|
/**
|
|
1760
|
-
*
|
|
2192
|
+
* List the projects the signed-in user can access (discovery — no project
|
|
2193
|
+
* scope). Pure handler: the `withUserToken` wrapper resolves the user-token
|
|
2194
|
+
* client and owns the error envelope.
|
|
1761
2195
|
*/
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
break;
|
|
1791
|
-
case "error":
|
|
1792
|
-
hasError = true;
|
|
1793
|
-
logger$3.error(`Worker error: ${message.error}`);
|
|
1794
|
-
if (message.stack) logger$3.error(message.stack);
|
|
1795
|
-
break;
|
|
1796
|
-
}
|
|
1797
|
-
} catch {
|
|
1798
|
-
logger$3.debug("Failed to parse worker protocol message. Printing raw line");
|
|
1799
|
-
console.log(line.substring(16));
|
|
1800
|
-
}
|
|
1801
|
-
else console.log(line);
|
|
1802
|
-
});
|
|
1803
|
-
child.stderr.on("data", (data) => {
|
|
1804
|
-
process.stderr.write(data);
|
|
1805
|
-
});
|
|
1806
|
-
child.on("exit", (code, signal) => {
|
|
1807
|
-
if (this.currentProcess?.pid === child.pid) this.currentProcess = null;
|
|
1808
|
-
if (signal) reject(/* @__PURE__ */ new Error(`Worker terminated by signal: ${signal}`));
|
|
1809
|
-
else if (code === 0) resolve(result);
|
|
1810
|
-
else {
|
|
1811
|
-
if (!hasError) logger$3.error(`Worker exited with code ${code}`);
|
|
1812
|
-
reject(/* @__PURE__ */ new Error(`Worker exited with code ${code}`));
|
|
1813
|
-
}
|
|
1814
|
-
});
|
|
1815
|
-
child.on("error", (error) => {
|
|
1816
|
-
this.currentProcess = null;
|
|
1817
|
-
reject(/* @__PURE__ */ new Error(`Failed to spawn worker: ${error.message}`));
|
|
1818
|
-
});
|
|
1819
|
-
child.stdin?.write(JSON.stringify(config) + "\n");
|
|
1820
|
-
child.stdin?.end();
|
|
1821
|
-
});
|
|
1822
|
-
}
|
|
1823
|
-
/**
|
|
1824
|
-
* Kill the currently running subprocess
|
|
1825
|
-
* @returns true if a process was killed, false if no process was running
|
|
1826
|
-
*/
|
|
1827
|
-
kill() {
|
|
1828
|
-
if (this.currentProcess) {
|
|
1829
|
-
const processToKill = this.currentProcess;
|
|
1830
|
-
this.currentProcess.kill("SIGTERM");
|
|
1831
|
-
setTimeout(() => {
|
|
1832
|
-
if (processToKill && processToKill.exitCode === null) {
|
|
1833
|
-
logger$3.warn("Child process did not terminate, using SIGKILL");
|
|
1834
|
-
processToKill.kill("SIGKILL");
|
|
1835
|
-
}
|
|
1836
|
-
}, 5e3);
|
|
1837
|
-
return true;
|
|
1838
|
-
}
|
|
1839
|
-
return false;
|
|
1840
|
-
}
|
|
1841
|
-
/**
|
|
1842
|
-
* Check if a subprocess is currently running
|
|
1843
|
-
*/
|
|
1844
|
-
isRunning() {
|
|
1845
|
-
return this.currentProcess !== null;
|
|
1846
|
-
}
|
|
2196
|
+
const handleProjectsList = async (client, opts) => {
|
|
2197
|
+
const projects = await client.cli.listProjects();
|
|
2198
|
+
const linked = (await readProjectLink())?.projectId;
|
|
2199
|
+
if (opts.json) {
|
|
2200
|
+
outputJson(projects.map((p) => ({
|
|
2201
|
+
...p,
|
|
2202
|
+
linked: p.id === linked
|
|
2203
|
+
})));
|
|
2204
|
+
return;
|
|
2205
|
+
}
|
|
2206
|
+
if (projects.length === 0) {
|
|
2207
|
+
console.log("No projects found. Create one in the dashboard, then run `lmnr-cli setup`.");
|
|
2208
|
+
return;
|
|
2209
|
+
}
|
|
2210
|
+
const columns = [
|
|
2211
|
+
"",
|
|
2212
|
+
"Workspace",
|
|
2213
|
+
"Project",
|
|
2214
|
+
"Project ID"
|
|
2215
|
+
];
|
|
2216
|
+
const rows = projects.map((p) => [
|
|
2217
|
+
p.id === linked ? "●" : "",
|
|
2218
|
+
p.workspaceName,
|
|
2219
|
+
p.name,
|
|
2220
|
+
p.id
|
|
2221
|
+
]);
|
|
2222
|
+
console.log(renderTable(columns, rows));
|
|
2223
|
+
console.log(linked ? "\n● = linked to this directory (lmnr-cli setup). Override per-command with --project-id.\n" : "\nNot linked here. Run `lmnr-cli setup` in your project directory, or pass --project-id.\n");
|
|
1847
2224
|
};
|
|
1848
2225
|
//#endregion
|
|
1849
|
-
//#region src/
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
}
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
2226
|
+
//#region src/auth/project-id.ts
|
|
2227
|
+
function trimSlash$1(url) {
|
|
2228
|
+
return url.replace(/\/+$/, "");
|
|
2229
|
+
}
|
|
2230
|
+
async function probeProjectKey(projectApiKey, baseUrl = DEFAULT_BASE_URL$1, port) {
|
|
2231
|
+
const url = new URL(trimSlash$1(baseUrl));
|
|
2232
|
+
if (port) url.port = String(port);
|
|
2233
|
+
url.pathname = "/v1/project";
|
|
2234
|
+
let res;
|
|
2235
|
+
try {
|
|
2236
|
+
res = await fetch(url.toString(), {
|
|
2237
|
+
method: "GET",
|
|
2238
|
+
headers: {
|
|
2239
|
+
Authorization: `Bearer ${projectApiKey}`,
|
|
2240
|
+
Accept: "application/json"
|
|
2241
|
+
}
|
|
2242
|
+
});
|
|
2243
|
+
} catch {
|
|
2244
|
+
return { status: "unverifiable" };
|
|
2245
|
+
}
|
|
2246
|
+
if (res.status === 401) return { status: "invalid" };
|
|
2247
|
+
if (!res.ok) return { status: "unverifiable" };
|
|
2248
|
+
const body = await res.json().catch(() => null);
|
|
2249
|
+
return body?.projectId ? {
|
|
2250
|
+
status: "ok",
|
|
2251
|
+
projectId: body.projectId
|
|
2252
|
+
} : { status: "unverifiable" };
|
|
2253
|
+
}
|
|
2254
|
+
//#endregion
|
|
2255
|
+
//#region src/utils/env-file.ts
|
|
2256
|
+
const execFileAsync = (0, node_util.promisify)(node_child_process.execFile);
|
|
2257
|
+
const DEFAULT_VAR_NAME = "LMNR_PROJECT_API_KEY";
|
|
2258
|
+
const CANDIDATE_FILES = [".env.local", ".env"];
|
|
2259
|
+
/**
|
|
2260
|
+
* Write/replace `<varName>=<value>` in the .env file at `envPath`.
|
|
2261
|
+
*
|
|
2262
|
+
* Semantics:
|
|
2263
|
+
* - If the file does not exist: create it with mode 0o600 and a single line.
|
|
2264
|
+
* - If the file exists:
|
|
2265
|
+
* - Replace an existing `^<varName>\s*=.*` line in place (preserves comments,
|
|
2266
|
+
* ordering, and other keys).
|
|
2267
|
+
* - Otherwise append the line (with a leading newline if the file does not
|
|
2268
|
+
* end in one).
|
|
2269
|
+
* - DO NOT change the file mode on existing files — the user may have set
|
|
2270
|
+
* a deliberate mode and changing it surprises them.
|
|
2271
|
+
* - Writes are atomic: write to `<envPath>.tmp`, then rename.
|
|
2272
|
+
*/
|
|
2273
|
+
async function writeEnvFile(envPath, value, varName = DEFAULT_VAR_NAME) {
|
|
2274
|
+
if (!await fileExists(envPath)) {
|
|
2275
|
+
await atomicWrite(envPath, `${varName}=${value}\n`, 384);
|
|
2276
|
+
return {
|
|
2277
|
+
path: envPath,
|
|
2278
|
+
created: true,
|
|
2279
|
+
replaced: false
|
|
2280
|
+
};
|
|
1889
2281
|
}
|
|
1890
|
-
|
|
2282
|
+
const original = await (0, node_fs_promises.readFile)(envPath, "utf-8");
|
|
2283
|
+
const regex = new RegExp(`^${escapeRegex(varName)}\\s*=.*$`, "m");
|
|
2284
|
+
let next;
|
|
2285
|
+
let replaced = false;
|
|
2286
|
+
if (regex.test(original)) {
|
|
2287
|
+
next = original.replace(regex, `${varName}=${value}`);
|
|
2288
|
+
replaced = true;
|
|
2289
|
+
} else next = `${original.endsWith("\n") || original.length === 0 ? original : `${original}\n`}${varName}=${value}\n`;
|
|
2290
|
+
const existingMode = (await (0, node_fs_promises.stat)(envPath)).mode & 511;
|
|
2291
|
+
await atomicWrite(envPath, next, existingMode);
|
|
2292
|
+
return {
|
|
2293
|
+
path: envPath,
|
|
2294
|
+
created: false,
|
|
2295
|
+
replaced
|
|
2296
|
+
};
|
|
2297
|
+
}
|
|
2298
|
+
/**
|
|
2299
|
+
* Read a single env var's value from a .env file. Returns null when the file
|
|
2300
|
+
* is missing, the var is absent, or its value is empty/whitespace.
|
|
2301
|
+
*/
|
|
2302
|
+
async function readEnvVar(envPath, varName = DEFAULT_VAR_NAME) {
|
|
2303
|
+
if (!await fileExists(envPath)) return null;
|
|
2304
|
+
const original = await (0, node_fs_promises.readFile)(envPath, "utf-8");
|
|
2305
|
+
const regex = new RegExp(`^${escapeRegex(varName)}\\s*=(.*)$`, "m");
|
|
2306
|
+
const match = original.match(regex);
|
|
2307
|
+
if (!match) return null;
|
|
2308
|
+
const value = match[1].trim().replace(/^["']|["']$/g, "");
|
|
2309
|
+
return value.length > 0 ? value : null;
|
|
2310
|
+
}
|
|
1891
2311
|
/**
|
|
1892
|
-
*
|
|
1893
|
-
*
|
|
2312
|
+
* Find an already-configured key, checking `process.env` → `.env.local` → `.env`
|
|
2313
|
+
* (first match wins, mirroring Next.js precedence). `process.env` comes first
|
|
2314
|
+
* because an exported var is what actually runs, regardless of language.
|
|
1894
2315
|
*/
|
|
1895
|
-
function
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
2316
|
+
async function findEnvKey(cwd, varName = DEFAULT_VAR_NAME) {
|
|
2317
|
+
const fromProcess = process.env[varName]?.trim();
|
|
2318
|
+
if (fromProcess) return {
|
|
2319
|
+
value: fromProcess,
|
|
2320
|
+
source: { type: "process-env" }
|
|
1899
2321
|
};
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
".cjs",
|
|
1910
|
-
".mts",
|
|
1911
|
-
".cts",
|
|
1912
|
-
".jsx"
|
|
1913
|
-
].includes(ext)) try {
|
|
1914
|
-
const workerPath = require.resolve("@lmnr-ai/lmnr/dist/cli/worker/index.cjs");
|
|
1915
|
-
return {
|
|
1916
|
-
command: worker.command,
|
|
1917
|
-
args: [workerPath]
|
|
2322
|
+
for (const name of CANDIDATE_FILES) {
|
|
2323
|
+
const path = (0, node_path.resolve)(cwd, name);
|
|
2324
|
+
const value = await readEnvVar(path, varName);
|
|
2325
|
+
if (value) return {
|
|
2326
|
+
value,
|
|
2327
|
+
source: {
|
|
2328
|
+
type: "file",
|
|
2329
|
+
path
|
|
2330
|
+
}
|
|
1918
2331
|
};
|
|
1919
|
-
} catch (error) {
|
|
1920
|
-
throw new Error(`Failed to resolve TypeScript/JavaScript worker from @lmnr-ai/lmnr package. Make sure @lmnr-ai/lmnr is installed. Error: ${error instanceof Error ? error.message : String(error)}`);
|
|
1921
2332
|
}
|
|
1922
|
-
return
|
|
2333
|
+
return null;
|
|
1923
2334
|
}
|
|
1924
|
-
//#endregion
|
|
1925
|
-
//#region src/commands/dev/metadata.ts
|
|
1926
|
-
const logger$2 = initializeLogger();
|
|
1927
|
-
const TS_JS_EXTENSIONS = [
|
|
1928
|
-
".ts",
|
|
1929
|
-
".tsx",
|
|
1930
|
-
".js",
|
|
1931
|
-
".mjs",
|
|
1932
|
-
".cjs",
|
|
1933
|
-
".jsx",
|
|
1934
|
-
".mts",
|
|
1935
|
-
".cts"
|
|
1936
|
-
];
|
|
1937
|
-
const EXTENSIONS_TO_DISCOVER_METADATA = [...TS_JS_EXTENSIONS, ".py"];
|
|
1938
2335
|
/**
|
|
1939
|
-
*
|
|
1940
|
-
*
|
|
2336
|
+
* LMNR_* config keys the CLI hydrates from a project `.env.local` / `.env`.
|
|
2337
|
+
* Curated on purpose — we do NOT slurp the whole file, so unrelated app secrets
|
|
2338
|
+
* (model API keys, etc.) never enter the CLI process. Notable exclusions:
|
|
2339
|
+
* - `LMNR_GRPC_PORT`: the CLI is REST-only (no gRPC), so it has no use for it.
|
|
2340
|
+
* - `LMNR_LOG_LEVEL`: loggers are built at module-import time (before
|
|
2341
|
+
* `loadLocalEnv` runs), so hydrating it here would silently have no effect.
|
|
2342
|
+
* - `LMNR_PROJECT_ID`: the project comes from `--project-id` or
|
|
2343
|
+
* `.lmnr/project.json` only; `resolveAuth` ignores the env var, so loading
|
|
2344
|
+
* it here would just contradict that.
|
|
1941
2345
|
*/
|
|
1942
|
-
const
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
2346
|
+
const AUTOLOADED_ENV_KEYS = [
|
|
2347
|
+
"LMNR_BASE_URL",
|
|
2348
|
+
"LMNR_HTTP_PORT",
|
|
2349
|
+
"LMNR_FRONTEND_URL"
|
|
2350
|
+
];
|
|
1947
2351
|
/**
|
|
1948
|
-
*
|
|
1949
|
-
*
|
|
1950
|
-
*
|
|
1951
|
-
*
|
|
1952
|
-
* 4. Matching metadata by span name
|
|
2352
|
+
* Hydrate `process.env` from `.env.local` / `.env` in `cwd` for the curated
|
|
2353
|
+
* {@link AUTOLOADED_ENV_KEYS}, WITHOUT overriding values already present in the
|
|
2354
|
+
* environment (a real exported var / Claude Code `settings.json` env always
|
|
2355
|
+
* wins — `findEnvKey` checks `process.env` first and we skip those).
|
|
1953
2356
|
*
|
|
1954
|
-
*
|
|
1955
|
-
*
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
const tsParserPath = require.resolve(`${lmnrPackage}/dist/cli/worker/ts-parser.cjs`);
|
|
1965
|
-
const buildModulePath = require.resolve(`${lmnrPackage}/dist/cli/worker/build.cjs`);
|
|
1966
|
-
delete require.cache[tsParserPath];
|
|
1967
|
-
delete require.cache[buildModulePath];
|
|
1968
|
-
extractRolloutFunctions = require(tsParserPath).extractRolloutFunctions;
|
|
1969
|
-
const buildModule = require(buildModulePath);
|
|
1970
|
-
buildFile = buildModule.buildFile;
|
|
1971
|
-
loadModule = buildModule.loadModule;
|
|
1972
|
-
selectRolloutFunction = buildModule.selectRolloutFunction;
|
|
1973
|
-
if (!extractRolloutFunctions || !buildFile || !loadModule || !selectRolloutFunction) {
|
|
1974
|
-
logger$2.error("Missing exports from @lmnr-ai/lmnr modules. This may indicate an outdated package version.");
|
|
1975
|
-
logLmnrPackageNotFoundAndExit();
|
|
1976
|
-
}
|
|
1977
|
-
} catch (error) {
|
|
1978
|
-
if (error.code === "MODULE_NOT_FOUND") logLmnrPackageNotFoundAndExit();
|
|
1979
|
-
logger$2.error(`Unexpected error loading @lmnr-ai/lmnr modules: ${error.message}`);
|
|
1980
|
-
throw error;
|
|
2357
|
+
* Why this exists: Claude Code and many other runners do NOT inject a project
|
|
2358
|
+
* `.env` into a spawned subprocess's environment, so self-hosters who put
|
|
2359
|
+
* `LMNR_BASE_URL` / `LMNR_HTTP_PORT` in `.env` previously had to export them or
|
|
2360
|
+
* pass flags on every call. cwd-only, no upward directory walk (mirrors
|
|
2361
|
+
* dotenv's default) — the CLI must be invoked from the dir holding the `.env`.
|
|
2362
|
+
*/
|
|
2363
|
+
async function loadLocalEnv(cwd, keys = AUTOLOADED_ENV_KEYS) {
|
|
2364
|
+
for (const key of keys) {
|
|
2365
|
+
const found = await findEnvKey(cwd, key);
|
|
2366
|
+
if (found?.source.type === "file") process.env[key] = found.value;
|
|
1981
2367
|
}
|
|
1982
|
-
|
|
2368
|
+
}
|
|
2369
|
+
/**
|
|
2370
|
+
* Pick the file to write a freshly-minted key into:
|
|
2371
|
+
* - rewrite in place if the key already lives in a file,
|
|
2372
|
+
* - else prefer an existing `.env.local` (its presence proves the project opted
|
|
2373
|
+
* into the gitignored-secret convention) — but never CREATE one, since Python
|
|
2374
|
+
* loaders ignore it,
|
|
2375
|
+
* - else `.env` (the default every ecosystem loads).
|
|
2376
|
+
*/
|
|
2377
|
+
async function resolveEnvWriteTarget(cwd, existing) {
|
|
2378
|
+
if (existing?.source.type === "file") return existing.source.path;
|
|
2379
|
+
const local = (0, node_path.resolve)(cwd, ".env.local");
|
|
2380
|
+
if (await fileExists(local)) return local;
|
|
2381
|
+
return (0, node_path.resolve)(cwd, ".env");
|
|
2382
|
+
}
|
|
2383
|
+
/**
|
|
2384
|
+
* Whether `path` is gitignored. Returns null when it can't be determined (not a
|
|
2385
|
+
* git repo / git absent) so callers can stay silent rather than warn wrongly.
|
|
2386
|
+
*/
|
|
2387
|
+
async function isPathGitIgnored(path) {
|
|
1983
2388
|
try {
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
2389
|
+
await execFileAsync("git", [
|
|
2390
|
+
"check-ignore",
|
|
2391
|
+
"-q",
|
|
2392
|
+
path
|
|
2393
|
+
]);
|
|
2394
|
+
return true;
|
|
2395
|
+
} catch (err) {
|
|
2396
|
+
return err.code === 1 ? false : null;
|
|
1988
2397
|
}
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
});
|
|
1997
|
-
const selectedFunction = selectRolloutFunction(options.function);
|
|
1998
|
-
if (paramsMetadata) {
|
|
1999
|
-
logger$2.debug(`Available TS metadata keys: ${Array.from(paramsMetadata.keys()).join(", ")}`);
|
|
2000
|
-
logger$2.debug(`Looking for span name: ${selectedFunction.name} (runtime key: ${selectedFunction.exportName})`);
|
|
2001
|
-
let foundMetadata = null;
|
|
2002
|
-
for (const [exportName, metadata] of paramsMetadata.entries()) {
|
|
2003
|
-
logger$2.debug(`Checking ${exportName}: span name = ${metadata.name}, export name = ${exportName}`);
|
|
2004
|
-
if (metadata.name === selectedFunction.name) {
|
|
2005
|
-
foundMetadata = metadata;
|
|
2006
|
-
logger$2.debug(`Match. Export name: ${exportName}, span name: ${metadata.name}`);
|
|
2007
|
-
break;
|
|
2008
|
-
}
|
|
2009
|
-
}
|
|
2010
|
-
if (foundMetadata) {
|
|
2011
|
-
selectedFunction.params = foundMetadata.params;
|
|
2012
|
-
logger$2.debug(`Using TypeScript metadata for span: ${selectedFunction.name}`);
|
|
2013
|
-
} else logger$2.info(`No TypeScript metadata found for span name: ${selectedFunction.name}`);
|
|
2398
|
+
}
|
|
2399
|
+
async function fileExists(path) {
|
|
2400
|
+
try {
|
|
2401
|
+
await (0, node_fs_promises.access)(path);
|
|
2402
|
+
return true;
|
|
2403
|
+
} catch {
|
|
2404
|
+
return false;
|
|
2014
2405
|
}
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
};
|
|
2019
|
-
|
|
2406
|
+
}
|
|
2407
|
+
async function atomicWrite(path, contents, mode) {
|
|
2408
|
+
const tmp = `${path}.tmp`;
|
|
2409
|
+
await (0, node_fs_promises.writeFile)(tmp, contents, mode === void 0 ? void 0 : { mode });
|
|
2410
|
+
await (0, node_fs_promises.rename)(tmp, path);
|
|
2411
|
+
}
|
|
2412
|
+
function escapeRegex(raw) {
|
|
2413
|
+
return raw.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
2414
|
+
}
|
|
2415
|
+
//#endregion
|
|
2416
|
+
//#region src/skill/laminar-skill.ts
|
|
2417
|
+
const SKILL_REPO = "lmnr-ai/lmnr-skills";
|
|
2418
|
+
const SKILL_REF = "main";
|
|
2419
|
+
const SKILL_NAME = "laminar";
|
|
2020
2420
|
/**
|
|
2021
|
-
*
|
|
2421
|
+
* giget source for the pinned skill subdir: `github:<owner>/<repo>/<subdir>#<ref>`.
|
|
2422
|
+
* giget resolves this against codeload.github.com, gunzips, untars, strips the
|
|
2423
|
+
* `repo-<ref>/` prefix, and extracts only the subdir — all the mechanics we'd
|
|
2424
|
+
* otherwise hand-roll.
|
|
2022
2425
|
*/
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
child.stdout.on("data", (data) => {
|
|
2029
|
-
stdout += data.toString();
|
|
2030
|
-
});
|
|
2031
|
-
child.stderr.on("data", (data) => {
|
|
2032
|
-
stderr += data.toString();
|
|
2033
|
-
});
|
|
2034
|
-
child.on("close", (code) => {
|
|
2035
|
-
if (code === 0) resolve({
|
|
2036
|
-
stdout,
|
|
2037
|
-
stderr
|
|
2038
|
-
});
|
|
2039
|
-
else reject(/* @__PURE__ */ new Error(`Command failed with code ${code}: ${stderr}`));
|
|
2040
|
-
});
|
|
2041
|
-
child.on("error", (error) => {
|
|
2042
|
-
reject(error);
|
|
2043
|
-
});
|
|
2044
|
-
});
|
|
2426
|
+
function skillSource() {
|
|
2427
|
+
return `github:${SKILL_REPO}/skills/${SKILL_NAME}#${SKILL_REF}`;
|
|
2428
|
+
}
|
|
2429
|
+
//#endregion
|
|
2430
|
+
//#region src/skill/fetch-skill.ts
|
|
2045
2431
|
/**
|
|
2046
|
-
*
|
|
2047
|
-
*
|
|
2432
|
+
* Download the pinned Laminar skill subtree into `dir` using giget.
|
|
2433
|
+
*
|
|
2434
|
+
* giget owns every quirk we'd otherwise hand-roll: the codeload tarball URL,
|
|
2435
|
+
* gunzip, untar, stripping the `repo-<ref>/` leading segment, subdir filtering,
|
|
2436
|
+
* and ref pinning. `force` overwrites `dir` so reruns are idempotent.
|
|
2048
2437
|
*
|
|
2049
|
-
*
|
|
2050
|
-
*
|
|
2051
|
-
*
|
|
2438
|
+
* We deliberately do NOT pass `preferOffline`: giget caches the tarball keyed by
|
|
2439
|
+
* REF name, and SKILL_REF is a moving branch (`main` / a feature branch), so
|
|
2440
|
+
* `preferOffline` would reuse a stale cached tarball forever — reinstalling
|
|
2441
|
+
* files that were since deleted upstream. Without it, giget revalidates via the
|
|
2442
|
+
* stored etag and re-downloads when the branch moved.
|
|
2443
|
+
*
|
|
2444
|
+
* Throws on network / resolve / extract errors — callers (skill install is
|
|
2445
|
+
* best-effort) catch and skip.
|
|
2052
2446
|
*/
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
for (const pos of prefixPositions) {
|
|
2069
|
-
const startPos = pos + 14;
|
|
2070
|
-
const jsonText = stdout.slice(startPos).trim();
|
|
2071
|
-
const nextNewline = stdout.indexOf("\n", startPos);
|
|
2072
|
-
if (nextNewline !== -1) {
|
|
2073
|
-
const lineText = stdout.slice(startPos, nextNewline).trim();
|
|
2074
|
-
try {
|
|
2075
|
-
lastValidJson = JSON.parse(lineText);
|
|
2076
|
-
continue;
|
|
2077
|
-
} catch {}
|
|
2078
|
-
}
|
|
2079
|
-
try {
|
|
2080
|
-
let depth = 0;
|
|
2081
|
-
let inString = false;
|
|
2082
|
-
let escapeNext = false;
|
|
2083
|
-
let firstChar = -1;
|
|
2084
|
-
for (let i = 0; i < jsonText.length; i++) {
|
|
2085
|
-
const char = jsonText[i];
|
|
2086
|
-
if (escapeNext) {
|
|
2087
|
-
escapeNext = false;
|
|
2088
|
-
continue;
|
|
2089
|
-
}
|
|
2090
|
-
if (char === "\\" && inString) {
|
|
2091
|
-
escapeNext = true;
|
|
2092
|
-
continue;
|
|
2093
|
-
}
|
|
2094
|
-
if (char === "\"") {
|
|
2095
|
-
inString = !inString;
|
|
2096
|
-
continue;
|
|
2097
|
-
}
|
|
2098
|
-
if (inString) continue;
|
|
2099
|
-
if (char === "{" || char === "[") {
|
|
2100
|
-
if (firstChar === -1) firstChar = i;
|
|
2101
|
-
depth++;
|
|
2102
|
-
} else if (char === "}" || char === "]") {
|
|
2103
|
-
depth--;
|
|
2104
|
-
if (depth === 0 && firstChar !== -1) {
|
|
2105
|
-
const candidate = jsonText.slice(0, i + 1);
|
|
2106
|
-
lastValidJson = JSON.parse(candidate);
|
|
2107
|
-
break;
|
|
2108
|
-
}
|
|
2109
|
-
}
|
|
2110
|
-
}
|
|
2111
|
-
if (depth !== 0 || firstChar === -1) lastValidJson = JSON.parse(jsonText);
|
|
2112
|
-
} catch {
|
|
2113
|
-
continue;
|
|
2114
|
-
}
|
|
2115
|
-
}
|
|
2116
|
-
if (lastValidJson === null) throw new Error("No valid metadata JSON found in output. Please make sure you are running the latest version of `lmnr` python package.");
|
|
2117
|
-
return lastValidJson;
|
|
2118
|
-
};
|
|
2447
|
+
async function downloadSkill(dir) {
|
|
2448
|
+
await (0, giget.downloadTemplate)(skillSource(), {
|
|
2449
|
+
dir,
|
|
2450
|
+
force: true
|
|
2451
|
+
});
|
|
2452
|
+
}
|
|
2453
|
+
//#endregion
|
|
2454
|
+
//#region src/utils/install-skill.ts
|
|
2455
|
+
const AGENT_DIRS = [
|
|
2456
|
+
".claude",
|
|
2457
|
+
".cursor",
|
|
2458
|
+
".codex",
|
|
2459
|
+
".agents"
|
|
2460
|
+
];
|
|
2461
|
+
const DEFAULT_AGENT_DIRS = [".claude", ".agents"];
|
|
2119
2462
|
/**
|
|
2120
|
-
*
|
|
2463
|
+
* Fetch the pinned Laminar skill (`SKILL_NAME`) from the lmnr-skills repo and
|
|
2464
|
+
* write its full tree into every present agent dir under `cwd`
|
|
2465
|
+
* (`<dir>/skills/<SKILL_NAME>/SKILL.md`, `.../references/*.md`, ...). If none
|
|
2466
|
+
* are present, default to BOTH `.claude/` and `.agents/`. Idempotent —
|
|
2467
|
+
* overwrites on rerun.
|
|
2468
|
+
*
|
|
2469
|
+
* We download ONCE into a temp staging dir (one network hit) and copy it into
|
|
2470
|
+
* each agent dir. Skill install is the last, best-effort step of `setup`: a
|
|
2471
|
+
* download failure MUST NOT break setup — on error we log a warning and return
|
|
2472
|
+
* `{ written: [], defaulted: false, skipped: true }`.
|
|
2473
|
+
*
|
|
2474
|
+
* For .cursor/.codex the skills layout is not guaranteed to match CC; we write
|
|
2475
|
+
* the CC-guaranteed `skills/<SKILL_NAME>/` shape there too rather than
|
|
2476
|
+
* inventing a path that silently loads nothing.
|
|
2121
2477
|
*/
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
const args = ["discover"];
|
|
2125
|
-
if (options.pythonModule) args.push("--module", options.pythonModule);
|
|
2126
|
-
else args.push("--file", filePathOrModule);
|
|
2127
|
-
if (options.function) args.push("--function", options.function);
|
|
2478
|
+
async function installSkill(cwd = process.cwd()) {
|
|
2479
|
+
const staging = await (0, node_fs_promises.mkdtemp)((0, node_path.join)((0, node_os.tmpdir)(), "lmnr-skill-"));
|
|
2128
2480
|
try {
|
|
2129
|
-
|
|
2481
|
+
try {
|
|
2482
|
+
await downloadSkill(staging);
|
|
2483
|
+
} catch (err) {
|
|
2484
|
+
process.stderr.write(`Warning: could not fetch the Laminar skill (${skillSource()}): ${describeError$1(err)}; skipping skill install.\n`);
|
|
2485
|
+
return {
|
|
2486
|
+
written: [],
|
|
2487
|
+
defaulted: false,
|
|
2488
|
+
skipped: true
|
|
2489
|
+
};
|
|
2490
|
+
}
|
|
2491
|
+
const relFiles = (await (0, node_fs_promises.readdir)(staging, {
|
|
2492
|
+
recursive: true,
|
|
2493
|
+
withFileTypes: true
|
|
2494
|
+
})).filter((d) => d.isFile()).map((d) => (0, node_path.relative)(staging, (0, node_path.join)(d.parentPath, d.name)));
|
|
2495
|
+
const present = [];
|
|
2496
|
+
for (const dir of AGENT_DIRS) if (await dirExists((0, node_path.join)(cwd, dir))) present.push(dir);
|
|
2497
|
+
const targets = present.length > 0 ? present : DEFAULT_AGENT_DIRS;
|
|
2498
|
+
const written = [];
|
|
2499
|
+
for (const dir of targets) {
|
|
2500
|
+
const skillRoot = (0, node_path.join)(cwd, dir, "skills", SKILL_NAME);
|
|
2501
|
+
await (0, node_fs_promises.rm)(skillRoot, {
|
|
2502
|
+
recursive: true,
|
|
2503
|
+
force: true
|
|
2504
|
+
});
|
|
2505
|
+
await (0, node_fs_promises.mkdir)(skillRoot, { recursive: true });
|
|
2506
|
+
await (0, node_fs_promises.cp)(staging, skillRoot, { recursive: true });
|
|
2507
|
+
for (const rel of relFiles) written.push((0, node_path.join)(skillRoot, rel));
|
|
2508
|
+
}
|
|
2130
2509
|
return {
|
|
2131
|
-
|
|
2132
|
-
|
|
2510
|
+
written,
|
|
2511
|
+
defaulted: present.length === 0,
|
|
2512
|
+
skipped: false
|
|
2133
2513
|
};
|
|
2134
|
-
}
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2514
|
+
} finally {
|
|
2515
|
+
await (0, node_fs_promises.rm)(staging, {
|
|
2516
|
+
recursive: true,
|
|
2517
|
+
force: true
|
|
2518
|
+
});
|
|
2139
2519
|
}
|
|
2140
|
-
};
|
|
2141
|
-
/**
|
|
2142
|
-
* Generic metadata discovery dispatcher that routes to language-specific implementations
|
|
2143
|
-
*/
|
|
2144
|
-
const discoverFunctionMetadata = async (filePathOrModule, options) => {
|
|
2145
|
-
if (options.pythonModule) return await discoverPythonMetadata(filePathOrModule, options);
|
|
2146
|
-
const ext = path.extname(filePathOrModule);
|
|
2147
|
-
if (TS_JS_EXTENSIONS.includes(ext)) return await discoverTypeScriptMetadata(filePathOrModule, options);
|
|
2148
|
-
if (ext === ".py") return await discoverPythonMetadata(filePathOrModule, options);
|
|
2149
|
-
logger$2.warn(`No metadata discovery available for ${ext} files`);
|
|
2150
|
-
return {
|
|
2151
|
-
functionName: options.function || path.basename(filePathOrModule, ext),
|
|
2152
|
-
params: []
|
|
2153
|
-
};
|
|
2154
|
-
};
|
|
2155
|
-
//#endregion
|
|
2156
|
-
//#region src/commands/dev/index.ts
|
|
2157
|
-
const logger$1 = initializeLogger();
|
|
2158
|
-
function newUUID() {
|
|
2159
|
-
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") return crypto.randomUUID();
|
|
2160
|
-
return (0, uuid.v4)();
|
|
2161
2520
|
}
|
|
2162
|
-
function
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
url = url.replace(/:\d{1,5}$/g, "");
|
|
2169
|
-
return `${url}:${port}`;
|
|
2521
|
+
async function dirExists(path) {
|
|
2522
|
+
try {
|
|
2523
|
+
await (0, node_fs_promises.access)(path);
|
|
2524
|
+
return true;
|
|
2525
|
+
} catch {
|
|
2526
|
+
return false;
|
|
2170
2527
|
}
|
|
2171
|
-
return url;
|
|
2172
2528
|
}
|
|
2529
|
+
function describeError$1(err) {
|
|
2530
|
+
return err instanceof Error ? err.message : String(err);
|
|
2531
|
+
}
|
|
2532
|
+
//#endregion
|
|
2533
|
+
//#region src/commands/setup/index.ts
|
|
2534
|
+
const DEFAULT_FRONTEND_URL = "https://www.laminar.sh";
|
|
2535
|
+
const DEFAULT_BASE_URL = "https://api.lmnr.ai";
|
|
2536
|
+
const EXIT_NO_ACCESS = 4;
|
|
2537
|
+
const EXIT_LOGIN_FAILED = 6;
|
|
2538
|
+
const EXIT_NO_PROJECT = 7;
|
|
2539
|
+
const EXIT_ENV_WRITE_FAILED = 8;
|
|
2540
|
+
const EXIT_SETUP_KEY_FAILED = 9;
|
|
2541
|
+
const EXIT_LIST_PROJECTS_FAILED = 10;
|
|
2542
|
+
const EXIT_KEY_PROBE_FAILED = 11;
|
|
2543
|
+
const EXIT_KEY_MISMATCH = 12;
|
|
2173
2544
|
/**
|
|
2174
|
-
*
|
|
2545
|
+
* Directory-scoped onboarding (SPEC decision tree):
|
|
2546
|
+
* - log in if needed (browser picks/creates the project; its id rides back on
|
|
2547
|
+
* the device-token metadata, see parseProjectFromMetadata),
|
|
2548
|
+
* - resolve a project for this directory (`.lmnr/project.json`), enforcing
|
|
2549
|
+
* access,
|
|
2550
|
+
* - mint a project API key only when one isn't already configured for this
|
|
2551
|
+
* project (checked across process.env → .env.local → .env), then write it to
|
|
2552
|
+
* an existing .env.local or else .env,
|
|
2553
|
+
* - install the Laminar skill into present agent dirs,
|
|
2554
|
+
* - print a summary.
|
|
2555
|
+
*
|
|
2556
|
+
* The minted key goes ONLY into the project's env file, never into
|
|
2557
|
+
* credentials.json (which stores user-scoped BetterAuth tokens).
|
|
2175
2558
|
*/
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2559
|
+
async function handleSetup(options) {
|
|
2560
|
+
const writeEnv = options.writeEnv !== false;
|
|
2561
|
+
const frontendUrl = pick(options.frontendUrl, process.env.LMNR_FRONTEND_URL, DEFAULT_FRONTEND_URL);
|
|
2562
|
+
const baseUrl = pick(options.baseUrl, process.env.LMNR_BASE_URL, DEFAULT_BASE_URL);
|
|
2563
|
+
const isJson = options.json === true;
|
|
2564
|
+
if (!isJson) process.stderr.write(`\n${orange("Laminar CLI")} ${pc.dim(`v${version$1}`)}\n\n`);
|
|
2565
|
+
const cwd = process.cwd();
|
|
2566
|
+
const existingKey = await findEnvKey(cwd);
|
|
2567
|
+
let creds = await safeReadCredentials();
|
|
2568
|
+
let link = await readProjectLink();
|
|
2569
|
+
if (!creds) {
|
|
2570
|
+
let login;
|
|
2571
|
+
try {
|
|
2572
|
+
login = await handleLogin({
|
|
2573
|
+
frontendUrl,
|
|
2574
|
+
noBrowser: options.noBrowser
|
|
2575
|
+
});
|
|
2576
|
+
} catch (err) {
|
|
2577
|
+
emitError(isJson, "login_failed", describeError(err));
|
|
2578
|
+
process.exit(EXIT_LOGIN_FAILED);
|
|
2579
|
+
}
|
|
2580
|
+
creds = await safeReadCredentials();
|
|
2581
|
+
if (!creds) {
|
|
2582
|
+
emitError(isJson, "login_failed", "credentials missing after login");
|
|
2583
|
+
process.exit(EXIT_LOGIN_FAILED);
|
|
2584
|
+
}
|
|
2585
|
+
const issuer = creds.issuer || frontendUrl;
|
|
2586
|
+
const userBaseUrl = baseUrl;
|
|
2587
|
+
if (link) await assertAccess(creds, userBaseUrl, link.projectId, isJson);
|
|
2588
|
+
else if (login.projectId) link = await writeLink(issuer, userBaseUrl, login.projectId, isJson);
|
|
2589
|
+
else link = await resolveProjectViaCli(creds, userBaseUrl, issuer, isJson, options);
|
|
2590
|
+
} else {
|
|
2591
|
+
const userBaseUrl = baseUrl;
|
|
2592
|
+
const issuer = creds.issuer || frontendUrl;
|
|
2593
|
+
if (link) await assertAccess(creds, userBaseUrl, link.projectId, isJson);
|
|
2594
|
+
else link = await resolveProjectViaCli(creds, userBaseUrl, issuer, isJson, options);
|
|
2595
|
+
}
|
|
2596
|
+
if (!isJson) {
|
|
2597
|
+
process.stderr.write(`${pc.green("✓")} Logged in as ${creds.userEmail ?? "<unknown>"}\n`);
|
|
2598
|
+
process.stderr.write(`${pc.green("✓")} Project: ${link.projectName ?? link.projectId}` + (link.workspaceName ? pc.dim(` (${link.workspaceName})`) : "") + "\n");
|
|
2599
|
+
}
|
|
2600
|
+
if (!creds || !link.projectId) {
|
|
2601
|
+
emitError(isJson, "setup_invariant", "missing credentials or project after resolution");
|
|
2602
|
+
process.exit(EXIT_NO_PROJECT);
|
|
2603
|
+
}
|
|
2604
|
+
const issuer = creds.issuer || frontendUrl;
|
|
2605
|
+
const userBaseUrl = baseUrl;
|
|
2606
|
+
let apiKey = null;
|
|
2607
|
+
let envPath = null;
|
|
2608
|
+
let keyMeta = null;
|
|
2609
|
+
let needMint = true;
|
|
2610
|
+
if (existingKey) {
|
|
2611
|
+
const probe = await probeProjectKey(existingKey.value, userBaseUrl, envHttpPort());
|
|
2612
|
+
const where = existingKey.source.type === "process-env" ? "your environment" : (0, node_path.relative)(cwd, existingKey.source.path);
|
|
2613
|
+
if (probe.status === "unverifiable") {
|
|
2614
|
+
emitError(isJson, "key_probe_failed", `Couldn't verify the existing Project API Key in ${where} (network or server error). Check your connection and re-run.`);
|
|
2615
|
+
process.exit(EXIT_KEY_PROBE_FAILED);
|
|
2616
|
+
} else if (probe.status === "ok" && probe.projectId === link.projectId) {
|
|
2617
|
+
needMint = false;
|
|
2618
|
+
if (!isJson) process.stderr.write(`${pc.green("✓")} Project API Key already set in ${where}\n`);
|
|
2619
|
+
} else if (probe.status === "ok") {
|
|
2620
|
+
emitError(isJson, "key_mismatch", `The Project API Key in ${where} belongs to a different project (${probe.projectId}), not the one linked here (${link.projectId}). Remove or update it, then re-run.`);
|
|
2621
|
+
process.exit(EXIT_KEY_MISMATCH);
|
|
2622
|
+
} else if (!isJson) process.stderr.write(`${pc.yellow("⚠")} Existing Project API Key in ${where} is invalid or revoked, minting a new one\n`);
|
|
2623
|
+
}
|
|
2624
|
+
if (needMint) {
|
|
2625
|
+
try {
|
|
2626
|
+
keyMeta = await mintSetupKey(issuer, creds.sessionToken, link.projectId);
|
|
2627
|
+
} catch (err) {
|
|
2628
|
+
emitError(isJson, "setup_key_failed", describeError(err));
|
|
2629
|
+
process.exit(EXIT_SETUP_KEY_FAILED);
|
|
2630
|
+
}
|
|
2631
|
+
apiKey = keyMeta.apiKey;
|
|
2632
|
+
if (!link.projectName && keyMeta.projectName) link.projectName = keyMeta.projectName;
|
|
2633
|
+
if (!link.workspaceName && keyMeta.workspaceName) link.workspaceName = keyMeta.workspaceName;
|
|
2634
|
+
if (!link.workspaceId && keyMeta.workspaceId) link.workspaceId = keyMeta.workspaceId;
|
|
2635
|
+
if (writeEnv) {
|
|
2636
|
+
const target = await resolveEnvWriteTarget(cwd, existingKey);
|
|
2637
|
+
try {
|
|
2638
|
+
const result = await writeEnvFile(target, apiKey);
|
|
2639
|
+
envPath = result.path;
|
|
2640
|
+
if (!isJson) {
|
|
2641
|
+
const rel = (0, node_path.relative)(cwd, result.path);
|
|
2642
|
+
const verb = result.created ? "Created" : result.replaced ? "Updated LMNR_PROJECT_API_KEY in" : "Added LMNR_PROJECT_API_KEY to";
|
|
2643
|
+
process.stderr.write(`${pc.green("✓")} ${verb} ${rel}\n`);
|
|
2644
|
+
if (await isPathGitIgnored(result.path) === false) process.stderr.write(`${pc.yellow("⚠")} ${rel} isn't gitignored; add it so the key isn't committed\n`);
|
|
2645
|
+
}
|
|
2646
|
+
} catch (err) {
|
|
2647
|
+
process.stderr.write(`\n${pc.red("ERROR")}: failed to write ${target}: ${describeError(err)}\n` + pc.dim("Your API key (set it manually):") + `\n LMNR_PROJECT_API_KEY=${apiKey}\n\n`);
|
|
2648
|
+
if (isJson) process.stdout.write(JSON.stringify({
|
|
2649
|
+
error: "env_write_failed",
|
|
2650
|
+
apiKey,
|
|
2651
|
+
projectId: link.projectId,
|
|
2652
|
+
message: describeError(err)
|
|
2653
|
+
}) + "\n");
|
|
2654
|
+
process.exit(EXIT_ENV_WRITE_FAILED);
|
|
2655
|
+
}
|
|
2656
|
+
}
|
|
2181
2657
|
}
|
|
2182
|
-
|
|
2183
|
-
|
|
2658
|
+
let skillsInstalled = [];
|
|
2659
|
+
try {
|
|
2660
|
+
const skillResult = await installSkill(process.cwd());
|
|
2661
|
+
skillsInstalled = skillResult.written;
|
|
2662
|
+
if (!isJson) {
|
|
2663
|
+
if (skillResult.skipped) process.stderr.write(pc.dim(" Laminar skill install skipped\n"));
|
|
2664
|
+
else if (skillResult.written.length > 0) {
|
|
2665
|
+
const note = skillResult.defaulted ? pc.dim(" (no agent dir found; defaulted to .claude and .agents)") : "";
|
|
2666
|
+
process.stderr.write(`${pc.green("✓")} Installed Laminar skill${note}\n`);
|
|
2667
|
+
}
|
|
2668
|
+
}
|
|
2669
|
+
} catch (err) {
|
|
2670
|
+
if (!isJson) process.stderr.write(`${pc.yellow("Warning")}: could not install Laminar skill (${describeError(err)}).\n`);
|
|
2671
|
+
}
|
|
2672
|
+
const frontendLink = `${trimSlash(issuer)}/project/${link.projectId}/traces`;
|
|
2673
|
+
const result = {
|
|
2674
|
+
projectId: link.projectId,
|
|
2675
|
+
projectName: link.projectName ?? null,
|
|
2676
|
+
workspaceId: link.workspaceId ?? null,
|
|
2677
|
+
workspaceName: link.workspaceName ?? null,
|
|
2678
|
+
apiKey,
|
|
2679
|
+
envFileUpdated: envPath,
|
|
2680
|
+
skillsInstalled,
|
|
2681
|
+
frontendUrl: frontendLink,
|
|
2682
|
+
userEmail: creds.userEmail ?? null
|
|
2683
|
+
};
|
|
2684
|
+
if (isJson) process.stdout.write(JSON.stringify(result) + "\n");
|
|
2685
|
+
else process.stdout.write(`
|
|
2686
|
+
Next steps:
|
|
2687
|
+
1. Instrument your project with Laminar using the installed skill or the docs:
|
|
2688
|
+
${pcOut.cyan("https://laminar.sh/docs/tracing/integrations/overview")}\n 2. Run your project.
|
|
2689
|
+
3. Verify instrumentation:
|
|
2690
|
+
${pcOut.green("lmnr-cli sql query \"SELECT * FROM traces ORDER BY start_time DESC LIMIT 1\" --json")}\n 4. View your traces in the browser:
|
|
2691
|
+
${pcOut.cyan(frontendLink)}\n`);
|
|
2692
|
+
}
|
|
2184
2693
|
/**
|
|
2185
|
-
*
|
|
2694
|
+
* Resolve a project via the CLI (logged-in, no link). 0 projects routes to the
|
|
2695
|
+
* browser create flow (gap A): we re-run the device flow, which lands on the
|
|
2696
|
+
* /device picker → first-project create UI, and the new project's id rides back
|
|
2697
|
+
* on the device-token metadata. >1 prompts a CLI choice; ==1 auto-selects.
|
|
2186
2698
|
*/
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
const { trace_id, path_to_count, args: rawArgs, overrides } = event.data;
|
|
2190
|
-
const parsedArgs = Array.isArray(rawArgs) ? rawArgs.map(tryParseArg) : Object.fromEntries(Object.entries(rawArgs).map(([key, value]) => [key, tryParseArg(value)]));
|
|
2191
|
-
cache.clear();
|
|
2192
|
-
setMetadata({
|
|
2193
|
-
pathToCount: {},
|
|
2194
|
-
overrides
|
|
2195
|
-
});
|
|
2699
|
+
async function resolveProjectViaCli(creds, userBaseUrl, issuer, isJson, options) {
|
|
2700
|
+
let projects;
|
|
2196
2701
|
try {
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
AND path IN {paths:String[]}
|
|
2207
|
-
ORDER BY start_time ASC
|
|
2208
|
-
`;
|
|
2209
|
-
logger$1.debug(`Querying spans from trace ${trace_id}...`);
|
|
2210
|
-
const spans = await client.sql.query(query, {
|
|
2211
|
-
traceId: trace_id,
|
|
2212
|
-
paths
|
|
2213
|
-
});
|
|
2214
|
-
logger$1.debug(`Received ${spans.length} spans from backend`);
|
|
2215
|
-
const spansByPath = {};
|
|
2216
|
-
for (const span of spans) {
|
|
2217
|
-
const path$2 = span.path;
|
|
2218
|
-
if (!spansByPath[path$2]) spansByPath[path$2] = [];
|
|
2219
|
-
spansByPath[path$2].push(span);
|
|
2220
|
-
}
|
|
2221
|
-
for (const [path$3, pathSpans] of Object.entries(spansByPath)) {
|
|
2222
|
-
const maxCount = path_to_count?.[path$3] || 0;
|
|
2223
|
-
const spansToCache = pathSpans.slice(0, maxCount);
|
|
2224
|
-
spansToCache.forEach((span, index) => {
|
|
2225
|
-
let parsedInput;
|
|
2226
|
-
let parsedOutput;
|
|
2227
|
-
let parsedAttributes;
|
|
2228
|
-
try {
|
|
2229
|
-
parsedInput = typeof span.input === "string" ? JSON.parse(span.input) : span.input;
|
|
2230
|
-
} catch {
|
|
2231
|
-
parsedInput = span.input;
|
|
2232
|
-
}
|
|
2233
|
-
try {
|
|
2234
|
-
parsedOutput = typeof span.output === "string" ? span.output : JSON.stringify(span.output);
|
|
2235
|
-
} catch {
|
|
2236
|
-
parsedOutput = String(span.output);
|
|
2237
|
-
}
|
|
2238
|
-
try {
|
|
2239
|
-
parsedAttributes = typeof span.attributes === "string" ? JSON.parse(span.attributes) : span.attributes;
|
|
2240
|
-
} catch {
|
|
2241
|
-
parsedAttributes = {};
|
|
2242
|
-
}
|
|
2243
|
-
const cachedSpan = {
|
|
2244
|
-
name: span.name,
|
|
2245
|
-
input: parsedInput,
|
|
2246
|
-
output: parsedOutput,
|
|
2247
|
-
attributes: parsedAttributes
|
|
2248
|
-
};
|
|
2249
|
-
const cacheKey = `${index}:${path$3}`;
|
|
2250
|
-
cache.set(cacheKey, cachedSpan);
|
|
2251
|
-
});
|
|
2252
|
-
logger$1.info(`Cached ${spansToCache.length} spans for path: ${path$3}`);
|
|
2253
|
-
}
|
|
2254
|
-
setMetadata({
|
|
2255
|
-
pathToCount: path_to_count || {},
|
|
2256
|
-
overrides
|
|
2257
|
-
});
|
|
2258
|
-
}
|
|
2702
|
+
projects = await listProjects(creds, userBaseUrl);
|
|
2703
|
+
} catch (err) {
|
|
2704
|
+
emitError(isJson, "list_projects_failed", describeError(err));
|
|
2705
|
+
process.exit(EXIT_LIST_PROJECTS_FAILED);
|
|
2706
|
+
}
|
|
2707
|
+
if (projects.length === 0) {
|
|
2708
|
+
if (isJson) {
|
|
2709
|
+
emitError(isJson, "no_projects", `No projects found. Run \`lmnr-cli setup\` interactively (it opens the browser to create your first project) or create one at ${trimSlash(issuer)}/onboarding.`);
|
|
2710
|
+
process.exit(EXIT_NO_PROJECT);
|
|
2259
2711
|
}
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
const grpcPort = options.grpcPort ?? 8443;
|
|
2263
|
-
const env = {
|
|
2264
|
-
LMNR_ROLLOUT_SESSION_ID: sessionId,
|
|
2265
|
-
LMNR_ROLLOUT_STATE_SERVER_ADDRESS: `http://localhost:${cacheServerPort}`
|
|
2266
|
-
};
|
|
2267
|
-
const workerConfig = {
|
|
2268
|
-
filePath: options.pythonModule ? void 0 : filePathOrModule,
|
|
2269
|
-
modulePath: options.pythonModule,
|
|
2270
|
-
functionName: options.function,
|
|
2271
|
-
args: parsedArgs,
|
|
2272
|
-
env,
|
|
2273
|
-
cacheServerPort,
|
|
2274
|
-
baseUrl,
|
|
2275
|
-
projectApiKey: options.projectApiKey,
|
|
2276
|
-
httpPort,
|
|
2277
|
-
grpcPort,
|
|
2278
|
-
externalPackages: options.externalPackages,
|
|
2279
|
-
dynamicImportsToSkip: options.dynamicImportsToSkip
|
|
2280
|
-
};
|
|
2281
|
-
const workerCommand = options.command ? {
|
|
2282
|
-
command: options.command,
|
|
2283
|
-
args: options.commandArgs ?? []
|
|
2284
|
-
} : getWorkerCommand(options.pythonModule ? void 0 : filePathOrModule, options);
|
|
2712
|
+
process.stderr.write("\nYou have no projects yet. Opening the browser to create your first one...\n");
|
|
2713
|
+
let login;
|
|
2285
2714
|
try {
|
|
2286
|
-
await
|
|
2287
|
-
|
|
2288
|
-
|
|
2715
|
+
login = await handleLogin({
|
|
2716
|
+
frontendUrl: issuer,
|
|
2717
|
+
noBrowser: options.noBrowser
|
|
2289
2718
|
});
|
|
2290
|
-
} catch (
|
|
2291
|
-
|
|
2719
|
+
} catch (err) {
|
|
2720
|
+
emitError(isJson, "login_failed", describeError(err));
|
|
2721
|
+
process.exit(EXIT_LOGIN_FAILED);
|
|
2292
2722
|
}
|
|
2293
|
-
|
|
2294
|
-
|
|
2295
|
-
|
|
2296
|
-
config: workerConfig
|
|
2297
|
-
});
|
|
2298
|
-
try {
|
|
2299
|
-
await client.rolloutSessions.setStatus({
|
|
2300
|
-
sessionId,
|
|
2301
|
-
status: "FINISHED"
|
|
2302
|
-
});
|
|
2303
|
-
} catch (error) {
|
|
2304
|
-
logger$1.error(`Error setting debugger session status: ${error instanceof Error ? error.message : error}`);
|
|
2723
|
+
if (!login.projectId) {
|
|
2724
|
+
emitError(isJson, "no_projects", `No project was created. Create one at ${trimSlash(issuer)}/onboarding then re-run setup.`);
|
|
2725
|
+
process.exit(EXIT_NO_PROJECT);
|
|
2305
2726
|
}
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
|
|
2309
|
-
|
|
2310
|
-
|
|
2311
|
-
|
|
2312
|
-
|
|
2313
|
-
|
|
2314
|
-
}
|
|
2315
|
-
|
|
2727
|
+
return writeLink(issuer, userBaseUrl, login.projectId, isJson);
|
|
2728
|
+
}
|
|
2729
|
+
let chosen;
|
|
2730
|
+
if (options.projectId) {
|
|
2731
|
+
const match = projects.find((p) => p.id === options.projectId);
|
|
2732
|
+
if (!match) {
|
|
2733
|
+
emitError(isJson, "no_access", `You don't have access to project ${options.projectId}. Accessible: ` + projects.map((p) => `${p.id} (${p.workspaceName}/${p.name})`).join(", "));
|
|
2734
|
+
process.exit(EXIT_NO_ACCESS);
|
|
2735
|
+
}
|
|
2736
|
+
chosen = match;
|
|
2737
|
+
} else if (projects.length === 1) chosen = projects[0];
|
|
2738
|
+
else {
|
|
2739
|
+
if (isJson) {
|
|
2740
|
+
emitError(isJson, "project_ambiguous", `Multiple projects: pass --project-id <id>, or run setup interactively. ` + projects.map((p) => `${p.id} (${p.workspaceName}/${p.name})`).join(", "));
|
|
2741
|
+
process.exit(EXIT_NO_PROJECT);
|
|
2316
2742
|
}
|
|
2743
|
+
chosen = await promptProjectChoice(projects);
|
|
2317
2744
|
}
|
|
2318
|
-
|
|
2319
|
-
|
|
2320
|
-
|
|
2321
|
-
|
|
2322
|
-
|
|
2323
|
-
const isPythonModule = !!options.pythonModule;
|
|
2324
|
-
const filePathOrModule = filePath || options.pythonModule;
|
|
2325
|
-
let didLogHandshake = false;
|
|
2326
|
-
const sessionId = newUUID();
|
|
2327
|
-
const client = new LaminarClient({
|
|
2328
|
-
baseUrl: options.baseUrl,
|
|
2329
|
-
projectApiKey: options.projectApiKey,
|
|
2330
|
-
port: options.port
|
|
2745
|
+
const linkPath = await writeProjectLink({
|
|
2746
|
+
projectId: chosen.id,
|
|
2747
|
+
projectName: chosen.name,
|
|
2748
|
+
workspaceId: chosen.workspaceId,
|
|
2749
|
+
workspaceName: chosen.workspaceName
|
|
2331
2750
|
});
|
|
2332
|
-
|
|
2333
|
-
|
|
2334
|
-
|
|
2335
|
-
|
|
2336
|
-
|
|
2337
|
-
|
|
2751
|
+
if (!isJson) process.stderr.write(`${pc.green("✓")} Linked ${linkPath}\n`);
|
|
2752
|
+
return {
|
|
2753
|
+
projectId: chosen.id,
|
|
2754
|
+
projectName: chosen.name,
|
|
2755
|
+
workspaceId: chosen.workspaceId,
|
|
2756
|
+
workspaceName: chosen.workspaceName
|
|
2757
|
+
};
|
|
2758
|
+
}
|
|
2759
|
+
/** Write `.lmnr/project.json`, enriching display details from listProjects when possible. */
|
|
2760
|
+
async function writeLink(issuer, userBaseUrl, projectId, isJson) {
|
|
2761
|
+
let link = { projectId };
|
|
2338
2762
|
try {
|
|
2339
|
-
|
|
2340
|
-
|
|
2341
|
-
const
|
|
2342
|
-
|
|
2343
|
-
|
|
2344
|
-
|
|
2345
|
-
|
|
2346
|
-
|
|
2347
|
-
|
|
2348
|
-
logger$1.warn(`Metadata discovery not available for ${path.extname(filePath)} files`);
|
|
2763
|
+
const creds = await safeReadCredentials();
|
|
2764
|
+
if (creds) {
|
|
2765
|
+
const match = (await listProjects(creds, userBaseUrl)).find((p) => p.id === projectId);
|
|
2766
|
+
if (match) link = {
|
|
2767
|
+
projectId,
|
|
2768
|
+
projectName: match.name,
|
|
2769
|
+
workspaceId: match.workspaceId,
|
|
2770
|
+
workspaceName: match.workspaceName
|
|
2771
|
+
};
|
|
2349
2772
|
}
|
|
2350
|
-
} catch
|
|
2351
|
-
|
|
2352
|
-
|
|
2353
|
-
|
|
2773
|
+
} catch {}
|
|
2774
|
+
try {
|
|
2775
|
+
const linkPath = await writeProjectLink(link);
|
|
2776
|
+
if (!isJson) process.stderr.write(`${pc.green("✓")} Linked ${linkPath}\n`);
|
|
2777
|
+
} catch (err) {
|
|
2778
|
+
if (!isJson) process.stderr.write(`${pc.yellow("Warning")}: could not write .lmnr/project.json (${describeError(err)}). CLI commands will need --project-id ${projectId}.\n`);
|
|
2354
2779
|
}
|
|
2355
|
-
|
|
2356
|
-
|
|
2357
|
-
|
|
2358
|
-
|
|
2359
|
-
|
|
2360
|
-
|
|
2361
|
-
|
|
2362
|
-
|
|
2363
|
-
|
|
2364
|
-
|
|
2365
|
-
|
|
2366
|
-
|
|
2367
|
-
|
|
2368
|
-
|
|
2369
|
-
|
|
2370
|
-
|
|
2371
|
-
|
|
2372
|
-
|
|
2373
|
-
|
|
2374
|
-
|
|
2375
|
-
|
|
2376
|
-
|
|
2377
|
-
|
|
2378
|
-
|
|
2379
|
-
|
|
2380
|
-
|
|
2381
|
-
|
|
2382
|
-
|
|
2383
|
-
|
|
2384
|
-
|
|
2385
|
-
awaitWriteFinish: {
|
|
2386
|
-
stabilityThreshold: 100,
|
|
2387
|
-
pollInterval: 100
|
|
2780
|
+
return link;
|
|
2781
|
+
}
|
|
2782
|
+
/**
|
|
2783
|
+
* Access check: the user must be a member of `projectId`. Calls
|
|
2784
|
+
* GET /v1/cli/projects (user JWT) and asserts the id is present; aborts
|
|
2785
|
+
* otherwise (SPEC: "You don't have access to the project in this directory").
|
|
2786
|
+
*/
|
|
2787
|
+
async function assertAccess(creds, userBaseUrl, projectId, isJson) {
|
|
2788
|
+
let projects;
|
|
2789
|
+
try {
|
|
2790
|
+
projects = await listProjects(creds, userBaseUrl);
|
|
2791
|
+
} catch (err) {
|
|
2792
|
+
emitError(isJson, "list_projects_failed", describeError(err));
|
|
2793
|
+
process.exit(EXIT_LIST_PROJECTS_FAILED);
|
|
2794
|
+
}
|
|
2795
|
+
if (!projects.some((p) => p.id === projectId)) {
|
|
2796
|
+
emitError(isJson, "no_access", "You don't have access to the project in this directory");
|
|
2797
|
+
process.exit(EXIT_NO_ACCESS);
|
|
2798
|
+
}
|
|
2799
|
+
}
|
|
2800
|
+
/** List the projects the user can access (user-JWT-authed discovery). */
|
|
2801
|
+
async function listProjects(creds, baseUrl) {
|
|
2802
|
+
const updated = await refreshIfNeeded(creds);
|
|
2803
|
+
return new LaminarClient({
|
|
2804
|
+
baseUrl,
|
|
2805
|
+
port: envHttpPort(),
|
|
2806
|
+
auth: {
|
|
2807
|
+
type: "userToken",
|
|
2808
|
+
token: updated.accessToken,
|
|
2809
|
+
projectId: ""
|
|
2388
2810
|
}
|
|
2389
|
-
});
|
|
2390
|
-
|
|
2391
|
-
|
|
2811
|
+
}).cli.listProjects();
|
|
2812
|
+
}
|
|
2813
|
+
async function safeReadCredentials() {
|
|
2392
2814
|
try {
|
|
2393
|
-
|
|
2394
|
-
|
|
2395
|
-
|
|
2396
|
-
params,
|
|
2397
|
-
name: functionName ?? ""
|
|
2398
|
-
});
|
|
2399
|
-
let currentRunPromise = null;
|
|
2400
|
-
let stopRequested = false;
|
|
2401
|
-
sseClient.on("heartbeat", () => {
|
|
2402
|
-
logger$1.debug("Heartbeat received");
|
|
2403
|
-
});
|
|
2404
|
-
sseClient.on("run", (event) => {
|
|
2405
|
-
if (currentRunPromise !== null) {
|
|
2406
|
-
logger$1.warn("Already processing a run event, skipping new run");
|
|
2407
|
-
return;
|
|
2408
|
-
}
|
|
2409
|
-
currentRunPromise = (async () => {
|
|
2410
|
-
try {
|
|
2411
|
-
stopRequested = false;
|
|
2412
|
-
if (reloadScheduled) {
|
|
2413
|
-
logger$1.info("Reloading function metadata before run...");
|
|
2414
|
-
if (isPythonModule || filePath && EXTENSIONS_TO_DISCOVER_METADATA.includes(path.extname(filePath))) try {
|
|
2415
|
-
const metadata = await discoverFunctionMetadata(filePathOrModule, options);
|
|
2416
|
-
if (stopRequested) {
|
|
2417
|
-
logger$1.info("Run cancelled during metadata discovery");
|
|
2418
|
-
return;
|
|
2419
|
-
}
|
|
2420
|
-
logger$1.debug(`Updated function metadata: ${metadata.functionName}`);
|
|
2421
|
-
logger$1.debug(`Updated parameters: ${JSON.stringify(metadata.params, null, 2)}`);
|
|
2422
|
-
if (sseClient) {
|
|
2423
|
-
sseClient.updateMetadata(metadata.params, metadata.functionName);
|
|
2424
|
-
logger$1.debug("Notified backend of metadata changes");
|
|
2425
|
-
}
|
|
2426
|
-
reloadScheduled = false;
|
|
2427
|
-
} catch (error) {
|
|
2428
|
-
logger$1.error("Failed to update function metadata: " + (error instanceof Error ? error.message : String(error)));
|
|
2429
|
-
if (error instanceof Error && error.stack) logger$1.debug(`Stack trace: ${error.stack}`);
|
|
2430
|
-
return;
|
|
2431
|
-
}
|
|
2432
|
-
else reloadScheduled = false;
|
|
2433
|
-
}
|
|
2434
|
-
if (stopRequested) {
|
|
2435
|
-
logger$1.info("Run cancelled before execution");
|
|
2436
|
-
return;
|
|
2437
|
-
}
|
|
2438
|
-
await handleRunEvent(event, sessionId, filePathOrModule, client, cacheServerPort, cache, setMetadata, options, subprocessManager);
|
|
2439
|
-
} catch (error) {
|
|
2440
|
-
logger$1.error("Unhandled error in run event handler: " + (error instanceof Error ? error.message : String(error)));
|
|
2441
|
-
} finally {
|
|
2442
|
-
currentRunPromise = null;
|
|
2443
|
-
}
|
|
2444
|
-
})();
|
|
2445
|
-
});
|
|
2446
|
-
sseClient.on("handshake", (event) => {
|
|
2447
|
-
const projectId = event.data.project_id;
|
|
2448
|
-
const sessionId = event.data.session_id;
|
|
2449
|
-
const frontendUrl = getFrontendUrl(options.baseUrl, options.frontendPort);
|
|
2450
|
-
if (!didLogHandshake) logger$1.info(`View your session at ${frontendUrl}/project/${projectId}/debugger-sessions/${sessionId}`);
|
|
2451
|
-
didLogHandshake = true;
|
|
2452
|
-
});
|
|
2453
|
-
sseClient.on("error", (error) => {
|
|
2454
|
-
logger$1.warn(`Error connecting to backend: ${error.message}`);
|
|
2455
|
-
});
|
|
2456
|
-
sseClient.on("reconnecting", () => {
|
|
2457
|
-
logger$1.info("Reconnecting to backend...");
|
|
2458
|
-
});
|
|
2459
|
-
sseClient.on("heartbeat_timeout", () => {
|
|
2460
|
-
logger$1.debug("Heartbeat timeout, reconnecting...");
|
|
2461
|
-
});
|
|
2462
|
-
sseClient.on("stop", () => {
|
|
2463
|
-
logger$1.debug("Stop event received");
|
|
2464
|
-
stopRequested = true;
|
|
2465
|
-
if (subprocessManager.kill()) logger$1.info("Current run cancelled");
|
|
2466
|
-
});
|
|
2467
|
-
let reloadTimeout = null;
|
|
2468
|
-
let reloadScheduled = false;
|
|
2469
|
-
watcher.on("change", (changedPath) => {
|
|
2470
|
-
logger$1.info(`File changed: ${changedPath}, scheduling reload...`);
|
|
2471
|
-
if (reloadTimeout) clearTimeout(reloadTimeout);
|
|
2472
|
-
reloadTimeout = setTimeout(() => {
|
|
2473
|
-
logger$1.debug("Marking reload as scheduled for next run...");
|
|
2474
|
-
reloadTimeout = null;
|
|
2475
|
-
reloadScheduled = true;
|
|
2476
|
-
}, 100);
|
|
2477
|
-
});
|
|
2478
|
-
const shutdown = () => {
|
|
2479
|
-
logger$1.debug("Shutting down...");
|
|
2480
|
-
if (reloadTimeout) {
|
|
2481
|
-
clearTimeout(reloadTimeout);
|
|
2482
|
-
reloadTimeout = null;
|
|
2483
|
-
}
|
|
2484
|
-
reloadScheduled = false;
|
|
2485
|
-
logger$1.debug("Closing file watcher...");
|
|
2486
|
-
watcher.close().catch((error) => {
|
|
2487
|
-
logger$1.error(`Failed to close file watcher: ${error instanceof Error ? error.message : error}`);
|
|
2488
|
-
});
|
|
2489
|
-
subprocessManager.kill();
|
|
2490
|
-
logger$1.debug("Deleting debugger session...");
|
|
2491
|
-
client.rolloutSessions.delete({ sessionId }).then(() => {
|
|
2492
|
-
if (sseClient) sseClient.shutdown();
|
|
2493
|
-
cacheServer.close(() => {
|
|
2494
|
-
logger$1.debug("Cache server closed");
|
|
2495
|
-
});
|
|
2496
|
-
process.exit(0);
|
|
2497
|
-
}).catch((error) => {
|
|
2498
|
-
logger$1.warn(`Failed to delete debugger session: ${error instanceof Error ? error.message : error}`);
|
|
2499
|
-
process.exit(1);
|
|
2500
|
-
});
|
|
2501
|
-
};
|
|
2502
|
-
process.on("SIGINT", shutdown);
|
|
2503
|
-
process.on("SIGTERM", shutdown);
|
|
2504
|
-
process.stdin.resume();
|
|
2505
|
-
logger$1.debug("Connecting to backend...");
|
|
2506
|
-
await sseClient.connectAndListen();
|
|
2507
|
-
} catch (error) {
|
|
2508
|
-
logger$1.error("Failed to start dev command: " + (error instanceof Error ? error.message : String(error)));
|
|
2509
|
-
try {
|
|
2510
|
-
await client.rolloutSessions.delete({ sessionId });
|
|
2511
|
-
} catch {}
|
|
2512
|
-
await watcher.close();
|
|
2513
|
-
cacheServer.close(() => {
|
|
2514
|
-
process.exit(1);
|
|
2515
|
-
});
|
|
2815
|
+
return await readCredentials();
|
|
2816
|
+
} catch {
|
|
2817
|
+
return null;
|
|
2516
2818
|
}
|
|
2517
2819
|
}
|
|
2518
|
-
|
|
2519
|
-
|
|
2520
|
-
const
|
|
2521
|
-
const
|
|
2522
|
-
|
|
2523
|
-
|
|
2524
|
-
|
|
2525
|
-
|
|
2820
|
+
/** POST /api/cli/api-key with the session bearer for an explicit project. */
|
|
2821
|
+
async function mintSetupKey(issuer, sessionToken, projectId) {
|
|
2822
|
+
const url = `${trimSlash(issuer)}/api/cli/api-key`;
|
|
2823
|
+
const res = await fetch(url, {
|
|
2824
|
+
method: "POST",
|
|
2825
|
+
headers: {
|
|
2826
|
+
authorization: `Bearer ${sessionToken}`,
|
|
2827
|
+
"content-type": "application/json"
|
|
2828
|
+
},
|
|
2829
|
+
body: JSON.stringify({
|
|
2830
|
+
deviceName: (0, node_os.hostname)(),
|
|
2831
|
+
projectId
|
|
2832
|
+
})
|
|
2833
|
+
});
|
|
2834
|
+
if (res.ok) return await res.json();
|
|
2835
|
+
const body = await res.json().catch(() => ({}));
|
|
2836
|
+
throw new Error(body.error ?? `api-key request failed (${res.status})`);
|
|
2837
|
+
}
|
|
2838
|
+
async function promptProjectChoice(projects) {
|
|
2839
|
+
process.stderr.write("\nMultiple projects available. Choose one:\n");
|
|
2840
|
+
projects.forEach((p, i) => {
|
|
2841
|
+
process.stderr.write(` ${i + 1}) ${p.workspaceName} / ${p.name}\n`);
|
|
2842
|
+
});
|
|
2843
|
+
const rl = (0, node_readline_promises.createInterface)({
|
|
2844
|
+
input: process.stdin,
|
|
2845
|
+
output: process.stderr
|
|
2526
2846
|
});
|
|
2527
2847
|
try {
|
|
2528
|
-
|
|
2529
|
-
|
|
2530
|
-
|
|
2531
|
-
return;
|
|
2848
|
+
while (true) {
|
|
2849
|
+
const answer = (await rl.question(`Select [1-${projects.length}]: `)).trim();
|
|
2850
|
+
const idx = Number.parseInt(answer, 10);
|
|
2851
|
+
if (Number.isInteger(idx) && idx >= 1 && idx <= projects.length) return projects[idx - 1];
|
|
2852
|
+
process.stderr.write(`${pc.red("Invalid selection.")}\n`);
|
|
2532
2853
|
}
|
|
2533
|
-
|
|
2534
|
-
|
|
2535
|
-
return;
|
|
2536
|
-
}
|
|
2537
|
-
const columns = Object.keys(rows[0]);
|
|
2538
|
-
const tableRows = rows.map((row) => columns.map((col) => String(row[col] ?? "")));
|
|
2539
|
-
console.log(renderTable(columns, tableRows));
|
|
2540
|
-
console.log(`\n${rows.length} row(s)\n`);
|
|
2541
|
-
} catch (error) {
|
|
2542
|
-
if (options.json) outputJsonError(error);
|
|
2543
|
-
logger.error(`Query failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
2544
|
-
process.exit(1);
|
|
2854
|
+
} finally {
|
|
2855
|
+
rl.close();
|
|
2545
2856
|
}
|
|
2857
|
+
}
|
|
2858
|
+
function pick(...candidates) {
|
|
2859
|
+
for (const c of candidates) if (c && c.length > 0) return c;
|
|
2860
|
+
return "";
|
|
2861
|
+
}
|
|
2862
|
+
function trimSlash(url) {
|
|
2863
|
+
return url.replace(/\/+$/, "");
|
|
2864
|
+
}
|
|
2865
|
+
function describeError(err) {
|
|
2866
|
+
if (err instanceof Error) return err.message;
|
|
2867
|
+
return String(err);
|
|
2868
|
+
}
|
|
2869
|
+
function emitError(json, code, detail) {
|
|
2870
|
+
if (json) process.stdout.write(JSON.stringify({
|
|
2871
|
+
error: code,
|
|
2872
|
+
detail
|
|
2873
|
+
}) + "\n");
|
|
2874
|
+
else process.stderr.write(`\n${pc.red(`ERROR (${code})`)}: ${detail}\n`);
|
|
2875
|
+
}
|
|
2876
|
+
//#endregion
|
|
2877
|
+
//#region src/commands/sql/index.ts
|
|
2878
|
+
/**
|
|
2879
|
+
* Run a SQL query against the project's data and print the rows. Pure handler:
|
|
2880
|
+
* the command wrapper (`withProjectClient`) resolves the client and owns the
|
|
2881
|
+
* error envelope (`--json` → structured error + exit, otherwise log + exit).
|
|
2882
|
+
*/
|
|
2883
|
+
const handleSqlQuery = async (client, query, opts) => {
|
|
2884
|
+
const rows = await client.sql.query(query);
|
|
2885
|
+
if (opts.json) {
|
|
2886
|
+
outputJson(rows);
|
|
2887
|
+
return;
|
|
2888
|
+
}
|
|
2889
|
+
if (rows.length === 0) {
|
|
2890
|
+
console.log("No rows returned.");
|
|
2891
|
+
return;
|
|
2892
|
+
}
|
|
2893
|
+
const columns = Object.keys(rows[0]);
|
|
2894
|
+
const tableRows = rows.map((row) => columns.map((col) => String(row[col] ?? "")));
|
|
2895
|
+
console.log(renderTable(columns, tableRows));
|
|
2896
|
+
console.log(`\n${rows.length} row(s)\n`);
|
|
2546
2897
|
};
|
|
2547
2898
|
//#endregion
|
|
2548
2899
|
//#region src/commands/sql/schema.ts
|
|
@@ -2591,44 +2942,60 @@ Available tables:
|
|
|
2591
2942
|
data (String), target (String), metadata (String)
|
|
2592
2943
|
`;
|
|
2593
2944
|
//#endregion
|
|
2945
|
+
//#region src/commands/trace/index.ts
|
|
2946
|
+
const logger = initializeLogger();
|
|
2947
|
+
const NOTE_SEPARATOR = "\n\n";
|
|
2948
|
+
/**
|
|
2949
|
+
* Append a free-text note to an existing trace. Stored under the
|
|
2950
|
+
* `rollout.note` trace-metadata key via the post-factum metadata patch
|
|
2951
|
+
* endpoint. The patch endpoint is last-write-wins per key, so the current
|
|
2952
|
+
* note is read back first (via the SQL endpoint) and the new text is pushed
|
|
2953
|
+
* as `existing + "\n\n" + note`. The note may contain markdown /
|
|
2954
|
+
* span-reference links.
|
|
2955
|
+
*
|
|
2956
|
+
* Pure handler: the command wrapper (`withProjectClient`) resolves a user-token
|
|
2957
|
+
* {@link LaminarClient} (routes to `/v1/cli/*` with the resolved project) and
|
|
2958
|
+
* owns the error envelope (`--json` → structured error + exit, else log + exit).
|
|
2959
|
+
*
|
|
2960
|
+
* The read-modify-write is not transactional: the patch lands via the async
|
|
2961
|
+
* ingestion queue, so a second append issued within ~a second of the first
|
|
2962
|
+
* can read the pre-patch note and drop the first append. Fine for the
|
|
2963
|
+
* intended cadence (one note per investigation step), not for concurrent
|
|
2964
|
+
* writers.
|
|
2965
|
+
*
|
|
2966
|
+
* TODO: revisit — make the append atomic server-side (e.g. an append mode on
|
|
2967
|
+
* the metadata patch endpoint that concatenates within the Postgres UPDATE,
|
|
2968
|
+
* which already serializes on the trace row lock).
|
|
2969
|
+
*/
|
|
2970
|
+
const handleTraceAppendNote = async (client, traceId, note, opts) => {
|
|
2971
|
+
const id = normalizeTraceId(traceId);
|
|
2972
|
+
const rows = await client.sql.query("SELECT metadata FROM traces WHERE id = {trace_id:UUID} LIMIT 1", { trace_id: id });
|
|
2973
|
+
if (rows.length === 0) throw new Error(`Trace ${id} not found. If the run just finished, the trace may not be flushed yet. Retry in a few seconds.`);
|
|
2974
|
+
const existing = readNoteFromMetadata(rows[0].metadata);
|
|
2975
|
+
const updated = existing ? `${existing}${NOTE_SEPARATOR}${note}` : note;
|
|
2976
|
+
await client.traces.pushMetadata(id, { [NOTE_METADATA_KEY]: updated }, { failOnNotFound: true });
|
|
2977
|
+
if (opts.json) {
|
|
2978
|
+
outputJson({
|
|
2979
|
+
traceId: id,
|
|
2980
|
+
note: updated
|
|
2981
|
+
});
|
|
2982
|
+
return;
|
|
2983
|
+
}
|
|
2984
|
+
logger.info(`Appended note to trace ${id}.`);
|
|
2985
|
+
};
|
|
2986
|
+
//#endregion
|
|
2594
2987
|
//#region src/index.ts
|
|
2595
2988
|
async function main() {
|
|
2989
|
+
await loadLocalEnv(process.cwd());
|
|
2596
2990
|
const program = new commander.Command();
|
|
2597
2991
|
program.name("lmnr-cli").description("CLI for the Laminar agent observability platform").version(version$1, "-v, --version", "display version number");
|
|
2598
|
-
program.command("
|
|
2599
|
-
|
|
2600
|
-
|
|
2601
|
-
|
|
2602
|
-
|
|
2603
|
-
|
|
2604
|
-
|
|
2605
|
-
process.exit(1);
|
|
2606
|
-
}
|
|
2607
|
-
await runDev(file, options);
|
|
2608
|
-
}).addHelpText("after", `
|
|
2609
|
-
Examples:
|
|
2610
|
-
$ lmnr-cli dev agent.ts # TypeScript file
|
|
2611
|
-
$ lmnr-cli dev agent.py # Python file (script mode)
|
|
2612
|
-
$ lmnr-cli dev -m src.agent # Python module (module mode)
|
|
2613
|
-
$ lmnr-cli dev agent.ts --function myAgent # Specific function
|
|
2614
|
-
`);
|
|
2615
|
-
const datasetsCmd = program.command("dataset").description("Manage datasets").option("--project-api-key <key>", "Project API key. If not provided, reads from LMNR_PROJECT_API_KEY env variable").option("--base-url <url>", "Base URL for the Laminar API. Defaults to https://api.lmnr.ai or LMNR_BASE_URL env variable").option("--port <port>", "Port for the Laminar API. Defaults to 443", (val) => parseInt(val, 10)).option("--json", "Output structured JSON to stdout");
|
|
2616
|
-
datasetsCmd.command("list").description("List all datasets").action(async (_options, cmd) => {
|
|
2617
|
-
await handleDatasetsList(cmd.optsWithGlobals());
|
|
2618
|
-
});
|
|
2619
|
-
datasetsCmd.command("push").description("Push datapoints to an existing dataset").argument("<paths...>", "Paths to files or directories containing data to push").option("-n, --name <name>", "Name of the dataset (either name or id must be provided)").option("--id <id>", "ID of the dataset (either name or id must be provided)").option("-r, --recursive", "Recursively read files in directories", false).option("--batch-size <size>", "Batch size for pushing data", (val) => parseInt(val, 10), 100).action(async (paths, _options, cmd) => {
|
|
2620
|
-
await handleDatasetsPush(paths, cmd.optsWithGlobals());
|
|
2621
|
-
});
|
|
2622
|
-
datasetsCmd.command("pull").description("Pull data from a dataset").argument("[output-path]", "Path to save the data. If not provided, prints to console").option("-n, --name <name>", "Name of the dataset (either name or id must be provided)").option("--id <id>", "ID of the dataset (either name or id must be provided)").option("--output-format <format>", "Output format (json, csv, jsonl). Inferred from file extension if not provided").option("--batch-size <size>", "Batch size for pulling data", (val) => parseInt(val, 10), 100).option("--limit <limit>", "Limit number of datapoints to pull", (val) => parseInt(val, 10)).option("--offset <offset>", "Offset for pagination", (val) => parseInt(val, 10), 0).action(async (outputPath, _options, cmd) => {
|
|
2623
|
-
await handleDatasetsPull(outputPath, cmd.optsWithGlobals());
|
|
2624
|
-
});
|
|
2625
|
-
datasetsCmd.command("create").description("Create a dataset from input files").argument("<name>", "Name of the dataset to create").argument("<paths...>", "Paths to files or directories containing data to push").requiredOption("-o, --output-file <file>", "Path to save the pulled data").option("--output-format <format>", "Output format (json, csv, jsonl). Inferred from file extension if not provided").option("-r, --recursive", "Recursively read files in directories", false).option("--batch-size <size>", "Batch size for pushing/pulling data", (val) => parseInt(val, 10), 100).action(async (name, paths, _options, cmd) => {
|
|
2626
|
-
await handleDatasetsCreate(name, paths, cmd.optsWithGlobals());
|
|
2627
|
-
});
|
|
2628
|
-
const sqlCmd = program.command("sql").description("Run SQL queries against your Laminar project data").option("--project-api-key <key>", "Project API key. If not provided, reads from LMNR_PROJECT_API_KEY env variable").option("--base-url <url>", "Base URL for the Laminar API. Defaults to https://api.lmnr.ai or LMNR_BASE_URL env variable").option("--port <port>", "Port for the Laminar API. Defaults to 443", (val) => parseInt(val, 10)).option("--json", "Output structured JSON to stdout");
|
|
2629
|
-
sqlCmd.command("query").description("Execute a SQL query").argument("<query>", "SQL query string").action(async (query, _options, cmd) => {
|
|
2630
|
-
await handleSqlQuery(query, cmd.optsWithGlobals());
|
|
2631
|
-
}).addHelpText("after", SQL_SCHEMA_HELP + `
|
|
2992
|
+
const datasetsCmd = program.command("dataset").description("Manage datasets").option("--project-id <id>", "Target project id. Defaults to the linked .lmnr/project.json. Run `lmnr-cli login` first.").option("--base-url <url>", "Base URL for the Laminar API. Defaults to https://api.lmnr.ai or LMNR_BASE_URL env variable").option("--port <port>", "Port for the Laminar API. Defaults to 443", (val) => parseInt(val, 10)).option("--json", "Output structured JSON to stdout");
|
|
2993
|
+
datasetsCmd.command("list").description("List all datasets").action(withProjectClient(handleDatasetsList));
|
|
2994
|
+
datasetsCmd.command("push").description("Push datapoints to an existing dataset").argument("<paths...>", "Paths to files or directories containing data to push").option("-n, --name <name>", "Name of the dataset (either name or id must be provided)").option("--id <id>", "ID of the dataset (either name or id must be provided)").option("-r, --recursive", "Recursively read files in directories", false).option("--batch-size <size>", "Batch size for pushing data", (val) => parseInt(val, 10), 100).action(withProjectClient(handleDatasetsPush));
|
|
2995
|
+
datasetsCmd.command("pull").description("Pull data from a dataset").argument("[output-path]", "Path to save the data. If not provided, prints to console").option("-n, --name <name>", "Name of the dataset (either name or id must be provided)").option("--id <id>", "ID of the dataset (either name or id must be provided)").option("--output-format <format>", "Output format (json, csv, jsonl). Inferred from file extension if not provided").option("--batch-size <size>", "Batch size for pulling data", (val) => parseInt(val, 10), 100).option("--limit <limit>", "Limit number of datapoints to pull", (val) => parseInt(val, 10)).option("--offset <offset>", "Offset for pagination", (val) => parseInt(val, 10), 0).action(withProjectClient(handleDatasetsPull));
|
|
2996
|
+
datasetsCmd.command("create").description("Create a dataset from input files").argument("<name>", "Name of the dataset to create").argument("<paths...>", "Paths to files or directories containing data to push").requiredOption("-o, --output-file <file>", "Path to save the pulled data").option("--output-format <format>", "Output format (json, csv, jsonl). Inferred from file extension if not provided").option("-r, --recursive", "Recursively read files in directories", false).option("--batch-size <size>", "Batch size for pushing/pulling data", (val) => parseInt(val, 10), 100).action(withProjectClient(handleDatasetsCreate));
|
|
2997
|
+
const sqlCmd = program.command("sql").description("Run SQL queries against your Laminar project data").option("--project-id <id>", "Target project id. Defaults to the linked .lmnr/project.json. Run `lmnr-cli login` first.").option("--base-url <url>", "Base URL for the Laminar API. Defaults to https://api.lmnr.ai or LMNR_BASE_URL env variable").option("--port <port>", "Port for the Laminar API. Defaults to 443", (val) => parseInt(val, 10)).option("--json", "Output structured JSON to stdout");
|
|
2998
|
+
sqlCmd.command("query").description("Execute a SQL query").argument("<query>", "SQL query string").action(withProjectClient(handleSqlQuery)).addHelpText("after", SQL_SCHEMA_HELP + `
|
|
2632
2999
|
Examples:
|
|
2633
3000
|
$ lmnr-cli sql query "SELECT * FROM spans LIMIT 10"
|
|
2634
3001
|
$ lmnr-cli sql query "SELECT id, total_cost, status FROM traces LIMIT 20"
|
|
@@ -2637,32 +3004,77 @@ Examples:
|
|
|
2637
3004
|
sqlCmd.command("schema").description("Show available tables and their columns").action(() => {
|
|
2638
3005
|
process.stdout.write(SQL_SCHEMA_HELP);
|
|
2639
3006
|
});
|
|
3007
|
+
program.command("project").description("Work with Laminar projects").command("list").description("List the projects you can access (● = linked to this directory)").option("--base-url <url>", "Base URL for the Laminar API. Defaults to the logged-in session or LMNR_BASE_URL").option("--port <port>", "Port for the Laminar API. Defaults to 443", (val) => parseInt(val, 10)).option("--json", "Output structured JSON to stdout").action(withUserToken(handleProjectsList));
|
|
3008
|
+
program.command("login").description("Authenticate the CLI via OAuth Device Flow").option("--frontend-url <url>", "Frontend URL (issuer). Defaults to https://www.laminar.sh or LMNR_FRONTEND_URL env variable").option("--no-browser", "Do not open the verification URL in a browser").action(async (options) => {
|
|
3009
|
+
const result = await handleLogin(options);
|
|
3010
|
+
process.stderr.write(`${pc.green("✓")} Logged in as ${result.userEmail ?? "<unknown>"}.\n`);
|
|
3011
|
+
process.stderr.write(pc.dim("Client: lmnr-cli. Tokens stored at ~/.config/lmnr/credentials.json (mode 0600).\n"));
|
|
3012
|
+
process.stderr.write(pc.dim("Run `lmnr-cli setup` in a project directory to link it and write its API key.\n"));
|
|
3013
|
+
});
|
|
3014
|
+
program.command("logout").description("Log out and remove the stored credentials").action(async () => {
|
|
3015
|
+
await handleLogout();
|
|
3016
|
+
});
|
|
3017
|
+
program.command("setup").description("One-shot onboarding: login, select a project, write its key to .env, link .lmnr, and install the Laminar agent skill").option("--write-env", "Write LMNR_PROJECT_API_KEY to ./.env (default)", true).option("--no-write-env", "Do not write to ./.env").option("--project-id <id>", "Project to link when you can access more than one (disambiguates the project_ambiguous case in --json mode)").option("--json", "Emit a machine-readable JSON line on stdout").option("--no-browser", "Do not auto-open the device-flow URL").option("--frontend-url <url>", "Frontend URL (issuer). Defaults to LMNR_FRONTEND_URL or https://www.laminar.sh").option("--base-url <url>", "Base URL for the Laminar API. Defaults to LMNR_BASE_URL or https://api.lmnr.ai").action(async (options) => {
|
|
3018
|
+
await handleSetup(options);
|
|
3019
|
+
});
|
|
3020
|
+
program.command("trace").description("Inspect and operate on traces").option("--project-id <id>", "Target project id. Defaults to the linked .lmnr/project.json. Run `lmnr-cli login` first.").option("--base-url <url>", "Base URL for the Laminar API. Defaults to https://api.lmnr.ai or LMNR_BASE_URL env variable").option("--port <port>", "Port for the Laminar API. Defaults to 443", (val) => parseInt(val, 10)).option("--json", "Output structured JSON to stdout").command("append-note").description("Append a free-text note to a trace (stored in trace metadata)").argument("<trace-id>", "Trace ID (UUID or 32-char OTel hex trace id)").argument("<note>", "Note text (may contain markdown)").action(withProjectClient(handleTraceAppendNote)).addHelpText("after", `
|
|
3021
|
+
Notes accumulate: each call appends a new paragraph to the trace's existing
|
|
3022
|
+
note rather than overwriting it.
|
|
3023
|
+
|
|
3024
|
+
Examples:
|
|
3025
|
+
$ lmnr-cli trace append-note <trace-id> "Reproduced the timeout on the search tool."
|
|
3026
|
+
`);
|
|
3027
|
+
const debugSessionCmd = program.command("debug").description("Operate on debug sessions").option("--project-id <id>", "Target project id. Defaults to the linked .lmnr/project.json. Run `lmnr-cli login` first.").option("--base-url <url>", "Base URL for the Laminar API. Defaults to https://api.lmnr.ai or LMNR_BASE_URL env variable").option("--port <port>", "Port for the Laminar API. Defaults to 443", (val) => parseInt(val, 10)).option("--json", "Output structured JSON to stdout").addHelpText("after", `
|
|
3028
|
+
Learn more about debugging features at https://laminar.sh/docs/platform/debugger
|
|
3029
|
+
`).command("session").description("Manage debug sessions").addHelpText("after", `
|
|
3030
|
+
Learn more about debugging features at https://laminar.sh/docs/platform/debugger
|
|
3031
|
+
`);
|
|
3032
|
+
debugSessionCmd.command("set-name").description("Set the display name of a debug session").argument("<session-id>", "Debug session ID").argument("<name>", "Session display name").action(withProjectClient(handleDebugSessionSetName)).addHelpText("after", `
|
|
3033
|
+
Examples:
|
|
3034
|
+
$ lmnr-cli debug session set-name <session-id> "Fix report length + search tool"
|
|
3035
|
+
`);
|
|
3036
|
+
debugSessionCmd.command("summary").description("Print every trace in a debug session with its note, oldest first").argument("<session-id>", "Debug session ID").action(withProjectClient(handleDebugSessionSummary)).addHelpText("after", `
|
|
3037
|
+
Output is one block per trace (oldest first), the trace's note followed by a
|
|
3038
|
+
self-closing tag carrying the trace id and end time:
|
|
3039
|
+
|
|
3040
|
+
{note}
|
|
3041
|
+
<trace id="{trace-id}" end-time="{end-time}"/>
|
|
3042
|
+
|
|
3043
|
+
With --json, prints an array of {"note", "traceId", "endTime"} objects.
|
|
3044
|
+
|
|
3045
|
+
Examples:
|
|
3046
|
+
$ lmnr-cli debug session summary <session-id>
|
|
3047
|
+
$ lmnr-cli debug session summary <session-id> --json
|
|
3048
|
+
`);
|
|
2640
3049
|
program.addHelpText("after", `
|
|
2641
3050
|
Authentication:
|
|
2642
|
-
|
|
2643
|
-
|
|
2644
|
-
|
|
2645
|
-
|
|
3051
|
+
Run \`lmnr-cli setup\` to login, link this directory, write a project API key to
|
|
3052
|
+
./.env, and install the Laminar skill
|
|
3053
|
+
\`lmnr-cli login\` authenticates as a user. Every project command
|
|
3054
|
+
(sql / dataset / project / trace / debug) runs on that user session and
|
|
3055
|
+
targets a project via --project-id or the linked .lmnr/project.json.
|
|
2646
3056
|
|
|
2647
3057
|
Examples:
|
|
2648
|
-
lmnr-cli
|
|
2649
|
-
lmnr-cli
|
|
2650
|
-
lmnr-cli
|
|
3058
|
+
lmnr-cli setup # Logs in and prepares directory
|
|
3059
|
+
lmnr-cli login # Authenticate (user)
|
|
3060
|
+
lmnr-cli project list # Projects you can access
|
|
3061
|
+
lmnr-cli logout # Log out
|
|
2651
3062
|
lmnr-cli dataset list --json # List all datasets
|
|
2652
3063
|
lmnr-cli dataset push data.jsonl -n my-dataset --json # Push data to a dataset
|
|
2653
3064
|
lmnr-cli dataset pull output.jsonl -n my-dataset --json # Pull data from a dataset
|
|
2654
3065
|
lmnr-cli sql query "SELECT * FROM spans LIMIT 10" --json # Query spans
|
|
2655
|
-
lmnr-cli sql query "SELECT t.id, s.name FROM traces t JOIN spans s ON t.id = s.trace_id" --json
|
|
2656
3066
|
lmnr-cli sql schema # Show available tables
|
|
3067
|
+
lmnr-cli trace append-note <trace-id> "note text" # Append a note to a trace
|
|
3068
|
+
lmnr-cli debug session set-name <session-id> "title" # Rename a debug session
|
|
3069
|
+
lmnr-cli debug session summary <session-id> # Notes for each trace in a session
|
|
2657
3070
|
|
|
2658
3071
|
For more information about the Laminar platfrom:
|
|
2659
3072
|
Documentation: https://laminar.sh/docs
|
|
2660
|
-
Dashboard: https://www.laminar.sh
|
|
2661
3073
|
`);
|
|
2662
3074
|
await program.parseAsync();
|
|
2663
3075
|
}
|
|
2664
3076
|
main().catch((err) => {
|
|
2665
|
-
console.error(err
|
|
3077
|
+
console.error(errorMessage(err));
|
|
2666
3078
|
process.exit(1);
|
|
2667
3079
|
});
|
|
2668
3080
|
//#endregion
|