@levr-one/cli 0.7.8 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -2,6 +2,9 @@
2
2
  import { COMPLETION_SHELLS } from "./completionHandler-O2G_WZLf.js";
3
3
  import { buildApplication, buildCommand, buildRouteMap, proposeCompletions, run, text_en } from "@stricli/core";
4
4
  import chalk from "chalk";
5
+ import { existsSync, readFileSync } from "node:fs";
6
+ import { isAbsolute, resolve } from "node:path";
7
+ import { parse } from "dotenv";
5
8
 
6
9
  //#region src/utils/logger.ts
7
10
  var Logger = class {
@@ -210,7 +213,7 @@ Examples:
210
213
  aliases: { d: "device-code" }
211
214
  },
212
215
  loader: async () => {
213
- const { loginHandler } = await import("./loginHandler-BOWFHzAn.js");
216
+ const { loginHandler } = await import("./loginHandler-BE9JX12M.js");
214
217
  return loginHandler;
215
218
  }
216
219
  });
@@ -249,7 +252,7 @@ Examples:
249
252
  },
250
253
  parameters: {},
251
254
  loader: async () => {
252
- const { statusHandler } = await import("./statusHandler-BBTHpyog.js");
255
+ const { statusHandler } = await import("./statusHandler-CyhoTEEI.js");
253
256
  return statusHandler;
254
257
  }
255
258
  });
@@ -361,7 +364,7 @@ Examples:
361
364
  }
362
365
  },
363
366
  loader: async () => {
364
- const { pushHandler } = await import("./pushHandler-BBzX09LW.js");
367
+ const { pushHandler } = await import("./pushHandler-BukCooiv.js");
365
368
  return pushHandler;
366
369
  }
367
370
  });
@@ -382,9 +385,9 @@ Required-field rule: a column must map to test_name or the commit is
382
385
  rejected — interactive mode will ask; non-interactive runs exit 1.
383
386
 
384
387
  Examples:
385
- levr import ./testrail-export.csv --team-id <uuid>
386
- levr import ./cases.xlsx --team-id <uuid> --map "Title=test_name"
387
- levr import --sheets-url "https://docs.google.com/spreadsheets/d/..." --team-id <uuid> --yes
388
+ levr import ./testrail-export.csv --team-key ENG
389
+ levr import ./cases.xlsx --team-key ENG --map "Title=test_name"
390
+ levr import --sheets-url "https://docs.google.com/spreadsheets/d/..." --team-key ENG --yes
388
391
  levr import ./cases.csv --team-id <uuid> --save-mapping mapping.json
389
392
  levr import ./cases.csv --team-id <uuid> --mapping-file mapping.json --yes # CI replay`
390
393
  },
@@ -409,9 +412,16 @@ Examples:
409
412
  "team-id": {
410
413
  kind: "parsed",
411
414
  parse: String,
412
- brief: "Team the imported test cases will belong to",
415
+ brief: "Team doing the import, by UUID. Optional: use --team-key instead, or omit both when the workspace has one team",
413
416
  placeholder: "uuid",
414
- optional: false
417
+ optional: true
418
+ },
419
+ "team-key": {
420
+ kind: "parsed",
421
+ parse: String,
422
+ brief: "Team doing the import, by key (e.g. ENG). Case-insensitive. Alternative to --team-id",
423
+ placeholder: "key",
424
+ optional: true
415
425
  },
416
426
  "sheets-url": {
417
427
  kind: "parsed",
@@ -466,6 +476,7 @@ Examples:
466
476
  aliases: {
467
477
  w: "workspace-id",
468
478
  t: "team-id",
479
+ k: "team-key",
469
480
  f: "format",
470
481
  m: "map",
471
482
  y: "yes",
@@ -473,7 +484,7 @@ Examples:
473
484
  }
474
485
  },
475
486
  loader: async () => {
476
- const { importHandler } = await import("./importHandler--Odfz6sz.js");
487
+ const { importHandler } = await import("./importHandler-zfgrGCyh.js");
477
488
  return importHandler;
478
489
  }
479
490
  });
@@ -494,7 +505,7 @@ Examples:
494
505
  },
495
506
  parameters: {},
496
507
  loader: async () => {
497
- const { listHandler } = await import("./listHandler-CBKYVX8A.js");
508
+ const { listHandler } = await import("./listHandler-B5KqRcYG.js");
498
509
  return listHandler;
499
510
  }
