@levr-one/cli 0.11.7 → 0.11.8

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.
@@ -4,12 +4,13 @@ import "./token-refresh-DeusLK8H.js";
4
4
  import { resolveToken } from "./resolve-token-7xb7kG7h.js";
5
5
  import "./workspace-store-CnrxyYRB.js";
6
6
  import { fetchSites } from "./resolve-workspace-CStHisUb.js";
7
+ import { describeWorkspaces, findWorkspaceByKeyOrName, isScopedMcpUrl, resolveMcpUrl, scopedMcpUrl, servedBySessionApi } from "./find-workspace-T4xuXwtS.js";
7
8
  import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
8
9
  import { basename, delimiter, dirname, join, resolve } from "node:path";
9
10
  import { homedir } from "node:os";
10
- import { execFileSync } from "node:child_process";
11
11
  import { applyEdits, modify, parse, printParseErrorCode } from "jsonc-parser";
12
12
  import { TomlError, parse as parse$1 } from "smol-toml";
13
+ import { execFileSync } from "node:child_process";
13
14
 
14
15
  //#region ../mcp-harnesses/dist/format/toml-render.js
15
16
  /**
@@ -2104,140 +2105,6 @@ function writeConfigAtomic(path$1, text) {
2104
2105
  }
2105
2106
  }
2106
2107
 
2107
- //#endregion
2108
- //#region src/mcp/url.ts
2109
- const KNOWN_MCP_URLS = {
2110
- "api.levr.one": "https://ai.levr.one/api/v1/mcp",
2111
- "api.levr.now": "https://ai.levr.now/api/v1/mcp"
2112
- };
2113
- /**
2114
- * Resolve the MCP server URL: `--url` flag > `LEVR_MCP_URL` env > derived
2115
- * from the resolved API URL (which itself honors `LEVR_URL` > the URL stored
2116
- * at login > production default, ENG-2361). Known Levr hosts map to their
2117
- * app-host MCP resource; anything else (localhost dev stacks, custom
2118
- * deployments) derives `<api-url>/v1/mcp`.
2119
- */
2120
- function resolveMcpUrl(flagUrl) {
2121
- const resolved = resolveRaw(flagUrl);
2122
- assertUsableMcpUrl(resolved.url, resolved.source);
2123
- return resolved;
2124
- }
2125
- function resolveRaw(flagUrl) {
2126
- if (flagUrl) return {
2127
- url: stripSlash(flagUrl),
2128
- source: "flag"
2129
- };
2130
- const envVar = process.env["LEVR_MCP_URL"];
2131
- if (envVar) return {
2132
- url: stripSlash(envVar),
2133
- source: "env:LEVR_MCP_URL"
2134
- };
2135
- const apiUrl = getApiUrl();
2136
- return {
2137
- url: knownMcpUrl(apiUrl) ?? `${apiUrl}/v1/mcp`,
2138
- source: `derived:${apiUrl}`
2139
- };
2140
- }
2141
- /** Name the input the user has to change, not our internal source tag. */
2142
- function sourceLabel(source) {
2143
- if (source === "flag") return "--url";
2144
- if (source === "env:LEVR_MCP_URL") return "LEVR_MCP_URL";
2145
- return `the API URL (${source.slice(8)})`;
2146
- }
2147
- /**
2148
- * Reject a URL that must not reach a client config or a spawned CLI (ENG-4159).
2149
- *
2150
- * Three things this stops:
2151
- *
2152
- * 1. **No scheme.** `claude mcp add --transport http --scope user levr <value>`
2153
- * is spawned with fixed argv since ENG-4156, so a value like `--foo` is read
2154
- * by that CLI as a FLAG rather than as its URL operand — argument injection.
2155
- * Requiring a parseable absolute URL removes the shape entirely.
2156
- * 2. **A non-http(s) scheme.** `file:` / `javascript:` is never a valid MCP
2157
- * endpoint, but would be written verbatim into every client's config.
2158
- * 3. **Embedded credentials.** `https://user:pass@host` would be written into
2159
- * a config file — and since ENG-4151 a config file can be `--scope project`,
2160
- * which the user is told to COMMIT. That turns a bad URL into a secret in
2161
- * git history. MCP authenticates via OAuth in the browser; credentials in
2162
- * the URL are never needed.
2163
- */
2164
- function assertUsableMcpUrl(url, source) {
2165
- const from = sourceLabel(source);
2166
- let parsed;
2167
- try {
2168
- parsed = new URL(url);
2169
- } catch {
2170
- throw new Error(`Invalid MCP URL from ${from}: ${JSON.stringify(url)} is not a URL. Expected an absolute http(s) URL, e.g. https://ai.levr.one/api/v1/mcp`);
2171
- }
2172
- if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new Error(`Invalid MCP URL from ${from}: ${JSON.stringify(url)} uses "${parsed.protocol}" — only http and https are supported.`);
2173
- if (!parsed.hostname) throw new Error(`Invalid MCP URL from ${from}: ${JSON.stringify(url)} has no host.`);
2174
- if (parsed.username || parsed.password) throw new Error(`Invalid MCP URL from ${from}: ${JSON.stringify(url)} embeds credentials. They would be written into client config files — including project-scoped ones you are told to commit. Levr authenticates in the browser; remove the user:password@ prefix.`);
2175
- }
2176
- /**
2177
- * Is this MCP URL served by the API the CLI is logged in to? `--workspace`
2178
- * and the workspace picker read that API's workspaces, so pinning a URL on
2179
- * another server to one of them would name the wrong workspace, or none
2180
- * (ENG-6300 D6).
2181
- */
2182
- function servedBySessionApi(mcpUrl) {
2183
- const apiUrl = getApiUrl();
2184
- const derived = knownMcpUrl(apiUrl) ?? `${apiUrl}/v1/mcp`;
2185
- try {
2186
- return new URL(mcpUrl).origin === new URL(derived).origin;
2187
- } catch {
2188
- return false;
2189
- }
2190
- }
2191
- /** `…/v1/mcp/w/<url_key>`: the URL already names one workspace (ENG-6300). */
2192
- const SCOPED_SUFFIX = /\/w\/[^/]+$/;
2193
- /** Does this MCP URL already name a workspace? */
2194
- function isScopedMcpUrl(url) {
2195
- return SCOPED_SUFFIX.test(stripSlash(url));
2196
- }
2197
- /**
2198
- * The MCP URL for one workspace, `<base>/w/<url_key>` (ENG-6300 D6). A base
2199
- * that already names a workspace is an error rather than a silent re-point:
2200
- * `--url …/w/a --workspace b` asks for two different workspaces at once.
2201
- */
2202
- function scopedMcpUrl(base, urlKey) {
2203
- const root = stripSlash(base);
2204
- if (isScopedMcpUrl(root)) throw new Error(`The MCP URL ${root} already names a workspace. Drop --workspace, or pass the URL without its /w/<url_key> suffix.`);
2205
- return `${root}/w/${encodeURIComponent(urlKey)}`;
2206
- }
2207
- function knownMcpUrl(apiUrl) {
2208
- try {
2209
- return KNOWN_MCP_URLS[new URL(apiUrl).host];
2210
- } catch {
2211
- return;
2212
- }
2213
- }
2214
- function stripSlash(url) {
2215
- return url.replace(/\/+$/, "");
2216
- }
2217
-
2218
- //#endregion
2219
- //#region src/workspace/find-workspace.ts
2220
- /** ` - Name (url_key)` per workspace, for error messages and hints. */
2221
- function describeWorkspaces(sites) {
2222
- return sites.map((s) => ` - ${s.workspace_name} (${s.workspace_url_key})`).join("\n");
2223
- }
2224
- /**
2225
- * An exact `url_key` wins; otherwise a case-insensitive name that matches
2226
- * exactly one workspace. Anything else throws, listing the choices.
2227
- */
2228
- function findWorkspaceByKeyOrName(sites, input) {
2229
- const wanted = input.trim();
2230
- if (!wanted) throw new Error("--workspace needs a workspace name or url_key.");
2231
- const byKey = sites.find((s) => s.workspace_url_key === wanted);
2232
- if (byKey) return byKey;
2233
- const lower = wanted.toLowerCase();
2234
- const byName = sites.filter((s) => s.workspace_name.toLowerCase() === lower);
2235
- if (byName.length === 1) return byName[0];
2236
- if (byName.length > 1) throw new Error(`"${wanted}" names more than one workspace. Pass its url_key instead:\n` + describeWorkspaces(byName));
2237
- if (sites.length === 0) throw new Error("Your account has no workspaces.");
2238
- throw new Error(`No workspace "${wanted}" in your account. Choose one of:\n` + describeWorkspaces(sites));
2239
- }
2240
-
2241
2108
  //#endregion
