@levr-one/cli 0.11.7 → 0.11.9
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/{addHandler-BqLLDeKp.js → addHandler-B_3UmMbj.js} +22 -140
- package/dist/cli.js +25 -14
- package/dist/find-workspace-T4xuXwtS.js +147 -0
- package/dist/{loginHandler-DzyfdKc2.js → loginHandler-DE0L1ehF.js} +55 -15
- package/package.json +1 -1
- /package/dist/{appendHandler-9WDDNkkA.js → appendHandler-KVsJMxpX.js} +0 -0
- /package/dist/{currentHandler-B_sWXNeW.js → currentHandler-u89l068v.js} +0 -0
- /package/dist/{importHandler-D2_Ol-Vp.js → importHandler-zw5GKb-S.js} +0 -0
- /package/dist/{listHandler-hmLz8zm8.js → listHandler-YA-0RkwS.js} +0 -0
- /package/dist/{logoutHandler-TSQlZGpU.js → logoutHandler-S4TFYFwz.js} +0 -0
- /package/dist/{pushHandler-DRFf9Zeu.js → pushHandler-DdNyVQ5t.js} +0 -0
- /package/dist/{runHandler-9tfiD3ZK.js → runHandler-BFDjtxbt.js} +0 -0
- /package/dist/{selectHandler-DNb1KJvM.js → selectHandler-Cejme5nY.js} +0 -0
- /package/dist/{statusHandler-B_xmACZl.js → statusHandler-BNiWlC7e.js} +0 -0
|
@@ -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:
|
|
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:
|
|
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-
|
|
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
|
|
230
|
-
levr auth login --device-code
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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.
|
|
890
|
+
var version = "0.11.9";
|
|
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
|
|
234
|
-
*
|
|
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
|
-
|
|
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
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|