lmnr-cli 0.1.10 → 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 +1604 -355
- package/dist/index.cjs.map +1 -1
- package/package.json +6 -3
package/dist/index.cjs
CHANGED
|
@@ -31,6 +31,9 @@ let pino = require("pino");
|
|
|
31
31
|
let pino$3 = __toESM(pino, 1);
|
|
32
32
|
pino = __toESM(pino);
|
|
33
33
|
let pino_pretty = require("pino-pretty");
|
|
34
|
+
let node_fs_promises = require("node:fs/promises");
|
|
35
|
+
let node_path = require("node:path");
|
|
36
|
+
let node_os = require("node:os");
|
|
34
37
|
let csv_parser = require("csv-parser");
|
|
35
38
|
csv_parser = __toESM(csv_parser);
|
|
36
39
|
let export_to_csv = require("export-to-csv");
|
|
@@ -38,11 +41,18 @@ let fs_promises = require("fs/promises");
|
|
|
38
41
|
fs_promises = __toESM(fs_promises);
|
|
39
42
|
let cli_table3 = require("cli-table3");
|
|
40
43
|
cli_table3 = __toESM(cli_table3);
|
|
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");
|
|
41
51
|
//#region ../types/dist/index.mjs
|
|
42
52
|
const errorMessage = (error) => error instanceof Error ? error.message : String(error);
|
|
43
53
|
//#endregion
|
|
44
54
|
//#region package.json
|
|
45
|
-
var version$1 = "0.1.
|
|
55
|
+
var version$1 = "0.1.11";
|
|
46
56
|
//#endregion
|
|
47
57
|
//#region ../../node_modules/.pnpm/dotenv@17.4.2/node_modules/dotenv/lib/main.js
|
|
48
58
|
var require_main = /* @__PURE__ */ __commonJSMin(((exports, module) => {
|
|
@@ -348,22 +358,28 @@ function _v4(options, buf, offset) {
|
|
|
348
358
|
//#endregion
|
|
349
359
|
//#region ../client/dist/index.mjs
|
|
350
360
|
var import_main = require_main();
|
|
351
|
-
var version = "0.8.
|
|
361
|
+
var version = "0.8.29";
|
|
352
362
|
function getLangVersion() {
|
|
353
363
|
if (typeof process !== "undefined" && process.versions && process.versions.node) return `node-${process.versions.node}`;
|
|
354
364
|
if (typeof navigator !== "undefined" && navigator.userAgent) return `browser-${navigator.userAgent}`;
|
|
355
365
|
return null;
|
|
356
366
|
}
|
|
357
367
|
var BaseResource = class {
|
|
358
|
-
constructor(baseHttpUrl,
|
|
368
|
+
constructor(baseHttpUrl, auth) {
|
|
359
369
|
this.baseHttpUrl = baseHttpUrl;
|
|
360
|
-
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";
|
|
361
376
|
}
|
|
362
377
|
headers() {
|
|
363
378
|
return {
|
|
364
|
-
Authorization: `Bearer ${this.
|
|
379
|
+
Authorization: `Bearer ${this.credential}`,
|
|
365
380
|
"Content-Type": "application/json",
|
|
366
|
-
Accept: "application/json"
|
|
381
|
+
Accept: "application/json",
|
|
382
|
+
...this.auth.type === "userToken" ? { "x-lmnr-project-id": this.auth.projectId } : {}
|
|
367
383
|
};
|
|
368
384
|
}
|
|
369
385
|
async handleError(response) {
|
|
@@ -372,8 +388,8 @@ var BaseResource = class {
|
|
|
372
388
|
}
|
|
373
389
|
};
|
|
374
390
|
var BrowserEventsResource = class extends BaseResource {
|
|
375
|
-
constructor(baseHttpUrl,
|
|
376
|
-
super(baseHttpUrl,
|
|
391
|
+
constructor(baseHttpUrl, auth) {
|
|
392
|
+
super(baseHttpUrl, auth);
|
|
377
393
|
}
|
|
378
394
|
async send({ sessionId, traceId, events }) {
|
|
379
395
|
const payload = {
|
|
@@ -397,6 +413,33 @@ var BrowserEventsResource = class extends BaseResource {
|
|
|
397
413
|
if (!response.ok) await this.handleError(response);
|
|
398
414
|
}
|
|
399
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
|
+
};
|
|
400
443
|
function initializeLogger$1(options) {
|
|
401
444
|
const colorize = options?.colorize ?? true;
|
|
402
445
|
const level = options?.level ?? process.env.LMNR_LOG_LEVEL?.toLowerCase()?.trim() ?? "info";
|
|
@@ -458,8 +501,8 @@ const logger$3$1 = initializeLogger$1();
|
|
|
458
501
|
const DEFAULT_DATASET_PULL_LIMIT = 100;
|
|
459
502
|
const DEFAULT_DATASET_PUSH_BATCH_SIZE$1 = 100;
|
|
460
503
|
var DatasetsResource = class extends BaseResource {
|
|
461
|
-
constructor(baseHttpUrl,
|
|
462
|
-
super(baseHttpUrl,
|
|
504
|
+
constructor(baseHttpUrl, auth) {
|
|
505
|
+
super(baseHttpUrl, auth);
|
|
463
506
|
}
|
|
464
507
|
/**
|
|
465
508
|
* List all datasets.
|
|
@@ -467,7 +510,7 @@ var DatasetsResource = class extends BaseResource {
|
|
|
467
510
|
* @returns {Promise<Dataset[]>} Array of datasets
|
|
468
511
|
*/
|
|
469
512
|
async listDatasets() {
|
|
470
|
-
const response = await fetch(this.baseHttpUrl + "/
|
|
513
|
+
const response = await fetch(this.baseHttpUrl + this.apiPrefix + "/datasets", {
|
|
471
514
|
method: "GET",
|
|
472
515
|
headers: this.headers()
|
|
473
516
|
});
|
|
@@ -482,7 +525,7 @@ var DatasetsResource = class extends BaseResource {
|
|
|
482
525
|
*/
|
|
483
526
|
async getDatasetByName(name) {
|
|
484
527
|
const params = new URLSearchParams({ name });
|
|
485
|
-
const response = await fetch(this.baseHttpUrl +
|
|
528
|
+
const response = await fetch(this.baseHttpUrl + `${this.apiPrefix}/datasets?${params.toString()}`, {
|
|
486
529
|
method: "GET",
|
|
487
530
|
headers: this.headers()
|
|
488
531
|
});
|
|
@@ -511,7 +554,7 @@ var DatasetsResource = class extends BaseResource {
|
|
|
511
554
|
const batchNum = Math.floor(i / batchSize) + 1;
|
|
512
555
|
logger$3$1.debug(`Pushing batch ${batchNum} of ${totalBatches}`);
|
|
513
556
|
const batch = points.slice(i, i + batchSize);
|
|
514
|
-
const fetchResponse = await fetch(this.baseHttpUrl + "/
|
|
557
|
+
const fetchResponse = await fetch(this.baseHttpUrl + this.apiPrefix + "/datasets/datapoints", {
|
|
515
558
|
method: "POST",
|
|
516
559
|
headers: this.headers(),
|
|
517
560
|
body: JSON.stringify({
|
|
@@ -549,7 +592,7 @@ var DatasetsResource = class extends BaseResource {
|
|
|
549
592
|
if (name) paramsObj.name = name;
|
|
550
593
|
else paramsObj.datasetId = id;
|
|
551
594
|
const params = new URLSearchParams(paramsObj);
|
|
552
|
-
const response = await fetch(this.baseHttpUrl +
|
|
595
|
+
const response = await fetch(this.baseHttpUrl + `${this.apiPrefix}/datasets/datapoints?${params.toString()}`, {
|
|
553
596
|
method: "GET",
|
|
554
597
|
headers: this.headers()
|
|
555
598
|
});
|
|
@@ -560,8 +603,8 @@ var DatasetsResource = class extends BaseResource {
|
|
|
560
603
|
const logger$2$1 = initializeLogger$1();
|
|
561
604
|
const INITIAL_EVALUATION_DATAPOINT_MAX_DATA_LENGTH = 16e6;
|
|
562
605
|
var EvalsResource = class extends BaseResource {
|
|
563
|
-
constructor(baseHttpUrl,
|
|
564
|
-
super(baseHttpUrl,
|
|
606
|
+
constructor(baseHttpUrl, auth) {
|
|
607
|
+
super(baseHttpUrl, auth);
|
|
565
608
|
}
|
|
566
609
|
/**
|
|
567
610
|
* Initialize an evaluation.
|
|
@@ -735,8 +778,8 @@ var EvalsResource = class extends BaseResource {
|
|
|
735
778
|
* Resource for creating evaluator scores
|
|
736
779
|
*/
|
|
737
780
|
var EvaluatorsResource = class extends BaseResource {
|
|
738
|
-
constructor(baseHttpUrl,
|
|
739
|
-
super(baseHttpUrl,
|
|
781
|
+
constructor(baseHttpUrl, auth) {
|
|
782
|
+
super(baseHttpUrl, auth);
|
|
740
783
|
}
|
|
741
784
|
/**
|
|
742
785
|
* Create a score for a span or trace
|
|
@@ -793,9 +836,29 @@ var EvaluatorsResource = class extends BaseResource {
|
|
|
793
836
|
}
|
|
794
837
|
};
|
|
795
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
|
+
};
|
|
796
859
|
var RolloutSessionsResource = class extends BaseResource {
|
|
797
|
-
constructor(baseHttpUrl,
|
|
798
|
-
super(baseHttpUrl,
|
|
860
|
+
constructor(baseHttpUrl, auth) {
|
|
861
|
+
super(baseHttpUrl, auth);
|
|
799
862
|
}
|
|
800
863
|
/**
|
|
801
864
|
* Idempotently register (upsert) a debug session on the backend, keyed on the
|
|
@@ -806,7 +869,7 @@ var RolloutSessionsResource = class extends BaseResource {
|
|
|
806
869
|
* caller can build the debugger URL; null if the body can't be parsed.
|
|
807
870
|
*/
|
|
808
871
|
async register({ sessionId, name }) {
|
|
809
|
-
const response = await fetch(`${this.baseHttpUrl}/
|
|
872
|
+
const response = await fetch(`${this.baseHttpUrl}${this.apiPrefix}/rollouts/${sessionId}`, {
|
|
810
873
|
method: "POST",
|
|
811
874
|
headers: this.headers(),
|
|
812
875
|
body: JSON.stringify({ name })
|
|
@@ -826,15 +889,73 @@ var RolloutSessionsResource = class extends BaseResource {
|
|
|
826
889
|
* stays the SDK's job via {@link register}.
|
|
827
890
|
*/
|
|
828
891
|
async setName({ sessionId, name }) {
|
|
829
|
-
const response = await fetch(`${this.baseHttpUrl}/
|
|
892
|
+
const response = await fetch(`${this.baseHttpUrl}${this.apiPrefix}/rollouts/${sessionId}/name`, {
|
|
830
893
|
method: "PATCH",
|
|
831
894
|
headers: this.headers(),
|
|
832
895
|
body: JSON.stringify({ name })
|
|
833
896
|
});
|
|
834
897
|
if (!response.ok) await this.handleError(response);
|
|
835
898
|
}
|
|
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
|
+
}
|
|
836
957
|
async delete({ sessionId }) {
|
|
837
|
-
const response = await fetch(`${this.baseHttpUrl}/
|
|
958
|
+
const response = await fetch(`${this.baseHttpUrl}${this.apiPrefix}/rollouts/${sessionId}`, {
|
|
838
959
|
method: "DELETE",
|
|
839
960
|
headers: this.headers()
|
|
840
961
|
});
|
|
@@ -842,11 +963,11 @@ var RolloutSessionsResource = class extends BaseResource {
|
|
|
842
963
|
}
|
|
843
964
|
};
|
|
844
965
|
var SqlResource = class extends BaseResource {
|
|
845
|
-
constructor(baseHttpUrl,
|
|
846
|
-
super(baseHttpUrl,
|
|
966
|
+
constructor(baseHttpUrl, auth) {
|
|
967
|
+
super(baseHttpUrl, auth);
|
|
847
968
|
}
|
|
848
969
|
async query(sql, parameters = {}) {
|
|
849
|
-
const response = await fetch(`${this.baseHttpUrl}/
|
|
970
|
+
const response = await fetch(`${this.baseHttpUrl}${this.apiPrefix}/sql/query`, {
|
|
850
971
|
method: "POST",
|
|
851
972
|
headers: { ...this.headers() },
|
|
852
973
|
body: JSON.stringify({
|
|
@@ -861,8 +982,8 @@ var SqlResource = class extends BaseResource {
|
|
|
861
982
|
/** Resource for tagging traces. */
|
|
862
983
|
var TagsResource = class extends BaseResource {
|
|
863
984
|
/** Resource for tagging traces. */
|
|
864
|
-
constructor(baseHttpUrl,
|
|
865
|
-
super(baseHttpUrl,
|
|
985
|
+
constructor(baseHttpUrl, auth) {
|
|
986
|
+
super(baseHttpUrl, auth);
|
|
866
987
|
}
|
|
867
988
|
/**
|
|
868
989
|
* Tag a trace with a list of tags. Note that the trace must be ended before
|
|
@@ -917,8 +1038,8 @@ var TagsResource = class extends BaseResource {
|
|
|
917
1038
|
const logger$5 = initializeLogger$1();
|
|
918
1039
|
var TracesResource = class extends BaseResource {
|
|
919
1040
|
/** Resource for post-factum operations on existing traces. */
|
|
920
|
-
constructor(baseHttpUrl,
|
|
921
|
-
super(baseHttpUrl,
|
|
1041
|
+
constructor(baseHttpUrl, auth) {
|
|
1042
|
+
super(baseHttpUrl, auth);
|
|
922
1043
|
}
|
|
923
1044
|
/**
|
|
924
1045
|
* Push a metadata patch to an existing trace.
|
|
@@ -973,7 +1094,7 @@ var TracesResource = class extends BaseResource {
|
|
|
973
1094
|
async pushMetadata(traceId, metadata, options) {
|
|
974
1095
|
if (!metadata || Object.keys(metadata).length === 0) throw new Error("metadata must be a non-empty object");
|
|
975
1096
|
const formattedTraceId = isStringUUID(traceId) ? traceId : otelTraceIdToUUID(traceId);
|
|
976
|
-
const url = this.baseHttpUrl + "/
|
|
1097
|
+
const url = this.baseHttpUrl + this.apiPrefix + "/traces/metadata";
|
|
977
1098
|
const response = await fetch(url, {
|
|
978
1099
|
method: "POST",
|
|
979
1100
|
headers: this.headers(),
|
|
@@ -991,25 +1112,49 @@ var TracesResource = class extends BaseResource {
|
|
|
991
1112
|
if (!response.ok) await this.handleError(response);
|
|
992
1113
|
}
|
|
993
1114
|
};
|
|
994
|
-
var LaminarClient = class {
|
|
995
|
-
constructor({ baseUrl, projectApiKey,
|
|
1115
|
+
var LaminarClient = class LaminarClient {
|
|
1116
|
+
constructor({ baseUrl, port, auth, projectApiKey, cliUserProjectId } = {}) {
|
|
996
1117
|
loadEnv();
|
|
997
|
-
this.
|
|
1118
|
+
this.auth = LaminarClient.normalizeAuth(auth, projectApiKey, cliUserProjectId);
|
|
998
1119
|
const httpPort = port ?? (baseUrl?.match(/:\d{1,5}$/g) ? parseInt(baseUrl.match(/:\d{1,5}$/g)[0].slice(1)) : 443);
|
|
999
1120
|
const baseUrlNoPort = (baseUrl ?? process.env.LMNR_BASE_URL)?.replace(/\/$/, "").replace(/:\d{1,5}$/g, "");
|
|
1000
1121
|
this.baseUrl = `${baseUrlNoPort ?? "https://api.lmnr.ai"}:${httpPort}`;
|
|
1001
|
-
this._browserEvents = new BrowserEventsResource(this.baseUrl, this.
|
|
1002
|
-
this.
|
|
1003
|
-
this.
|
|
1004
|
-
this.
|
|
1005
|
-
this.
|
|
1006
|
-
this.
|
|
1007
|
-
this.
|
|
1008
|
-
this.
|
|
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
|
+
};
|
|
1009
1151
|
}
|
|
1010
1152
|
get browserEvents() {
|
|
1011
1153
|
return this._browserEvents;
|
|
1012
1154
|
}
|
|
1155
|
+
get cli() {
|
|
1156
|
+
return this._cli;
|
|
1157
|
+
}
|
|
1013
1158
|
get datasets() {
|
|
1014
1159
|
return this._datasets;
|
|
1015
1160
|
}
|
|
@@ -1044,8 +1189,458 @@ function initializeLogger(options) {
|
|
|
1044
1189
|
}));
|
|
1045
1190
|
}
|
|
1046
1191
|
//#endregion
|
|
1047
|
-
//#region src/utils/
|
|
1192
|
+
//#region src/utils/output.ts
|
|
1193
|
+
/**
|
|
1194
|
+
* Write structured JSON to stdout. Use this for machine-readable output
|
|
1195
|
+
* when --json is set.
|
|
1196
|
+
*/
|
|
1197
|
+
function outputJson(data) {
|
|
1198
|
+
console.log(JSON.stringify(data));
|
|
1199
|
+
}
|
|
1200
|
+
/**
|
|
1201
|
+
* Write a JSON error to stdout and exit with code 1.
|
|
1202
|
+
* Use this in --json mode so agents can parse the failure.
|
|
1203
|
+
*/
|
|
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";
|
|
1216
|
+
/**
|
|
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.
|
|
1220
|
+
*/
|
|
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
|
+
}
|
|
1254
|
+
/**
|
|
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).
|
|
1257
|
+
*/
|
|
1258
|
+
async function readCredentials() {
|
|
1259
|
+
const path = credentialsPath();
|
|
1260
|
+
let raw;
|
|
1261
|
+
try {
|
|
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;
|
|
1321
|
+
}
|
|
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
|
+
}
|
|
1342
|
+
/**
|
|
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).
|
|
1345
|
+
*/
|
|
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
|
+
}
|
|
1384
|
+
/**
|
|
1385
|
+
* Mint a fresh 15m EdDSA JWT from a session token. Throws DeviceFlowError
|
|
1386
|
+
* "invalid_grant" on 401 (session expired/revoked).
|
|
1387
|
+
*/
|
|
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
|
+
};
|
|
1417
|
+
}
|
|
1418
|
+
/**
|
|
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`.
|
|
1422
|
+
*/
|
|
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
|
|
1048
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();
|
|
1049
1644
|
/**
|
|
1050
1645
|
* Check if a file has a supported extension.
|
|
1051
1646
|
*/
|
|
@@ -1066,7 +1661,7 @@ const collectFiles = async (paths, recursive = false) => {
|
|
|
1066
1661
|
for (const filepath of paths) try {
|
|
1067
1662
|
const stats = await fs_promises.stat(filepath);
|
|
1068
1663
|
if (stats.isFile()) if (isSupportedFile(filepath)) collectedFiles.push(filepath);
|
|
1069
|
-
else logger$
|
|
1664
|
+
else logger$3.warn(`Skipping unsupported file type: ${filepath}`);
|
|
1070
1665
|
else if (stats.isDirectory()) {
|
|
1071
1666
|
const entries = await fs_promises.readdir(filepath);
|
|
1072
1667
|
for (const entry of entries) {
|
|
@@ -1080,7 +1675,7 @@ const collectFiles = async (paths, recursive = false) => {
|
|
|
1080
1675
|
}
|
|
1081
1676
|
}
|
|
1082
1677
|
} catch (error) {
|
|
1083
|
-
logger$
|
|
1678
|
+
logger$3.warn(`Path does not exist or is not accessible: ${filepath}. Error: ${errorMessage(error)}`);
|
|
1084
1679
|
}
|
|
1085
1680
|
return collectedFiles;
|
|
1086
1681
|
};
|
|
@@ -1102,7 +1697,7 @@ const tryParseJson = (content) => {
|
|
|
1102
1697
|
try {
|
|
1103
1698
|
return JSON.parse(content);
|
|
1104
1699
|
} catch (error) {
|
|
1105
|
-
logger$
|
|
1700
|
+
logger$3.debug(`Error parsing JSON: ${errorMessage(error)}`);
|
|
1106
1701
|
return content;
|
|
1107
1702
|
}
|
|
1108
1703
|
};
|
|
@@ -1130,7 +1725,7 @@ async function readJsonlFile(filepath) {
|
|
|
1130
1725
|
/**
|
|
1131
1726
|
* Read a single file and return its contents.
|
|
1132
1727
|
*/
|
|
1133
|
-
async function readFile(filepath) {
|
|
1728
|
+
async function readFile$1(filepath) {
|
|
1134
1729
|
const ext = path.extname(filepath).toLowerCase();
|
|
1135
1730
|
if (ext === ".json") return readJsonFile(filepath);
|
|
1136
1731
|
else if (ext === ".csv") return readCsvFile(filepath);
|
|
@@ -1143,17 +1738,17 @@ async function readFile(filepath) {
|
|
|
1143
1738
|
const loadFromPaths = async (paths, recursive = false) => {
|
|
1144
1739
|
const files = await collectFiles(paths, recursive);
|
|
1145
1740
|
if (files.length === 0) {
|
|
1146
|
-
logger$
|
|
1741
|
+
logger$3.warn("No supported files found in the specified paths");
|
|
1147
1742
|
return [];
|
|
1148
1743
|
}
|
|
1149
|
-
logger$
|
|
1744
|
+
logger$3.info(`Found ${files.length} file(s) to read`);
|
|
1150
1745
|
const result = [];
|
|
1151
1746
|
for (const file of files) try {
|
|
1152
|
-
const data = await readFile(file);
|
|
1747
|
+
const data = await readFile$1(file);
|
|
1153
1748
|
result.push(...data);
|
|
1154
|
-
logger$
|
|
1749
|
+
logger$3.info(`Read ${data.length} record(s) from ${file}`);
|
|
1155
1750
|
} catch (error) {
|
|
1156
|
-
logger$
|
|
1751
|
+
logger$3.error(`Error reading file ${file}: ${errorMessage(error)}`);
|
|
1157
1752
|
throw error;
|
|
1158
1753
|
}
|
|
1159
1754
|
return result;
|
|
@@ -1193,7 +1788,7 @@ const writeToFile = async (filepath, data, format) => {
|
|
|
1193
1788
|
const dir = path.dirname(filepath);
|
|
1194
1789
|
await fs_promises.mkdir(dir, { recursive: true });
|
|
1195
1790
|
const ext = format ?? path.extname(filepath).slice(1);
|
|
1196
|
-
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)}`);
|
|
1197
1792
|
if (ext === "json") await writeJsonFile(filepath, data);
|
|
1198
1793
|
else if (ext === "csv") await writeCsvFile(filepath, data);
|
|
1199
1794
|
else if (ext === "jsonl") await writeJsonlFile(filepath, data);
|
|
@@ -1218,7 +1813,7 @@ const printToConsole = (data, format = "json") => {
|
|
|
1218
1813
|
if (format === "json") console.log(JSON.stringify(data, null, 2));
|
|
1219
1814
|
else if (format === "csv") {
|
|
1220
1815
|
if (data.length === 0) {
|
|
1221
|
-
logger$
|
|
1816
|
+
logger$3.error("No data to print");
|
|
1222
1817
|
return;
|
|
1223
1818
|
}
|
|
1224
1819
|
console.log(formatCsv(data));
|
|
@@ -1226,23 +1821,6 @@ const printToConsole = (data, format = "json") => {
|
|
|
1226
1821
|
else throw new Error(`Unsupported output format: ${String(format)}. (supported formats: json, csv, jsonl)`);
|
|
1227
1822
|
};
|
|
1228
1823
|
//#endregion
|
|
1229
|
-
//#region src/utils/output.ts
|
|
1230
|
-
/**
|
|
1231
|
-
* Write structured JSON to stdout. Use this for machine-readable output
|
|
1232
|
-
* when --json is set.
|
|
1233
|
-
*/
|
|
1234
|
-
function outputJson(data) {
|
|
1235
|
-
console.log(JSON.stringify(data));
|
|
1236
|
-
}
|
|
1237
|
-
/**
|
|
1238
|
-
* Write a JSON error to stdout and exit with code 1.
|
|
1239
|
-
* Use this in --json mode so agents can parse the failure.
|
|
1240
|
-
*/
|
|
1241
|
-
function outputJsonError(error, exitCode = 1) {
|
|
1242
|
-
console.log(JSON.stringify({ error: errorMessage(error) }));
|
|
1243
|
-
process.exit(exitCode);
|
|
1244
|
-
}
|
|
1245
|
-
//#endregion
|
|
1246
1824
|
//#region src/utils/table.ts
|
|
1247
1825
|
const DEFAULT_TERMINAL_WIDTH = 80;
|
|
1248
1826
|
const PADDING_RIGHT = 2;
|
|
@@ -1305,9 +1883,14 @@ function renderTable(head, rows) {
|
|
|
1305
1883
|
}
|
|
1306
1884
|
//#endregion
|
|
1307
1885
|
//#region src/commands/dataset/index.ts
|
|
1308
|
-
const logger$
|
|
1886
|
+
const logger$2 = initializeLogger();
|
|
1309
1887
|
const DEFAULT_DATASET_PULL_BATCH_SIZE = 100;
|
|
1310
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
|
+
}
|
|
1311
1894
|
/**
|
|
1312
1895
|
* Pull all data from a dataset in batches.
|
|
1313
1896
|
*/
|
|
@@ -1332,171 +1915,95 @@ const pullAllData = async (client, identifier, batchSize = DEFAULT_DATASET_PULL_
|
|
|
1332
1915
|
return result;
|
|
1333
1916
|
};
|
|
1334
1917
|
/**
|
|
1335
|
-
* Handle datasets list command.
|
|
1918
|
+
* Handle datasets list command. Pure handler — `withProjectClient` resolves the
|
|
1919
|
+
* client and owns the error envelope.
|
|
1336
1920
|
*/
|
|
1337
|
-
const handleDatasetsList = async (
|
|
1338
|
-
const
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
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
|
+
];
|
|
1342
1938
|
});
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
if (datasets.length === 0) {
|
|
1350
|
-
console.log("No datasets found.");
|
|
1351
|
-
return;
|
|
1352
|
-
}
|
|
1353
|
-
const rows = datasets.map((dataset) => {
|
|
1354
|
-
const createdAtStr = new Date(dataset.createdAt).toISOString().replace("T", " ").substring(0, 19);
|
|
1355
|
-
return [
|
|
1356
|
-
dataset.id,
|
|
1357
|
-
createdAtStr,
|
|
1358
|
-
dataset.name
|
|
1359
|
-
];
|
|
1360
|
-
});
|
|
1361
|
-
console.log(renderTable([
|
|
1362
|
-
"ID",
|
|
1363
|
-
"Created At",
|
|
1364
|
-
"Name"
|
|
1365
|
-
], rows));
|
|
1366
|
-
console.log(`\nTotal: ${datasets.length} dataset(s)\n`);
|
|
1367
|
-
} catch (error) {
|
|
1368
|
-
if (options.json) outputJsonError(error);
|
|
1369
|
-
logger$3.error(`Failed to list datasets: ${errorMessage(error)}`);
|
|
1370
|
-
process.exit(1);
|
|
1371
|
-
}
|
|
1939
|
+
console.log(renderTable([
|
|
1940
|
+
"ID",
|
|
1941
|
+
"Created At",
|
|
1942
|
+
"Name"
|
|
1943
|
+
], rows));
|
|
1944
|
+
console.log(`\nTotal: ${datasets.length} dataset(s)\n`);
|
|
1372
1945
|
};
|
|
1373
1946
|
/**
|
|
1374
1947
|
* Handle datasets push command.
|
|
1375
1948
|
*/
|
|
1376
|
-
const handleDatasetsPush = async (paths,
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
process.exit(1);
|
|
1386
|
-
}
|
|
1387
|
-
const client = new LaminarClient({
|
|
1388
|
-
projectApiKey: options.projectApiKey,
|
|
1389
|
-
baseUrl: options.baseUrl,
|
|
1390
|
-
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
|
|
1391
1958
|
});
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
logger$3.error("No data to push. Skipping");
|
|
1397
|
-
process.exit(1);
|
|
1398
|
-
}
|
|
1399
|
-
const identifier = options.name ? { name: options.name } : { id: options.id };
|
|
1400
|
-
const result = await client.datasets.push({
|
|
1401
|
-
points: data,
|
|
1402
|
-
...identifier,
|
|
1403
|
-
batchSize: options.batchSize ?? DEFAULT_DATASET_PUSH_BATCH_SIZE
|
|
1959
|
+
if (opts.json) {
|
|
1960
|
+
outputJson({
|
|
1961
|
+
datasetId: result?.datasetId,
|
|
1962
|
+
count: data.length
|
|
1404
1963
|
});
|
|
1405
|
-
|
|
1406
|
-
outputJson({
|
|
1407
|
-
datasetId: result?.datasetId,
|
|
1408
|
-
count: data.length
|
|
1409
|
-
});
|
|
1410
|
-
return;
|
|
1411
|
-
}
|
|
1412
|
-
logger$3.info(`Pushed ${data.length} data points to dataset ${options.name || options.id}`);
|
|
1413
|
-
} catch (error) {
|
|
1414
|
-
if (options.json) outputJsonError(error);
|
|
1415
|
-
logger$3.error(`Failed to push dataset: ${errorMessage(error)}`);
|
|
1416
|
-
process.exit(1);
|
|
1964
|
+
return;
|
|
1417
1965
|
}
|
|
1966
|
+
logger$2.info(`Pushed ${data.length} data points to dataset ${opts.name || opts.id}`);
|
|
1418
1967
|
};
|
|
1419
1968
|
/**
|
|
1420
1969
|
* Handle datasets pull command.
|
|
1421
1970
|
*/
|
|
1422
|
-
const handleDatasetsPull = async (outputPath,
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
}
|
|
1433
|
-
|
|
1434
|
-
projectApiKey: options.projectApiKey,
|
|
1435
|
-
baseUrl: options.baseUrl,
|
|
1436
|
-
port: options.port
|
|
1437
|
-
});
|
|
1438
|
-
const identifier = options.name ? { name: options.name } : { id: options.id };
|
|
1439
|
-
try {
|
|
1440
|
-
const result = await pullAllData(client, identifier, options.batchSize ?? DEFAULT_DATASET_PULL_BATCH_SIZE, options.offset ?? 0, options.limit);
|
|
1441
|
-
if (outputPath) {
|
|
1442
|
-
await writeToFile(outputPath, result, options.outputFormat);
|
|
1443
|
-
if (options.json) outputJson({
|
|
1444
|
-
path: outputPath,
|
|
1445
|
-
count: result.length
|
|
1446
|
-
});
|
|
1447
|
-
else logger$3.info(`Successfully pulled ${result.length} data points to ${outputPath}`);
|
|
1448
|
-
} else if (options.json) outputJson(result);
|
|
1449
|
-
else printToConsole(result, options.outputFormat ?? "json");
|
|
1450
|
-
} catch (error) {
|
|
1451
|
-
if (options.json) outputJsonError(error);
|
|
1452
|
-
logger$3.error(`Failed to pull dataset: ${errorMessage(error)}`);
|
|
1453
|
-
process.exit(1);
|
|
1454
|
-
}
|
|
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");
|
|
1455
1983
|
};
|
|
1456
1984
|
/**
|
|
1457
1985
|
* Handle datasets create command.
|
|
1458
1986
|
*/
|
|
1459
|
-
const handleDatasetsCreate = async (name, paths,
|
|
1460
|
-
const
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
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
|
|
1464
1996
|
});
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
name,
|
|
1476
|
-
batchSize: options.batchSize ?? DEFAULT_DATASET_PUSH_BATCH_SIZE,
|
|
1477
|
-
createDataset: true
|
|
1478
|
-
});
|
|
1479
|
-
logger$3.info(`Successfully pushed ${data.length} data points to dataset '${name}'`);
|
|
1480
|
-
} catch (error) {
|
|
1481
|
-
if (options.json) outputJsonError(error);
|
|
1482
|
-
logger$3.error(`Failed to create dataset: ${errorMessage(error)}`);
|
|
1483
|
-
process.exit(1);
|
|
1484
|
-
}
|
|
1485
|
-
logger$3.info(`Pulling data from dataset '${name}'...`);
|
|
1486
|
-
try {
|
|
1487
|
-
const result = await pullAllData(client, { name }, options.batchSize ?? DEFAULT_DATASET_PULL_BATCH_SIZE, 0, void 0);
|
|
1488
|
-
await writeToFile(options.outputFile, result, options.outputFormat);
|
|
1489
|
-
if (options.json) outputJson({
|
|
1490
|
-
name,
|
|
1491
|
-
path: options.outputFile,
|
|
1492
|
-
count: result.length
|
|
1493
|
-
});
|
|
1494
|
-
else logger$3.info(`Successfully created dataset '${name}' and saved ${result.length} datapoints to ${options.outputFile}`);
|
|
1495
|
-
} catch (error) {
|
|
1496
|
-
if (options.json) outputJsonError(error);
|
|
1497
|
-
logger$3.error("Failed to pull dataset after creation: " + errorMessage(error));
|
|
1498
|
-
process.exit(1);
|
|
1499
|
-
}
|
|
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}`);
|
|
1500
2007
|
};
|
|
1501
2008
|
//#endregion
|
|
1502
2009
|
//#region src/utils/trace-note.ts
|
|
@@ -1533,112 +2040,860 @@ const readNoteFromMetadata = (metadata) => {
|
|
|
1533
2040
|
};
|
|
1534
2041
|
//#endregion
|
|
1535
2042
|
//#region src/commands/debug/index.ts
|
|
1536
|
-
const logger$
|
|
2043
|
+
const logger$1 = initializeLogger();
|
|
1537
2044
|
/**
|
|
1538
2045
|
* Upsert the display name of a debug session. Update-only on the backend: a
|
|
1539
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.
|
|
1540
2051
|
*/
|
|
1541
|
-
const handleDebugSessionSetName = async (sessionId, name,
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
port: options.port
|
|
2052
|
+
const handleDebugSessionSetName = async (client, sessionId, name, opts) => {
|
|
2053
|
+
await client.rolloutSessions.setName({
|
|
2054
|
+
sessionId,
|
|
2055
|
+
name
|
|
1546
2056
|
});
|
|
1547
|
-
|
|
1548
|
-
|
|
2057
|
+
if (opts.json) {
|
|
2058
|
+
outputJson({
|
|
1549
2059
|
sessionId,
|
|
1550
2060
|
name
|
|
1551
2061
|
});
|
|
1552
|
-
|
|
1553
|
-
outputJson({
|
|
1554
|
-
sessionId,
|
|
1555
|
-
name
|
|
1556
|
-
});
|
|
1557
|
-
return;
|
|
1558
|
-
}
|
|
1559
|
-
logger$2.info(`Set name of session ${sessionId} to "${name}".`);
|
|
1560
|
-
} catch (error) {
|
|
1561
|
-
if (options.json) outputJsonError(error);
|
|
1562
|
-
logger$2.error(`Failed to set session name: ${errorMessage(error)}`);
|
|
1563
|
-
process.exit(1);
|
|
2062
|
+
return;
|
|
1564
2063
|
}
|
|
2064
|
+
logger$1.info(`Set name of session ${sessionId} to "${name}".`);
|
|
1565
2065
|
};
|
|
1566
|
-
const SUMMARY_PAGE_SIZE = 1e3;
|
|
1567
2066
|
/**
|
|
1568
2067
|
* Print a per-trace summary of a debug session: every trace whose metadata
|
|
1569
2068
|
* groups it to the session (`rollout.session_id`), oldest first, with the
|
|
1570
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.
|
|
2074
|
+
*/
|
|
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;
|
|
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);
|
|
2101
|
+
}
|
|
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
|
|
2121
|
+
});
|
|
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
|
|
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 "";
|
|
2147
|
+
}
|
|
2148
|
+
//#endregion
|
|
2149
|
+
//#region src/commands/logout/index.ts
|
|
2150
|
+
/**
|
|
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.
|
|
2155
|
+
*/
|
|
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(/\/+$/, "");
|
|
2188
|
+
}
|
|
2189
|
+
//#endregion
|
|
2190
|
+
//#region src/commands/project/index.ts
|
|
2191
|
+
/**
|
|
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.
|
|
2195
|
+
*/
|
|
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");
|
|
2224
|
+
};
|
|
2225
|
+
//#endregion
|
|
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
|
+
};
|
|
2281
|
+
}
|
|
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
|
+
}
|
|
2311
|
+
/**
|
|
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.
|
|
2315
|
+
*/
|
|
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" }
|
|
2321
|
+
};
|
|
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
|
+
}
|
|
2331
|
+
};
|
|
2332
|
+
}
|
|
2333
|
+
return null;
|
|
2334
|
+
}
|
|
2335
|
+
/**
|
|
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.
|
|
2345
|
+
*/
|
|
2346
|
+
const AUTOLOADED_ENV_KEYS = [
|
|
2347
|
+
"LMNR_BASE_URL",
|
|
2348
|
+
"LMNR_HTTP_PORT",
|
|
2349
|
+
"LMNR_FRONTEND_URL"
|
|
2350
|
+
];
|
|
2351
|
+
/**
|
|
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).
|
|
2356
|
+
*
|
|
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;
|
|
2367
|
+
}
|
|
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) {
|
|
2388
|
+
try {
|
|
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;
|
|
2397
|
+
}
|
|
2398
|
+
}
|
|
2399
|
+
async function fileExists(path) {
|
|
2400
|
+
try {
|
|
2401
|
+
await (0, node_fs_promises.access)(path);
|
|
2402
|
+
return true;
|
|
2403
|
+
} catch {
|
|
2404
|
+
return false;
|
|
2405
|
+
}
|
|
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";
|
|
2420
|
+
/**
|
|
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.
|
|
2425
|
+
*/
|
|
2426
|
+
function skillSource() {
|
|
2427
|
+
return `github:${SKILL_REPO}/skills/${SKILL_NAME}#${SKILL_REF}`;
|
|
2428
|
+
}
|
|
2429
|
+
//#endregion
|
|
2430
|
+
//#region src/skill/fetch-skill.ts
|
|
2431
|
+
/**
|
|
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.
|
|
2437
|
+
*
|
|
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.
|
|
1571
2446
|
*/
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
port: options.port
|
|
2447
|
+
async function downloadSkill(dir) {
|
|
2448
|
+
await (0, giget.downloadTemplate)(skillSource(), {
|
|
2449
|
+
dir,
|
|
2450
|
+
force: true
|
|
1577
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"];
|
|
2462
|
+
/**
|
|
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.
|
|
2477
|
+
*/
|
|
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-"));
|
|
1578
2480
|
try {
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
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
|
|
1586
2504
|
});
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
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
|
+
}
|
|
2509
|
+
return {
|
|
2510
|
+
written,
|
|
2511
|
+
defaulted: present.length === 0,
|
|
2512
|
+
skipped: false
|
|
2513
|
+
};
|
|
2514
|
+
} finally {
|
|
2515
|
+
await (0, node_fs_promises.rm)(staging, {
|
|
2516
|
+
recursive: true,
|
|
2517
|
+
force: true
|
|
2518
|
+
});
|
|
2519
|
+
}
|
|
2520
|
+
}
|
|
2521
|
+
async function dirExists(path) {
|
|
2522
|
+
try {
|
|
2523
|
+
await (0, node_fs_promises.access)(path);
|
|
2524
|
+
return true;
|
|
2525
|
+
} catch {
|
|
2526
|
+
return false;
|
|
2527
|
+
}
|
|
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;
|
|
2544
|
+
/**
|
|
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).
|
|
2558
|
+
*/
|
|
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
|
|
1591
2575
|
});
|
|
1592
|
-
|
|
1593
|
-
|
|
2576
|
+
} catch (err) {
|
|
2577
|
+
emitError(isJson, "login_failed", describeError(err));
|
|
2578
|
+
process.exit(EXIT_LOGIN_FAILED);
|
|
1594
2579
|
}
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
2580
|
+
creds = await safeReadCredentials();
|
|
2581
|
+
if (!creds) {
|
|
2582
|
+
emitError(isJson, "login_failed", "credentials missing after login");
|
|
2583
|
+
process.exit(EXIT_LOGIN_FAILED);
|
|
1598
2584
|
}
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
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
|
+
}
|
|
1602
2656
|
}
|
|
1603
|
-
const blocks = traces.map((trace) => {
|
|
1604
|
-
const tag = `<trace id="${trace.traceId}" end-time="${trace.endTime}"/>`;
|
|
1605
|
-
return trace.note ? `${trace.note}\n${tag}` : tag;
|
|
1606
|
-
});
|
|
1607
|
-
console.log(blocks.join("\n\n"));
|
|
1608
|
-
} catch (error) {
|
|
1609
|
-
if (options.json) outputJsonError(error);
|
|
1610
|
-
logger$2.error(`Failed to summarize session: ${errorMessage(error)}`);
|
|
1611
|
-
process.exit(1);
|
|
1612
2657
|
}
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
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
|
+
}
|
|
2693
|
+
/**
|
|
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.
|
|
2698
|
+
*/
|
|
2699
|
+
async function resolveProjectViaCli(creds, userBaseUrl, issuer, isJson, options) {
|
|
2700
|
+
let projects;
|
|
2701
|
+
try {
|
|
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);
|
|
2711
|
+
}
|
|
2712
|
+
process.stderr.write("\nYou have no projects yet. Opening the browser to create your first one...\n");
|
|
2713
|
+
let login;
|
|
2714
|
+
try {
|
|
2715
|
+
login = await handleLogin({
|
|
2716
|
+
frontendUrl: issuer,
|
|
2717
|
+
noBrowser: options.noBrowser
|
|
2718
|
+
});
|
|
2719
|
+
} catch (err) {
|
|
2720
|
+
emitError(isJson, "login_failed", describeError(err));
|
|
2721
|
+
process.exit(EXIT_LOGIN_FAILED);
|
|
2722
|
+
}
|
|
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);
|
|
2726
|
+
}
|
|
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);
|
|
2742
|
+
}
|
|
2743
|
+
chosen = await promptProjectChoice(projects);
|
|
2744
|
+
}
|
|
2745
|
+
const linkPath = await writeProjectLink({
|
|
2746
|
+
projectId: chosen.id,
|
|
2747
|
+
projectName: chosen.name,
|
|
2748
|
+
workspaceId: chosen.workspaceId,
|
|
2749
|
+
workspaceName: chosen.workspaceName
|
|
1622
2750
|
});
|
|
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 };
|
|
1623
2762
|
try {
|
|
1624
|
-
const
|
|
1625
|
-
if (
|
|
1626
|
-
|
|
1627
|
-
|
|
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
|
+
};
|
|
1628
2772
|
}
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
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`);
|
|
2779
|
+
}
|
|
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: ""
|
|
1632
2810
|
}
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
2811
|
+
}).cli.listProjects();
|
|
2812
|
+
}
|
|
2813
|
+
async function safeReadCredentials() {
|
|
2814
|
+
try {
|
|
2815
|
+
return await readCredentials();
|
|
2816
|
+
} catch {
|
|
2817
|
+
return null;
|
|
2818
|
+
}
|
|
2819
|
+
}
|
|
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
|
|
2846
|
+
});
|
|
2847
|
+
try {
|
|
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`);
|
|
2853
|
+
}
|
|
2854
|
+
} finally {
|
|
2855
|
+
rl.close();
|
|
1641
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`);
|
|
1642
2897
|
};
|
|
1643
2898
|
//#endregion
|
|
1644
2899
|
//#region src/commands/sql/schema.ts
|
|
@@ -1698,6 +2953,10 @@ const NOTE_SEPARATOR = "\n\n";
|
|
|
1698
2953
|
* as `existing + "\n\n" + note`. The note may contain markdown /
|
|
1699
2954
|
* span-reference links.
|
|
1700
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
|
+
*
|
|
1701
2960
|
* The read-modify-write is not transactional: the patch lands via the async
|
|
1702
2961
|
* ingestion queue, so a second append issued within ~a second of the first
|
|
1703
2962
|
* can read the pre-patch note and drop the first append. Fine for the
|
|
@@ -1708,55 +2967,35 @@ const NOTE_SEPARATOR = "\n\n";
|
|
|
1708
2967
|
* the metadata patch endpoint that concatenates within the Postgres UPDATE,
|
|
1709
2968
|
* which already serializes on the trace row lock).
|
|
1710
2969
|
*/
|
|
1711
|
-
const handleTraceAppendNote = async (traceId, note,
|
|
1712
|
-
const
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
}
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
if (options.json) {
|
|
1725
|
-
outputJson({
|
|
1726
|
-
traceId: id,
|
|
1727
|
-
note: updated
|
|
1728
|
-
});
|
|
1729
|
-
return;
|
|
1730
|
-
}
|
|
1731
|
-
logger.info(`Appended note to trace ${id}.`);
|
|
1732
|
-
} catch (error) {
|
|
1733
|
-
if (options.json) outputJsonError(error);
|
|
1734
|
-
logger.error(`Failed to append trace note: ${errorMessage(error)}`);
|
|
1735
|
-
process.exit(1);
|
|
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;
|
|
1736
2983
|
}
|
|
2984
|
+
logger.info(`Appended note to trace ${id}.`);
|
|
1737
2985
|
};
|
|
1738
2986
|
//#endregion
|
|
1739
2987
|
//#region src/index.ts
|
|
1740
2988
|
async function main() {
|
|
2989
|
+
await loadLocalEnv(process.cwd());
|
|
1741
2990
|
const program = new commander.Command();
|
|
1742
2991
|
program.name("lmnr-cli").description("CLI for the Laminar agent observability platform").version(version$1, "-v, --version", "display version number");
|
|
1743
|
-
const datasetsCmd = program.command("dataset").description("Manage datasets").option("--project-
|
|
1744
|
-
datasetsCmd.command("list").description("List all datasets").action(
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
datasetsCmd.command("
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
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) => {
|
|
1751
|
-
await handleDatasetsPull(outputPath, cmd.optsWithGlobals());
|
|
1752
|
-
});
|
|
1753
|
-
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) => {
|
|
1754
|
-
await handleDatasetsCreate(name, paths, cmd.optsWithGlobals());
|
|
1755
|
-
});
|
|
1756
|
-
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");
|
|
1757
|
-
sqlCmd.command("query").description("Execute a SQL query").argument("<query>", "SQL query string").action(async (query, _options, cmd) => {
|
|
1758
|
-
await handleSqlQuery(query, cmd.optsWithGlobals());
|
|
1759
|
-
}).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 + `
|
|
1760
2999
|
Examples:
|
|
1761
3000
|
$ lmnr-cli sql query "SELECT * FROM spans LIMIT 10"
|
|
1762
3001
|
$ lmnr-cli sql query "SELECT id, total_cost, status FROM traces LIMIT 20"
|
|
@@ -1765,29 +3004,36 @@ Examples:
|
|
|
1765
3004
|
sqlCmd.command("schema").description("Show available tables and their columns").action(() => {
|
|
1766
3005
|
process.stdout.write(SQL_SCHEMA_HELP);
|
|
1767
3006
|
});
|
|
1768
|
-
program.command("
|
|
1769
|
-
|
|
1770
|
-
|
|
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", `
|
|
1771
3021
|
Notes accumulate: each call appends a new paragraph to the trace's existing
|
|
1772
3022
|
note rather than overwriting it.
|
|
1773
3023
|
|
|
1774
3024
|
Examples:
|
|
1775
3025
|
$ lmnr-cli trace append-note <trace-id> "Reproduced the timeout on the search tool."
|
|
1776
3026
|
`);
|
|
1777
|
-
const debugSessionCmd = program.command("debug").description("Operate on debug sessions").option("--project-
|
|
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", `
|
|
1778
3028
|
Learn more about debugging features at https://laminar.sh/docs/platform/debugger
|
|
1779
3029
|
`).command("session").description("Manage debug sessions").addHelpText("after", `
|
|
1780
3030
|
Learn more about debugging features at https://laminar.sh/docs/platform/debugger
|
|
1781
3031
|
`);
|
|
1782
|
-
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(
|
|
1783
|
-
await handleDebugSessionSetName(sessionId, name, cmd.optsWithGlobals());
|
|
1784
|
-
}).addHelpText("after", `
|
|
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", `
|
|
1785
3033
|
Examples:
|
|
1786
3034
|
$ lmnr-cli debug session set-name <session-id> "Fix report length + search tool"
|
|
1787
3035
|
`);
|
|
1788
|
-
debugSessionCmd.command("summary").description("Print every trace in a debug session with its note, oldest first").argument("<session-id>", "Debug session ID").action(
|
|
1789
|
-
await handleDebugSessionSummary(sessionId, cmd.optsWithGlobals());
|
|
1790
|
-
}).addHelpText("after", `
|
|
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", `
|
|
1791
3037
|
Output is one block per trace (oldest first), the trace's note followed by a
|
|
1792
3038
|
self-closing tag carrying the trace id and end time:
|
|
1793
3039
|
|
|
@@ -1802,17 +3048,21 @@ Examples:
|
|
|
1802
3048
|
`);
|
|
1803
3049
|
program.addHelpText("after", `
|
|
1804
3050
|
Authentication:
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
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.
|
|
1809
3056
|
|
|
1810
3057
|
Examples:
|
|
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
|
|
1811
3062
|
lmnr-cli dataset list --json # List all datasets
|
|
1812
3063
|
lmnr-cli dataset push data.jsonl -n my-dataset --json # Push data to a dataset
|
|
1813
3064
|
lmnr-cli dataset pull output.jsonl -n my-dataset --json # Pull data from a dataset
|
|
1814
3065
|
lmnr-cli sql query "SELECT * FROM spans LIMIT 10" --json # Query spans
|
|
1815
|
-
lmnr-cli sql query "SELECT t.id, s.name FROM traces t JOIN spans s ON t.id = s.trace_id" --json
|
|
1816
3066
|
lmnr-cli sql schema # Show available tables
|
|
1817
3067
|
lmnr-cli trace append-note <trace-id> "note text" # Append a note to a trace
|
|
1818
3068
|
lmnr-cli debug session set-name <session-id> "title" # Rename a debug session
|
|
@@ -1820,7 +3070,6 @@ Examples:
|
|
|
1820
3070
|
|
|
1821
3071
|
For more information about the Laminar platfrom:
|
|
1822
3072
|
Documentation: https://laminar.sh/docs
|
|
1823
|
-
Dashboard: https://www.laminar.sh
|
|
1824
3073
|
`);
|
|
1825
3074
|
await program.parseAsync();
|
|
1826
3075
|
}
|