500
511
  });
@@ -527,7 +538,7 @@ Examples:
527
538
  flags: {}
528
539
  },
529
540
  loader: async () => {
530
- const { selectHandler } = await import("./selectHandler-UrTpL_l1.js");
541
+ const { selectHandler } = await import("./selectHandler-CXwxIKL9.js");
531
542
  return selectHandler;
532
543
  }
533
544
  });
@@ -551,7 +562,7 @@ Examples:
551
562
 
552
563
  //#endregion
553
564
  //#region package.json
554
- var version = "0.7.8";
565
+ var version = "0.8.0";
555
566
 
556
567
  //#endregion
557
568
  //#region src/app.ts
@@ -622,9 +633,88 @@ async function proposeCompletionLines(rawArgs, compLine, context) {
622
633
  }
623
634
  }
624
635
 
636
+ //#endregion
637
+ //#region src/utils/load-env-file.ts
638
+ /**
639
+ * Only `LEVR_`-prefixed keys are taken from a `.env` file.
640
+ *
641
+ * `dotenv.config()` would merge EVERY key into `process.env`, which is more
642
+ * than this CLI needs and more than a user consents to by running it in a
643
+ * directory. A checkout's `.env` routinely carries things that change how Node
644
+ * itself behaves — `NODE_TLS_REJECT_UNAUTHORIZED`, `HTTP_PROXY`,
645
+ * `NODE_OPTIONS` — and none of them are ours to apply. Parsing and filtering
646
+ * means an unrelated project's `.env` cannot reach this process at all; only
647
+ * keys deliberately namespaced for this CLI have any effect.
648
+ */
649
+ const LEVR_KEY = /^LEVR_[A-Z0-9_]*$/;
650
+ /**
651
+ * Where to look. `LEVR_ENV_FILE` names an explicit file (absolute, or relative
652
+ * to cwd); otherwise `.env` in the working directory.
653
+ */
654
+ function envFilePath(cwd, env) {
655
+ const explicit = env["LEVR_ENV_FILE"];
656
+ if (explicit) return isAbsolute(explicit) ? explicit : resolve(cwd, explicit);
657
+ return resolve(cwd, ".env");
658
+ }
659
+ /**
660
+ * Merge `LEVR_*` variables from a `.env` file into `process.env`.
661
+ *
662
+ * A real environment variable ALWAYS wins: an already-set key is left alone,
663
+ * so `LEVR_URL=… levr push` still overrides the file, and CI variables are
664
+ * never shadowed by a checked-in `.env`. This matches dotenv's own precedence
665
+ * and is the behaviour anyone who has used a `.env` expects — the file is a
666
+ * default, not an override.
667
+ *
668
+ * Missing or unreadable files are not an error: `.env` is optional by nature,
669
+ * and a CLI that refused to start because a directory has no `.env` would be
670
+ * broken for every user who does not use one. An explicitly requested
671
+ * `LEVR_ENV_FILE` that does not exist IS reported, because naming a file and
672
+ * getting silence is the one case where quiet failure hides a real mistake.
673
+ */
674
+ function loadEnvFile(cwd = process.cwd(), env = process.env) {
675
+ const path = envFilePath(cwd, env);
676
+ const explicitlyRequested = Boolean(env["LEVR_ENV_FILE"]);
677
+ if (!existsSync(path)) {
678
+ if (explicitlyRequested) throw new Error(`LEVR_ENV_FILE points at "${path}", which does not exist.`);
679
+ return {
680
+ path: null,
681
+ applied: []
682
+ };
683
+ }
684
+ let parsed;
685
+ try {
686
+ parsed = parse(readFileSync(path, "utf8"));
687
+ } catch (error) {
688
+ if (explicitlyRequested) throw error;
689
+ return {
690
+ path: null,
691
+ applied: []
692
+ };
693
+ }
694
+ const applied = [];
695
+ for (const [key, value] of Object.entries(parsed)) {
696
+ if (!LEVR_KEY.test(key)) continue;
697
+ if (env[key] !== void 0 && env[key] !== "") continue;
698
+ env[key] = value;
699
+ applied.push(key);
700
+ }
701
+ return {
702
+ path,
703
+ applied
704
+ };
705
+ }
706
+
625
707
  //#endregion