2242
2109
  //#region src/mcp/run.ts
2243
2110
  /**
@@ -2541,22 +2408,37 @@ function depsFor(entry) {
2541
2408
  };
2542
2409
  }
2543
2410
  /**
2411
+ * The login that makes `--workspace <x>` work. With the hint, the login
2412
+ * skips the workspace picker for that workspace and selects it (ENG-6694).
2413
+ * Double quotes: the one quoting POSIX shells and cmd.exe both take.
2414
+ */
2415
+ function loginCommandFor(workspace) {
2416
+ if (workspace === void 0) return "levr auth login";
2417
+ return `levr auth login --workspace ${/^[A-Za-z0-9_.@:/-]+$/.test(workspace) ? workspace : `"${workspace.replace(/"/g, "\\\"")}"`}`;
2418
+ }
2419
+ /**
2544
2420
  * The workspaces of the logged-in user. Reads the session only; it never
2545
- * reads or changes the workspace the CLI has selected.
2421
+ * reads or changes the workspace the CLI has selected. `workspace` is the
2422
+ * `--workspace` value, for the refusal to name the exact login to run.
2546
2423
  */
2547
- async function loadSites() {
2424
+ async function loadSites(workspace) {
2548
2425
  let auth;
2549
2426
  try {
2550
2427
  auth = await resolveToken();
2551
2428
  } catch (err) {
2429
+ const cause = err instanceof Error ? err.message : "Not authenticated. Run 'levr auth login' first.";
2430
+ if (workspace === void 0) return {
2431
+ ok: false,
2432
+ error: cause
2433
+ };
2552
2434
  return {
2553
2435
  ok: false,
2554
- error: err instanceof Error ? err.message : "Not authenticated. Run 'levr auth login' first."
2436
+ error: `${cause.replace(" or set LEVR_TOKEN environment variable", "")}\nTo pin this entry, log in naming the workspace: ${loginCommandFor(workspace)} (add --device-code on a headless machine), then re-run this command.`
2555
2437
  };
2556
2438
  }
2557
2439
  if (auth.type === "pat") return {
2558
2440
  ok: false,
2559
- error: "--workspace needs a login session, not a personal access token. Run 'levr auth login'."
2441
+ error: `--workspace needs a login session, not a personal access token. Run '${loginCommandFor(workspace)}'.`
2560
2442
  };
2561
2443
  configureClient(auth);
2562
2444
  try {
@@ -2621,7 +2503,7 @@ async function mcpAddHandler(flags) {
2621
2503
  this.process.exitCode = 1;
2622
2504
  return;
2623
2505
  }
2624
- const loaded = await loadSites();
2506
+ const loaded = await loadSites(flags.workspace);
2625
2507
  if (!loaded.ok) {
2626
2508
  this.logger.error(loaded.error);
2627
2509
  this.process.exitCode = 1;
package/dist/cli.js CHANGED
@@ -209,7 +209,7 @@ Examples:
209
209
  aliases: { y: "yes" }
210
210
  },
211
211
  loader: async () => {
212
- const { mcpAddHandler } = await import("./addHandler-BqLLDeKp.js");
212
+ const { mcpAddHandler } = await import("./addHandler-B_3UmMbj.js");
213
213
  return mcpAddHandler;
214
214
  }
215
215
  });
@@ -225,9 +225,13 @@ By default, opens a browser for PKCE-based authentication.
225
225
  For headless environments (SSH, containers), use --device-code
226
226
  to authenticate via a code displayed in the terminal.
227
227
 
228
+ With --workspace, the login selects that workspace for the CLI, and a
229
+ url_key also skips the workspace picker in the browser.
230
+
228
231
  Examples:
229
- levr auth login # Browser-based PKCE login
230
- levr auth login --device-code # Device flow for SSH/headless`
232
+ levr auth login # Browser-based PKCE login
233
+ levr auth login --device-code # Device flow for SSH/headless
234
+ levr auth login --workspace acme # Skip the picker; select acme`
231
235
  },