626
708
  //#region src/bin/cli.ts
627
709
  const argv = process.argv.slice(2);
710
+ try {
711
+ loadEnvFile();
712
+ } catch (err) {
713
+ if (argv[0] !== "__complete") {
714
+ process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
715
+ process.exit(1);
716
+ }
717
+ }
628
718
  if (argv[0] === "__complete") for (const line of await proposeCompletionLines(argv, process.env["COMP_LINE"], buildContext(process))) process.stdout.write(`${line}\n`);
629
719
  else {
630
720
  let savedExitCode;
@@ -1,7 +1,7 @@
1
- import { getApiUrl } from "./env-CHeKHu5S.js";
2
- import { configureClient, testCaseImportCommitV1, testCaseImportPreviewV1 } from "./sdk-client-RgFjzRyG.js";
1
+ import { getApiUrl, getTeamId } from "./env-CHeKHu5S.js";
2
+ import { authGetProfileV1, configureClient, teamFindAllV1, testCaseImportCommitV1, testCaseImportPreviewV1 } from "./sdk-client-CATjlmyX.js";
3
3
  import "./workspace-store-DDOxnut1.js";
4
- import { resolveWorkspace } from "./resolve-workspace-Bk3y0q3v.js";
4
+ import { resolveWorkspace } from "./resolve-workspace-NupdkCvd.js";
5
5
  import "./token-refresh-Cu5RpkLJ.js";
6
6
  import { resolveToken } from "./resolve-token-DbQsmn03.js";
7
7
  import chalk from "chalk";
@@ -11,8 +11,167 @@ import { readFile, writeFile } from "node:fs/promises";
11
11
  import { createInterface } from "node:readline/promises";
12
12
  import ora from "ora";
13
13
 
14
+ //#region src/commands/resolve-team.ts
15
+ /**
16
+ * The importing team — NOT the same thing as the `teams` column in the file.
17
+ *
18
+ * `teams` is OWNERSHIP: which teams the imported tests belong to. The
19
+ * importing team is who is doing the import, and the backend uses it as
20
+ * `primaryTeamId` for decisions the file cannot answer:
21
+ *
22
+ * - where an UNQUALIFIED `estimate` column lands (ENG-4396) — a bare
23
+ * `estimate` is not team-neutral, it sizes one specific team;
24
+ * - the guaranteed owner when the file names no teams at all
25
+ * (`ensureMinimumOfOneOwner`);
26
+ * - which team is excluded from the "extra teams" set.
27
+ *
28
+ * So it is not redundant with the file. It IS unreasonable to demand a UUID
29
+ * for it when the file speaks in keys, which is what `--team-key` fixes.
30
+ *
31
+ * Deliberately NOT inferred from the file's first `teams` value. That value
32
+ * decides where an unqualified estimate lands, and row order is a proxy for
33
+ * that question, not an answer to it — a reordered export would silently size
34
+ * a different team. When it cannot be determined, ask.
35
+ */
36
+ async function fetchTeams() {
37
+ const PAGE_SIZE = 200;
38
+ const MAX_PAGES = 25;
39
+ const out = [];
40
+ for (let page = 1; page <= MAX_PAGES; page++) {
41
+ const result = await teamFindAllV1({ query: {
42
+ limit: PAGE_SIZE,
43
+ page
44
+ } });
45
+ if (result.error || !result.data) throw new Error(`Failed to list teams (${describeError(result.error)}).`);
46
+ const body = result.data;
47
+ const rows = body.data ?? [];
48
+ for (const r of rows) if (r.id && r.key) out.push({
49
+ id: r.id,
50
+ key: r.key,
51
+ name: r.name ?? r.key
52
+ });
53
+ const totalPages = body.meta?.totalPages;
54
+ if (rows.length === 0) break;
55
+ if (totalPages !== void 0 ? page >= totalPages : rows.length < PAGE_SIZE) break;
56
+ if (page === MAX_PAGES) process.stderr.write(`[levr] Warning: stopped after ${MAX_PAGES} pages of teams; a --team-key may not resolve.\n`);
57
+ }
58
+ return out;
59
+ }
60
+ /**
61
+ * The caller's ACTIVE team, when it belongs to this workspace.
62
+ *
63
+ * `active_team_id` is the user's HOME team, not "the team they are looking
64
+ * at" — a distinction `apps/client/CLAUDE.md` is emphatic about. That makes it
65
+ * a fair default for "who is importing" and a poor one for anything scoped to
66
+ * a view, so it is used here and reported, never assumed silently.
67
+ *
68
+ * MUST be checked against this workspace's teams. A user can belong to several
69
+ * workspaces while their home team lives in one of them; adopting it unchecked
70
+ * would import into a team that is not in the workspace being imported to.
71
+ *
72
+ * A failure here is not fatal — it just means no default. The explicit flags
73
+ * and the single-team case still work without a profile call succeeding.
74
+ */
75
+ async function activeTeamIn(teams) {
76
+ let activeId;
77
+ try {
78
+ const result = await authGetProfileV1();
79
+ if (result.error || !result.data) return null;
80
+ activeId = result.data.active_team_id ?? void 0;
81
+ } catch {
82
+ return null;
83
+ }
84
+ if (!activeId) return null;
85
+ return teams.some((t) => t.id === activeId) ? activeId : null;
86
+ }
87
+ /**
88
+ * F-012: `JSON.stringify(new Error("boom"))` is `"{}"` — Error's own fields are
89
+ * non-enumerable. The detail that was meant to end the misdirection produced
90
+ * "Failed to list teams ({})", which is worse than saying nothing.
91
+ */
92
+ function describeError(error) {
93
+ if (error === void 0 || error === null) return "no data returned";
94
+ if (error instanceof Error) return error.message;
95
+ if (typeof error === "string") return error;
96
+ try {
97
+ const json = JSON.stringify(error);
98
+ if (json !== void 0 && json !== "{}") return json;
99
+ } catch {}
100
+ return "unserialisable error";
101
+ }
102
+ function describe(teams) {
103
+ return teams.map((t) => ` ${t.key} ${t.name}`).join("\n");
104
+ }
105
+ /**
106
+ * Resolve the importing team from `--team-id`, `--team-key`, or the workspace.
107
+ *
108
+ * Key matching is case-insensitive: the file writes `ENG`, and a user typing
109
+ * `eng` means the same team. Errors list the valid keys, because "no team
110
+ * matched" without saying what would have is the least useful thing this can
111
+ * print.
112
+ */
113
+ async function resolveTeamId(teamId, teamKey, envTeamId) {
114
+ if (teamId && teamKey) throw new Error("Pass either --team-id or --team-key, not both — they can disagree, and there is no sensible winner.");
115
+ if (teamId) return {
116
+ teamId,
117
+ source: "team-id"
118
+ };
119
+ const teams = await fetchTeams();
120
+ if (teams.length === 0) throw new Error("No teams found in this workspace.");
121
+ if (teamKey) {
122
+ const wanted = teamKey.trim().toLowerCase();
123
+ const matches = teams.filter((t) => t.key.toLowerCase() === wanted);
124
+ if (matches.length === 1) return {
125
+ teamId: matches[0].id,
126
+ source: "team-key"
127
+ };
128
+ if (matches.length === 0) throw new Error(`No team with key "${teamKey}" in this workspace. Available:\n${describe(teams)}`);
129
+ throw new Error(`More than one team has key "${teamKey}" in this workspace — refusing to guess.`);
130
+ }
131
+ if (envTeamId) {
132
+ if (teams.some((t) => t.id === envTeamId)) return {
133
+ teamId: envTeamId,
134
+ source: "env"
135
+ };
136
+ throw new Error(`LEVR_TEAM_ID (${envTeamId}) is not a team in this workspace. Available:\n${describe(teams)}`);
137
+ }
138
+ const active = await activeTeamIn(teams);
139
+ if (active) return {
140
+ teamId: active,
141
+ source: "active-team"
142
+ };
143
+ if (teams.length === 1) return {
144
+ teamId: teams[0].id,
145
+ source: "only-team"
146
+ };
147
+ throw new Error(`--team-key or --team-id is required: this workspace has ${teams.length} teams and you have no active team here.\n${describe(teams)}`);
148
+ }
149
+
150
+ //#endregion
14
151
  //#region src/utils/import-mapping.ts
15
- const IMPORT_TARGETS = [
152
+ /**
153
+ * Every import target the CLI will accept for `--map`.
154
+ *
155
+ * ENG-3857 shipped a `teams` target on the server and in the web client, and
156
+ * this list was left behind — so `levr import --map "Team=teams"` threw
157
+ * "Invalid --map target" and team ownership was simply unreachable from the
158
+ * CLI. Nothing caught it: this is the THIRD hand-maintained copy of the
159
+ * target list (backend `IMPORT_TARGET_PROPERTIES`, client
160
+ * `TEST_CASE_KNOWN_PROPERTIES`, here), and while the ELEMENT type is derived
161
+ * from the SDK, TypeScript cannot see that an array literal omits a member of
162
+ * a union — `ImportTarget[]` is satisfied by any subset, including `[]`.
163
+ *
164
+ * So the literal is `as const` and the exhaustiveness assertions below turn a
165
+ * missing target into a COMPILE error naming it, rather than a runtime
166
+ * rejection the user discovers. Adding a target server-side now breaks this
167
+ * build until it is listed here.
168
+ *
169
+ * Caveat worth knowing: `@levr/sdk` resolves to `dist/`, so the union this
170
+ * is checked against is only as fresh as the last SDK build. A regenerated
171
+ * but unbuilt SDK delays detection to the next build — it does not restore
172
+ * the old failure, where the omission was invisible indefinitely.
173
+ */
174
+ const IMPORT_TARGET_LIST = [
16
175
  "row_type",
17
176
  "test_id",
18
177
  "test_key",
@@ -29,6 +188,7 @@ const IMPORT_TARGETS = [
29
188
  "is_automated",
30
189
  "assignee_email",
31
190
  "labels",
191
+ "teams",
32
192
  "attachment_filenames",
33
193
  "data_set_names",
34
194
  "sequence",
@@ -42,6 +202,7 @@ const IMPORT_TARGETS = [
42
202
  "steps",
43
203
  "preconditions"
44
204
  ];
205
+ const IMPORT_TARGETS = IMPORT_TARGET_LIST;
45
206
  const REQUIRED_TARGETS = ["test_name"];
46
207
  /**
47
208
  * Parse a --map value: "Source Column=target", "Source Column=" (drop),
@@ -166,16 +327,33 @@ async function importHandler(flags, file) {
166
327
  this.logger.debug(`API: ${getApiUrl()}`);
167
328
  }
168
329
  if (auth.type === "jwt") {
169
- if (!await resolveWorkspace.call(this, flags["workspace-id"])) {
330
+ let workspace;
331
+ try {
332
+ workspace = await resolveWorkspace.call(this, flags["workspace-id"]);
333
+ } catch (err) {
334
+ this.logger.error(err instanceof Error ? err.message : "Could not resolve the workspace.");
170
335
  this.process.exitCode = 1;
171
336
  return;
172
337
  }
338
+ configureClient(auth, workspace.workspaceId);
339
+ }
340
+ let teamId;
341
+ try {
342
+ const team = await resolveTeamId(flags["team-id"], flags["team-key"], getTeamId());
343
+ teamId = team.teamId;
344
+ if (team.source === "active-team") this.logger.info(`Importing as your active team (${teamId}).`);
345
+ else if (team.source === "env") this.logger.info(`Importing as LEVR_TEAM_ID (${teamId}).`);
346
+ else if (team.source === "only-team") this.logger.info(`Importing as the only team in this workspace.`);
347
+ } catch (err) {
348
+ this.logger.error(err instanceof Error ? err.message : "Could not resolve the team.");
349
+ this.process.exitCode = 1;
350
+ return;
173
351
  }
174
352
  const spinner = ora("Uploading and analyzing the file…").start();
175
353
  let preview;
176
354
  try {
177
355
  const body = {
178
- team_id: flags["team-id"],
356
+ team_id: teamId,
179
357
  format: flags.format,
180
358
  sheets_url: flags["sheets-url"]
181
359
  };
@@ -1,5 +1,5 @@
1
1
  import "./env-CHeKHu5S.js";
2
- import { authGetSitesV1, configureClient } from "./sdk-client-RgFjzRyG.js";
2
+ import { authGetSitesV1, configureClient } from "./sdk-client-CATjlmyX.js";
3
3
  import { loadWorkspace } from "./workspace-store-DDOxnut1.js";
4
4
  import "./token-refresh-Cu5RpkLJ.js";
5
5
  import { resolveToken } from "./resolve-token-DbQsmn03.js";