232
236
  parameters: {
233
237
  flags: {
@@ -242,12 +246,19 @@ Examples:
242
246
  brief: "API base URL (default: https://api.levr.one)",
243
247
  placeholder: "url",
244
248
  optional: true
249
+ },
250
+ workspace: {
251
+ kind: "parsed",
252
+ parse: String,
253
+ brief: "Workspace to select, by url_key or name (a url_key also skips the workspace picker)",
254
+ placeholder: "url_key|name",
255
+ optional: true
245
256
  }
246
257
  },
247
258
  aliases: { d: "device-code" }
248
259
  },
249
260
  loader: async () => {
250
- const { loginHandler } = await import("./loginHandler-DzyfdKc2.js");
261
+ const { loginHandler } = await import("./loginHandler-DE0L1ehF.js");
251
262
  return loginHandler;
252
263
  }
253
264
  });
@@ -266,7 +277,7 @@ Examples:
266
277
  },
267
278
  parameters: {},
268
279
  loader: async () => {
269
- const { logoutHandler } = await import("./logoutHandler-TSQlZGpU.js");
280
+ const { logoutHandler } = await import("./logoutHandler-S4TFYFwz.js");
270
281
  return logoutHandler;
271
282
  }
272
283
  });
@@ -286,7 +297,7 @@ Examples:
286
297
  },
287
298
  parameters: {},
288
299
  loader: async () => {
289
- const { statusHandler } = await import("./statusHandler-B_xmACZl.js");
300
+ const { statusHandler } = await import("./statusHandler-BNiWlC7e.js");
290
301
  return statusHandler;
291
302
  }
292
303
  });
@@ -415,7 +426,7 @@ levr attach --manifest or later uploads.`
415
426
  }
416
427
  },
417
428
  loader: async () => {
418
- const { pushHandler } = await import("./pushHandler-DRFf9Zeu.js");
429
+ const { pushHandler } = await import("./pushHandler-DdNyVQ5t.js");
419
430
  return pushHandler;
420
431
  }
421
432
  });
@@ -535,7 +546,7 @@ Examples:
535
546
  }
536
547
  },
537
548
  loader: async () => {
538
- const { importHandler } = await import("./importHandler-D2_Ol-Vp.js");
549
+ const { importHandler } = await import("./importHandler-zw5GKb-S.js");
539
550
  return importHandler;
540
551
  }
541
552
  });
@@ -556,7 +567,7 @@ Examples:
556
567
  },
557
568
  parameters: {},
558
569
  loader: async () => {
559
- const { listHandler } = await import("./listHandler-hmLz8zm8.js");
570
+ const { listHandler } = await import("./listHandler-YA-0RkwS.js");
560
571
  return listHandler;
561
572
  }
562
573
  });
@@ -594,7 +605,7 @@ Examples:
594
605
  flags: {}
595
606
  },
596
607
  loader: async () => {
597
- const { selectHandler } = await import("./selectHandler-DNb1KJvM.js");
608
+ const { selectHandler } = await import("./selectHandler-Cejme5nY.js");
598
609
  return selectHandler;
599
610
  }
600
611
  });
@@ -611,7 +622,7 @@ Examples:
611
622
  },
612
623
  parameters: {},
613
624
  loader: async () => {
614
- const { currentHandler } = await import("./currentHandler-B_sWXNeW.js");
625
+ const { currentHandler } = await import("./currentHandler-u89l068v.js");
615
626
  return currentHandler;
616
627
  }
617
628
  });
@@ -680,7 +691,7 @@ Examples:
680
691
  }
681
692
  },
682
693
  loader: async () => {
683
- const { gatesRunHandler } = await import("./runHandler-9tfiD3ZK.js");
694
+ const { gatesRunHandler } = await import("./runHandler-BFDjtxbt.js");
684
695
  return gatesRunHandler;
685
696
  }
686
697
  });
@@ -869,14 +880,14 @@ Examples:
869
880
  }
870
881
  },
871
882
  loader: async () => {
872
- const { resultAppendHandler } = await import("./appendHandler-9WDDNkkA.js");
883
+ const { resultAppendHandler } = await import("./appendHandler-KVsJMxpX.js");
873
884
  return resultAppendHandler;
874
885
  }
875
886
  });
876
887
 
877
888
  //#endregion
878
889
  //#region package.json
879
- var version = "0.11.7";
890
+ var version = "0.11.8";
880
891
 
881
892
  //#endregion
882
893
  //#region src/app.ts
@@ -0,0 +1,147 @@
1
+ import { getApiUrl } from "./env-CdwyPHGV.js";
2
+
3
+ //#region src/mcp/url.ts
4
+ const KNOWN_MCP_URLS = {
5
+ "api.levr.one": "https://ai.levr.one/api/v1/mcp",
6
+ "api.levr.now": "https://ai.levr.now/api/v1/mcp"
7
+ };
8
+ /**
9
+ * Resolve the MCP server URL: `--url` flag > `LEVR_MCP_URL` env > derived
10
+ * from the resolved API URL (which itself honors `LEVR_URL` > the URL stored
11
+ * at login > production default, ENG-2361). Known Levr hosts map to their
12
+ * app-host MCP resource; anything else (localhost dev stacks, custom
13
+ * deployments) derives `<api-url>/v1/mcp`.
14
+ */
15
+ function resolveMcpUrl(flagUrl) {
16
+ const resolved = resolveRaw(flagUrl);
17
+ assertUsableMcpUrl(resolved.url, resolved.source);
18
+ return resolved;
19
+ }
20
+ function resolveRaw(flagUrl) {
21
+ if (flagUrl) return {
22
+ url: stripSlash(flagUrl),
23
+ source: "flag"
24
+ };
25
+ const envVar = process.env["LEVR_MCP_URL"];
26
+ if (envVar) return {
27
+ url: stripSlash(envVar),
28
+ source: "env:LEVR_MCP_URL"
29
+ };
30
+ const apiUrl = getApiUrl();
31
+ return {
32
+ url: knownMcpUrl(apiUrl) ?? `${apiUrl}/v1/mcp`,
33
+ source: `derived:${apiUrl}`
34
+ };
35
+ }
36
+ /** Name the input the user has to change, not our internal source tag. */
37
+ function sourceLabel(source) {
38
+ if (source === "flag") return "--url";
39
+ if (source === "env:LEVR_MCP_URL") return "LEVR_MCP_URL";
40
+ return `the API URL (${source.slice(8)})`;
41
+ }
42
+ /**
43
+ * Reject a URL that must not reach a client config or a spawned CLI (ENG-4159).
44
+ *
45
+ * Three things this stops:
46
+ *
47
+ * 1. **No scheme.** `claude mcp add --transport http --scope user levr <value>`
48
+ * is spawned with fixed argv since ENG-4156, so a value like `--foo` is read
49
+ * by that CLI as a FLAG rather than as its URL operand — argument injection.
50
+ * Requiring a parseable absolute URL removes the shape entirely.
51
+ * 2. **A non-http(s) scheme.** `file:` / `javascript:` is never a valid MCP
52
+ * endpoint, but would be written verbatim into every client's config.
53
+ * 3. **Embedded credentials.** `https://user:pass@host` would be written into
54
+ * a config file — and since ENG-4151 a config file can be `--scope project`,
55
+ * which the user is told to COMMIT. That turns a bad URL into a secret in
56
+ * git history. MCP authenticates via OAuth in the browser; credentials in
57
+ * the URL are never needed.
58
+ */
59
+ function assertUsableMcpUrl(url, source) {
60
+ const from = sourceLabel(source);
61
+ let parsed;
62
+ try {
63
+ parsed = new URL(url);
64
+ } catch {
65
+ throw new Error(`Invalid MCP URL from ${from}: ${JSON.stringify(url)} is not a URL. Expected an absolute http(s) URL, e.g. https://ai.levr.one/api/v1/mcp`);
66
+ }
67
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new Error(`Invalid MCP URL from ${from}: ${JSON.stringify(url)} uses "${parsed.protocol}" — only http and https are supported.`);
68
+ if (!parsed.hostname) throw new Error(`Invalid MCP URL from ${from}: ${JSON.stringify(url)} has no host.`);
69
+ if (parsed.username || parsed.password) throw new Error(`Invalid MCP URL from ${from}: ${JSON.stringify(url)} embeds credentials. They would be written into client config files — including project-scoped ones you are told to commit. Levr authenticates in the browser; remove the user:password@ prefix.`);
70
+ }
71
+ /**
72
+ * Is this MCP URL served by the API the CLI is logged in to? `--workspace`
73
+ * and the workspace picker read that API's workspaces, so pinning a URL on
74
+ * another server to one of them would name the wrong workspace, or none
75
+ * (ENG-6300 D6).
76
+ */
77
+ function servedBySessionApi(mcpUrl) {
78
+ const apiUrl = getApiUrl();
79
+ const derived = knownMcpUrl(apiUrl) ?? `${apiUrl}/v1/mcp`;
80
+ try {
81
+ return new URL(mcpUrl).origin === new URL(derived).origin;
82
+ } catch {
83
+ return false;
84
+ }
85
+ }
86
+ /** `…/v1/mcp/w/<url_key>`: the URL already names one workspace (ENG-6300). */
87
+ const SCOPED_SUFFIX = /\/w\/[^/]+$/;
88
+ /** Does this MCP URL already name a workspace? */
89
+ function isScopedMcpUrl(url) {
90
+ return SCOPED_SUFFIX.test(stripSlash(url));
91
+ }
92
+ /**
93
+ * The MCP URL for one workspace, `<base>/w/<url_key>` (ENG-6300 D6). A base
94
+ * that already names a workspace is an error rather than a silent re-point:
95
+ * `--url …/w/a --workspace b` asks for two different workspaces at once.
96
+ */
97
+ function scopedMcpUrl(base, urlKey) {
98
+ const root = stripSlash(base);
99
+ if (isScopedMcpUrl(root)) throw new Error(`The MCP URL ${root} already names a workspace. Drop --workspace, or pass the URL without its /w/<url_key> suffix.`);
100
+ return `${root}/w/${encodeURIComponent(urlKey)}`;
101
+ }
102
+ function knownMcpUrl(apiUrl) {
103
+ try {
104
+ return KNOWN_MCP_URLS[new URL(apiUrl).host];
105
+ } catch {
106
+ return;
107
+ }
108
+ }
109
+ function stripSlash(url) {
110
+ return url.replace(/\/+$/, "");
111
+ }
112
+
113
+ //#endregion
114
+ //#region src/workspace/find-workspace.ts
115
+ /** The url_key syntax the backend and auth-web accept (reserved keys aside). */
116
+ const URL_KEY_SYNTAX = /^[a-z0-9][a-z0-9-]*[a-z0-9]$/;
117
+ /**
118
+ * Could this `--workspace` value be a url_key? Only a url_key can name a
119
+ * workspace before a session exists, so only one can skip the login picker
120
+ * (ENG-6694); a name is resolved once the token has arrived.
121
+ */
122
+ function looksLikeUrlKey(input) {
123
+ return input.length <= 100 && URL_KEY_SYNTAX.test(input);
124
+ }
125
+ /** ` - Name (url_key)` per workspace, for error messages and hints. */
126
+ function describeWorkspaces(sites) {
127
+ return sites.map((s) => ` - ${s.workspace_name} (${s.workspace_url_key})`).join("\n");
128
+ }
129
+ /**
130
+ * An exact `url_key` wins; otherwise a case-insensitive name that matches
131
+ * exactly one workspace. Anything else throws, listing the choices.
132
+ */
133
+ function findWorkspaceByKeyOrName(sites, input) {
134
+ const wanted = input.trim();
135
+ if (!wanted) throw new Error("--workspace needs a workspace name or url_key.");
136
+ const byKey = sites.find((s) => s.workspace_url_key === wanted);
137
+ if (byKey) return byKey;
138
+ const lower = wanted.toLowerCase();
139
+ const byName = sites.filter((s) => s.workspace_name.toLowerCase() === lower);
140
+ if (byName.length === 1) return byName[0];
141
+ if (byName.length > 1) throw new Error(`"${wanted}" names more than one workspace. Pass its url_key instead:\n` + describeWorkspaces(byName));
142
+ if (sites.length === 0) throw new Error("Your account has no workspaces.");
143
+ throw new Error(`No workspace "${wanted}" in your account. Choose one of:\n` + describeWorkspaces(sites));
144
+ }
145
+
146
+ //#endregion
147
+ export { describeWorkspaces, findWorkspaceByKeyOrName, isScopedMcpUrl, looksLikeUrlKey, resolveMcpUrl, scopedMcpUrl, servedBySessionApi };
@@ -1,12 +1,13 @@
1
1
  import { configureClient } from "./sdk-client-DYv-0CfA.js";
2
2
  import { CLI_CLIENT_ID, getApiUrl, getAuthUrl, setSessionApiUrl, writeCredentials } from "./env-CdwyPHGV.js";
3
- import "./workspace-store-CnrxyYRB.js";
4
- import { autoSelectWorkspace } from "./resolve-workspace-CStHisUb.js";
3
+ import { saveWorkspace } from "./workspace-store-CnrxyYRB.js";
4
+ import { autoSelectWorkspace, fetchSites } from "./resolve-workspace-CStHisUb.js";
5
5
  import { sleep } from "./sleep-BpJ39Ypm.js";
6
+ import { findWorkspaceByKeyOrName, looksLikeUrlKey, resolveMcpUrl, scopedMcpUrl } from "./find-workspace-T4xuXwtS.js";
6
7
  import chalk from "chalk";
7
8
  import { createHash, randomBytes } from "node:crypto";
8
9
  import { createServer } from "node:http";
9
- import { URL } from "node:url";
10
+ import { URL as URL$1 } from "node:url";
10
11
 
11
12
  //#region src/auth/pkce.ts
12
13
  /**
@@ -103,7 +104,7 @@ function startCallbackServer(options = {}) {
103
104
  rejectCode = reject;
104
105
  });
105
106
  const server = createServer((req, res) => {
106
- const url = new URL(req.url ?? "/", `http://127.0.0.1`);
107
+ const url = new URL$1(req.url ?? "/", `http://127.0.0.1`);
107
108
  if (url.pathname === "/callback") {
108
109
  const code = url.searchParams.get("code");
109
110
  const error = url.searchParams.get("error");
@@ -225,20 +226,21 @@ function formatCountdown(ms) {
225
226
  async function loginHandler(flags) {
226
227
  await performLogin(this, {
227
228
  deviceCode: flags["device-code"],
228
- url: flags.url
229
+ url: flags.url,
230
+ workspace: flags.workspace
229
231
  });
230
232
  }
231
233
  /**
232
234
  * Run the login flow (PKCE or device code). Reusable outside the Stricli
233
- * handler (levr init composes it). Returns true on success; on failure the
234
- * error has been printed and `exitCode` set.
235
+ * handler. Returns true on success; on failure the error has been printed
236
+ * and `exitCode` set.
235
237
  */
236
238
  async function performLogin(ctx, options) {
237
239
  if (options.url) setSessionApiUrl(options.url);
238
- if (options.deviceCode) return deviceCodeLogin(ctx);
239
- return pkceLogin(ctx);
240
+ if (options.deviceCode) return deviceCodeLogin(ctx, options.workspace);
241
+ return pkceLogin(ctx, options.workspace);
240
242
  }
241
- async function pkceLogin(ctx) {
243
+ async function pkceLogin(ctx, workspace) {
242
244
  const apiUrl = getApiUrl();
243
245
  let authUrl;
244
246
  try {
@@ -248,6 +250,12 @@ async function pkceLogin(ctx) {
248
250
  ctx.process.exitCode = 1;
249
251
  return false;
250
252
  }
253
+ let resource;
254
+ if (workspace !== void 0 && looksLikeUrlKey(workspace)) try {
255
+ resource = scopedMcpUrl(resolveMcpUrl().url, workspace);
256
+ } catch (err) {
257
+ ctx.logger.debug(`Workspace picker not skipped: ${err instanceof Error ? err.message : String(err)}`);
258
+ }
251
259
  const codeVerifier = generateCodeVerifier();
252
260
  const codeChallenge = generateCodeChallenge(codeVerifier);
253
261
  const state = randomBytes(16).toString("hex");
@@ -265,6 +273,7 @@ async function pkceLogin(ctx) {
265
273
  scope: "read:own write:own",
266
274
  state
267
275
  });
276
+ if (resource) params.append("resource", resource);
268
277
  const authorizeUrl = `${authUrl}/auth/oauth/authorize?${params.toString()}`;
269
278
  ctx.process.stdout.write("Opening browser to authenticate...\n\n");
270
279
  ctx.process.stdout.write(` If the browser doesn't open, visit:\n ${chalk.cyan(authorizeUrl)}\n\n`);
@@ -306,7 +315,7 @@ async function pkceLogin(ctx) {
306
315
  const errBody = await tokenRes.text();
307
316
  throw new Error(`Token exchange failed (${tokenRes.status}): ${errBody}`);
308
317
  }
309
- return await saveTokensAndFinish(ctx, await tokenRes.json());
318
+ return await saveTokensAndFinish(ctx, await tokenRes.json(), workspace);
310
319
  } catch (err) {
311
320
  ctx.process.stdout.write("\n");
312
321
  ctx.logger.error(err instanceof Error ? err.message : "Token exchange failed.");
@@ -314,10 +323,16 @@ async function pkceLogin(ctx) {
314
323
  return false;
315
324
  }
316
325
  }
317
- async function deviceCodeLogin(ctx) {
326
+ async function deviceCodeLogin(ctx, workspace) {
318
327
  try {
319
328
  const device = await requestDeviceCode();
320
- ctx.process.stdout.write(`\nVisit: ${chalk.cyan(device.verification_uri)}\n`);
329
+ let visit = device.verification_uri;
330
+ if (workspace !== void 0 && looksLikeUrlKey(workspace)) try {
331
+ const url = new URL(device.verification_uri);
332
+ url.searchParams.set("workspace", workspace);
333
+ visit = url.toString();
334
+ } catch {}
335
+ ctx.process.stdout.write(`\nVisit: ${chalk.cyan(visit)}\n`);
321
336
  ctx.process.stdout.write(`Enter code: ${chalk.bold.yellow(device.user_code)}\n\n`);
322
337
  const deviceDeadline = Date.now() + device.expires_in * 1e3;
323
338
  const deviceTimer = setInterval(() => {
@@ -337,7 +352,7 @@ async function deviceCodeLogin(ctx) {
337
352
  throw err;
338
353
  }
339
354
  clearInterval(deviceTimer);
340
- return await saveTokensAndFinish(ctx, tokenData);
355
+ return await saveTokensAndFinish(ctx, tokenData, workspace);
341
356
  } catch (err) {
342
357
  ctx.process.stdout.write("\n");
343
358
  ctx.logger.error(err instanceof Error ? err.message : "Device flow failed.");
@@ -345,7 +360,7 @@ async function deviceCodeLogin(ctx) {
345
360
  return false;
346
361
  }
347
362
  }
348
- async function saveTokensAndFinish(ctx, tokenData) {
363
+ async function saveTokensAndFinish(ctx, tokenData, workspace) {
349
364
  const apiUrl = getApiUrl();
350
365
  configureClient({
351
366
  token: tokenData.access_token,
@@ -358,6 +373,26 @@ async function saveTokensAndFinish(ctx, tokenData) {
358
373
  ctx.process.exitCode = 1;
359
374
  return false;
360
375
  }
376
+ let selected;
377
+ if (workspace !== void 0) {
378
+ let sites;
379
+ try {
380
+ sites = await fetchSites();
381
+ } catch (err) {
382
+ ctx.process.stdout.write("\n");
383
+ ctx.logger.error(`${err instanceof Error ? err.message : "Could not list your workspaces."}\nNothing was saved. Try again.`);
384
+ ctx.process.exitCode = 1;
385
+ return false;
386
+ }
387
+ try {
388
+ selected = findWorkspaceByKeyOrName(sites, workspace);
389
+ } catch (err) {
390
+ ctx.process.stdout.write("\n");
391
+ ctx.logger.error(`${err instanceof Error ? err.message : "Unknown workspace."}\nNothing was saved. Run 'levr auth login --workspace <url_key>' with one of them, or 'levr auth login' to choose later.`);
392
+ ctx.process.exitCode = 1;
393
+ return false;
394
+ }
395
+ }
361
396
  writeCredentials({
362
397
  version: 1,
363
398
  api_url: apiUrl,
@@ -373,6 +408,11 @@ async function saveTokensAndFinish(ctx, tokenData) {
373
408
  ctx.process.stdout.write(` ${chalk.green("done")}\n\n`);
374
409
  ctx.logger.success(`Logged in as ${chalk.bold(user.email)}`);
375
410
  ctx.process.stdout.write("Credentials saved.\n");
411
+ if (selected) {
412
+ saveWorkspace(selected.workspace_id);
413
+ ctx.process.stdout.write(`Workspace: ${chalk.bold(selected.workspace_name)} (${selected.workspace_url_key})\n`);
414
+ return true;
415
+ }
376
416
  const wsResult = await autoSelectWorkspace();
377
417
  if (wsResult.kind === "single") ctx.process.stdout.write(`Workspace: ${chalk.bold(wsResult.workspaceName)}\n`);
378
418
  else if (wsResult.kind === "multiple") ctx.process.stdout.write(`\n${wsResult.count} workspaces available. Run ${chalk.cyan("'levr workspace list'")} to see them.\n`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@levr-one/cli",
3
- "version": "0.11.7",
3
+ "version": "0.11.8",
4
4
  "description": "The command-line interface for Levr",
5
5
  "type": "module",
6
6
  "license": "MIT",