@levr-one/cli 0.11.6 → 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.
- package/dist/{addHandler-DJqJGqz3.js → addHandler-B_3UmMbj.js} +144 -218
- 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
|
/**
|
|
@@ -773,6 +774,41 @@ function buildServerEntry(harness, mcpUrl, scope = defaultScope(harness), server
|
|
|
773
774
|
args
|
|
774
775
|
} };
|
|
775
776
|
}
|
|
777
|
+
/** The workspace a scoped MCP URL names (`…/w/<url_key>`), if any. */
|
|
778
|
+
function workspaceKeyOf(url) {
|
|
779
|
+
const m = /\/w\/([^/]+)$/.exec(url.replace(/\/+$/, ""));
|
|
780
|
+
if (!m?.[1]) return void 0;
|
|
781
|
+
try {
|
|
782
|
+
return decodeURIComponent(m[1]);
|
|
783
|
+
} catch {
|
|
784
|
+
return m[1];
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
/**
|
|
788
|
+
* Allowed client entry names: 1-64 of `A-Za-z0-9_-`, and never starting with
|
|
789
|
+
* `-`. The name is passed as an argv element to the client's own CLI
|
|
790
|
+
* (`claude mcp add … <name> <url>`), so a leading `-` would be read there as a
|
|
791
|
+
* flag — the ENG-4159 argument-injection family.
|
|
792
|
+
*/
|
|
793
|
+
const ENTRY_NAME = /^[A-Za-z0-9_-]{1,64}$/;
|
|
794
|
+
function isValidEntryName(name) {
|
|
795
|
+
return ENTRY_NAME.test(name) && !name.startsWith("-");
|
|
796
|
+
}
|
|
797
|
+
/** Claude Code tool names are `mcp__<entry>__<tool>`; keep that well short. */
|
|
798
|
+
const BESIDE_NAME_MAX = 30;
|
|
799
|
+
/**
|
|
800
|
+
* The name for a second entry beside `serverName`: `<serverName>-<url_key>`,
|
|
801
|
+
* so it says which workspace it is. Undefined — and "Add beside it" is not
|
|
802
|
+
* offered — when the target URL names no workspace, or when that name would
|
|
803
|
+
* be too long or not a valid entry name. It is never shortened: a cut name
|
|
804
|
+
* could drop the workspace, or give two workspaces the same entry.
|
|
805
|
+
*/
|
|
806
|
+
function besideName(serverName, url) {
|
|
807
|
+
const key = workspaceKeyOf(url);
|
|
808
|
+
if (!key) return void 0;
|
|
809
|
+
const name = `${serverName}-${key}`;
|
|
810
|
+
return name.length <= BESIDE_NAME_MAX && isValidEntryName(name) ? name : void 0;
|
|
811
|
+
}
|
|
776
812
|
|
|
777
813
|
//#endregion
|
|
778
814
|
//#region ../mcp-harnesses/dist/node/paths.js
|
|
@@ -1611,47 +1647,44 @@ function needsShell(binPath) {
|
|
|
1611
1647
|
*/
|
|
1612
1648
|
const SHELL_METACHARACTERS = /[&|<>^"%!`]/;
|
|
1613
1649
|
/**
|
|
1614
|
-
*
|
|
1650
|
+
* Everything decided before a command is spawned, shared by both runners: an
|
|
1651
|
+
* outcome when it must not run, else how to run it.
|
|
1615
1652
|
*
|
|
1616
1653
|
* Resolution uses {@link whichPathSync} — a pure `PATH` scan that spawns
|
|
1617
1654
|
* nothing, so the "is this installed" check can never itself be the thing
|
|
1618
1655
|
* that hangs. It returns the resolved path rather than a boolean because the
|
|
1619
1656
|
* extension decides how the command must be launched.
|
|
1620
1657
|
*/
|
|
1621
|
-
function
|
|
1658
|
+
function prepare(argv, opts) {
|
|
1622
1659
|
const env = opts.env ?? defaultEnv();
|
|
1623
1660
|
const dryRun = opts.dryRun ?? false;
|
|
1624
1661
|
const [bin, ...args] = argv;
|
|
1625
|
-
if (!bin) return {
|
|
1662
|
+
if (!bin) return { skip: {
|
|
1626
1663
|
executed: false,
|
|
1627
1664
|
ok: false,
|
|
1628
1665
|
reason: "empty-command"
|
|
1629
|
-
};
|
|
1630
|
-
if (dryRun) return {
|
|
1666
|
+
} };
|
|
1667
|
+
if (dryRun) return { skip: {
|
|
1631
1668
|
executed: false,
|
|
1632
1669
|
ok: true,
|
|
1633
1670
|
reason: "dry-run"
|
|
1634
|
-
};
|
|
1671
|
+
} };
|
|
1635
1672
|
const binPath = whichPathSync(bin, env);
|
|
1636
|
-
if (!binPath) return {
|
|
1673
|
+
if (!binPath) return { skip: {
|
|
1637
1674
|
executed: false,
|
|
1638
1675
|
ok: true,
|
|
1639
1676
|
reason: "binary-not-found"
|
|
1640
|
-
};
|
|
1677
|
+
} };
|
|
1641
1678
|
const useShell = needsShell(binPath);
|
|
1642
|
-
if (useShell && args.some((a) => SHELL_METACHARACTERS.test(a))) return {
|
|
1679
|
+
if (useShell && (binPath.includes("\"") || args.some((a) => SHELL_METACHARACTERS.test(a)))) return { skip: {
|
|
1643
1680
|
executed: false,
|
|
1644
1681
|
ok: false,
|
|
1645
1682
|
reason: "unsafe-argument"
|
|
1646
|
-
};
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
"ignore",
|
|
1652
|
-
"pipe",
|
|
1653
|
-
"pipe"
|
|
1654
|
-
],
|
|
1683
|
+
} };
|
|
1684
|
+
return { spawn: {
|
|
1685
|
+
binPath: useShell ? `"${binPath}"` : binPath,
|
|
1686
|
+
args,
|
|
1687
|
+
options: {
|
|
1655
1688
|
timeout: COMMAND_TIMEOUT_MS,
|
|
1656
1689
|
env: {
|
|
1657
1690
|
...childVars(env),
|
|
@@ -1660,20 +1693,42 @@ function runHarnessCommandSync(argv, opts = {}) {
|
|
|
1660
1693
|
},
|
|
1661
1694
|
cwd: env.cwd,
|
|
1662
1695
|
...useShell ? { shell: true } : {}
|
|
1696
|
+
}
|
|
1697
|
+
} };
|
|
1698
|
+
}
|
|
1699
|
+
/** A command that ran and failed (or could not start), as an outcome. */
|
|
1700
|
+
function failureOutcome(err) {
|
|
1701
|
+
const e = err;
|
|
1702
|
+
const exitCode = typeof e.status === "number" ? e.status : typeof e.code === "number" ? e.code : void 0;
|
|
1703
|
+
const raw = typeof e.stderr === "string" ? e.stderr : e.stderr?.toString("utf8") ?? "";
|
|
1704
|
+
return {
|
|
1705
|
+
executed: true,
|
|
1706
|
+
ok: false,
|
|
1707
|
+
...exitCode === void 0 ? {} : { exitCode },
|
|
1708
|
+
stderr: (raw.trim() || e.message || "command failed").slice(0, MAX_STDERR)
|
|
1709
|
+
};
|
|
1710
|
+
}
|
|
1711
|
+
/** Run a harness CLI invocation, or explain why it was not run. */
|
|
1712
|
+
function runHarnessCommandSync(argv, opts = {}) {
|
|
1713
|
+
const prepared = prepare(argv, opts);
|
|
1714
|
+
if ("skip" in prepared) return prepared.skip;
|
|
1715
|
+
const { binPath, args, options } = prepared.spawn;
|
|
1716
|
+
try {
|
|
1717
|
+
execFileSync(binPath, args, {
|
|
1718
|
+
...options,
|
|
1719
|
+
encoding: "utf8",
|
|
1720
|
+
stdio: [
|
|
1721
|
+
"ignore",
|
|
1722
|
+
"pipe",
|
|
1723
|
+
"pipe"
|
|
1724
|
+
]
|
|
1663
1725
|
});
|
|
1664
1726
|
return {
|
|
1665
1727
|
executed: true,
|
|
1666
1728
|
ok: true
|
|
1667
1729
|
};
|
|
1668
1730
|
} catch (err) {
|
|
1669
|
-
|
|
1670
|
-
const raw = typeof e.stderr === "string" ? e.stderr : e.stderr?.toString("utf8") ?? "";
|
|
1671
|
-
return {
|
|
1672
|
-
executed: true,
|
|
1673
|
-
ok: false,
|
|
1674
|
-
...typeof e.status === "number" ? { exitCode: e.status } : {},
|
|
1675
|
-
stderr: (raw.trim() || e.message || "command failed").slice(0, MAX_STDERR)
|
|
1676
|
-
};
|
|
1731
|
+
return failureOutcome(err);
|
|
1677
1732
|
}
|
|
1678
1733
|
}
|
|
1679
1734
|
|
|
@@ -1745,8 +1800,16 @@ function canonical(value) {
|
|
|
1745
1800
|
if (value !== null && typeof value === "object") return `{${Object.entries(value).filter(([, v]) => v !== void 0).sort(([x], [y]) => x < y ? -1 : x > y ? 1 : 0).map(([k, v]) => `${JSON.stringify(k)}:${canonical(v)}`).join(",")}}`;
|
|
1746
1801
|
return JSON.stringify(value) ?? "undefined";
|
|
1747
1802
|
}
|
|
1803
|
+
function drive(steps, run) {
|
|
1804
|
+
let step = steps.next();
|
|
1805
|
+
while (!step.done) step = steps.next(run(step.value.argv, step.value.opts));
|
|
1806
|
+
return step.value;
|
|
1807
|
+
}
|
|
1748
1808
|
/** Merge (or preview) our MCP into a harness config. */
|
|
1749
1809
|
function installHarnessSync(harness, mcpUrl, opts = {}) {
|
|
1810
|
+
return drive(installSteps(harness, mcpUrl, opts), opts.runCommand ?? runHarnessCommandSync);
|
|
1811
|
+
}
|
|
1812
|
+
function* installSteps(harness, mcpUrl, opts) {
|
|
1750
1813
|
const env = opts.env ?? defaultEnv();
|
|
1751
1814
|
const dryRun = opts.dryRun ?? false;
|
|
1752
1815
|
const scope = opts.scope ?? defaultScope(harness);
|
|
@@ -1785,13 +1848,12 @@ function installHarnessSync(harness, mcpUrl, opts = {}) {
|
|
|
1785
1848
|
alreadyConfigured: false,
|
|
1786
1849
|
reason: "unrecognized-entry"
|
|
1787
1850
|
};
|
|
1788
|
-
if (opts.replaceExisting) return
|
|
1851
|
+
if (opts.replaceExisting) return yield* replaceSteps(harness, argv, existing$1.url, {
|
|
1789
1852
|
env,
|
|
1790
1853
|
dryRun,
|
|
1791
1854
|
scope,
|
|
1792
1855
|
serverName,
|
|
1793
|
-
execute: opts.execute ?? true
|
|
1794
|
-
run: opts.runCommand ?? runHarnessCommandSync
|
|
1856
|
+
execute: opts.execute ?? true
|
|
1795
1857
|
});
|
|
1796
1858
|
return {
|
|
1797
1859
|
...base,
|
|
@@ -1802,11 +1864,13 @@ function installHarnessSync(harness, mcpUrl, opts = {}) {
|
|
|
1802
1864
|
...existing$1.url ? { currentUrl: existing$1.url } : {}
|
|
1803
1865
|
};
|
|
1804
1866
|
}
|
|
1805
|
-
const
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1867
|
+
const outcome = opts.execute ?? true ? yield {
|
|
1868
|
+
argv,
|
|
1869
|
+
opts: {
|
|
1870
|
+
env,
|
|
1871
|
+
dryRun
|
|
1872
|
+
}
|
|
1873
|
+
} : {
|
|
1810
1874
|
executed: false,
|
|
1811
1875
|
ok: true,
|
|
1812
1876
|
reason: "not-requested"
|
|
@@ -1815,7 +1879,7 @@ function installHarnessSync(harness, mcpUrl, opts = {}) {
|
|
|
1815
1879
|
...base,
|
|
1816
1880
|
ok: outcome.ok,
|
|
1817
1881
|
executed: outcome.executed,
|
|
1818
|
-
...outcome.ok ? {} : { commandError: outcome
|
|
1882
|
+
...outcome.ok ? {} : { commandError: notRunError(outcome) },
|
|
1819
1883
|
alreadyConfigured: false
|
|
1820
1884
|
};
|
|
1821
1885
|
}
|
|
@@ -1907,7 +1971,14 @@ function installHarnessSync(harness, mcpUrl, opts = {}) {
|
|
|
1907
1971
|
* second command must not leave the user with no entry at all without saying
|
|
1908
1972
|
* so.
|
|
1909
1973
|
*/
|
|
1910
|
-
|
|
1974
|
+
/**
|
|
1975
|
+
* What went wrong with a failed command: its stderr, else why it was never
|
|
1976
|
+
* run — so a refusal never reads as "unknown reason" (ENG-6737).
|
|
1977
|
+
*/
|
|
1978
|
+
function notRunError(outcome) {
|
|
1979
|
+
return outcome.stderr ?? (outcome.reason ? `not run: ${outcome.reason}` : void 0);
|
|
1980
|
+
}
|
|
1981
|
+
function* replaceSteps(harness, addArgv, previousUrl, ctx) {
|
|
1911
1982
|
const removeArgv = removeCommandArgv(ctx.scope, ctx.serverName);
|
|
1912
1983
|
const base = {
|
|
1913
1984
|
wrote: false,
|
|
@@ -1923,11 +1994,15 @@ function replaceViaCommand(harness, addArgv, previousUrl, ctx) {
|
|
|
1923
1994
|
ok: true,
|
|
1924
1995
|
executed: false
|
|
1925
1996
|
};
|
|
1926
|
-
const removed =
|
|
1997
|
+
const removed = yield {
|
|
1998
|
+
argv: removeArgv,
|
|
1999
|
+
opts: { env: ctx.env }
|
|
2000
|
+
};
|
|
1927
2001
|
if (!removed.executed) return {
|
|
1928
2002
|
...base,
|
|
1929
2003
|
ok: removed.ok,
|
|
1930
|
-
executed: false
|
|
2004
|
+
executed: false,
|
|
2005
|
+
...removed.ok ? {} : { commandError: notRunError(removed) }
|
|
1931
2006
|
};
|
|
1932
2007
|
if (!removed.ok) return {
|
|
1933
2008
|
...base,
|
|
@@ -1935,21 +2010,26 @@ function replaceViaCommand(harness, addArgv, previousUrl, ctx) {
|
|
|
1935
2010
|
executed: true,
|
|
1936
2011
|
commandError: `could not remove the existing entry: ${removed.stderr ?? "unknown error"}`
|
|
1937
2012
|
};
|
|
1938
|
-
const added =
|
|
2013
|
+
const added = yield {
|
|
2014
|
+
argv: addArgv,
|
|
2015
|
+
opts: { env: ctx.env }
|
|
2016
|
+
};
|
|
1939
2017
|
if (added.ok) return {
|
|
1940
2018
|
...base,
|
|
1941
2019
|
ok: true,
|
|
1942
2020
|
executed: true
|
|
1943
2021
|
};
|
|
1944
|
-
const
|
|
1945
|
-
|
|
2022
|
+
const restore = yield {
|
|
2023
|
+
argv: renderHarnessCommand(harness, previousUrl, ctx.scope, ctx.serverName),
|
|
2024
|
+
opts: { env: ctx.env }
|
|
2025
|
+
};
|
|
1946
2026
|
return {
|
|
1947
2027
|
...base,
|
|
1948
2028
|
ok: false,
|
|
1949
2029
|
executed: true,
|
|
1950
|
-
commandError: added
|
|
2030
|
+
commandError: notRunError(added) ?? "unknown error",
|
|
1951
2031
|
restored: restore.ok && restore.executed,
|
|
1952
|
-
...restore.ok && restore.executed ? {} : { restoreError: restore
|
|
2032
|
+
...restore.ok && restore.executed ? {} : { restoreError: notRunError(restore) ?? "the restore command did not run" }
|
|
1953
2033
|
};
|
|
1954
2034
|
}
|
|
1955
2035
|
/**
|
|
@@ -2025,150 +2105,6 @@ function writeConfigAtomic(path$1, text) {
|
|
|
2025
2105
|
}
|
|
2026
2106
|
}
|
|
2027
2107
|
|
|
2028
|
-
//#endregion
|
|
2029
|
-
//#region src/mcp/url.ts
|
|
2030
|
-
const KNOWN_MCP_URLS = {
|
|
2031
|
-
"api.levr.one": "https://ai.levr.one/api/v1/mcp",
|
|
2032
|
-
"api.levr.now": "https://ai.levr.now/api/v1/mcp"
|
|
2033
|
-
};
|
|
2034
|
-
/**
|
|
2035
|
-
* Resolve the MCP server URL: `--url` flag > `LEVR_MCP_URL` env > derived
|
|
2036
|
-
* from the resolved API URL (which itself honors `LEVR_URL` > the URL stored
|
|
2037
|
-
* at login > production default, ENG-2361). Known Levr hosts map to their
|
|
2038
|
-
* app-host MCP resource; anything else (localhost dev stacks, custom
|
|
2039
|
-
* deployments) derives `<api-url>/v1/mcp`.
|
|
2040
|
-
*/
|
|
2041
|
-
function resolveMcpUrl(flagUrl) {
|
|
2042
|
-
const resolved = resolveRaw(flagUrl);
|
|
2043
|
-
assertUsableMcpUrl(resolved.url, resolved.source);
|
|
2044
|
-
return resolved;
|
|
2045
|
-
}
|
|
2046
|
-
function resolveRaw(flagUrl) {
|
|
2047
|
-
if (flagUrl) return {
|
|
2048
|
-
url: stripSlash(flagUrl),
|
|
2049
|
-
source: "flag"
|
|
2050
|
-
};
|
|
2051
|
-
const envVar = process.env["LEVR_MCP_URL"];
|
|
2052
|
-
if (envVar) return {
|
|
2053
|
-
url: stripSlash(envVar),
|
|
2054
|
-
source: "env:LEVR_MCP_URL"
|
|
2055
|
-
};
|
|
2056
|
-
const apiUrl = getApiUrl();
|
|
2057
|
-
return {
|
|
2058
|
-
url: knownMcpUrl(apiUrl) ?? `${apiUrl}/v1/mcp`,
|
|
2059
|
-
source: `derived:${apiUrl}`
|
|
2060
|
-
};
|
|
2061
|
-
}
|
|
2062
|
-
/** Name the input the user has to change, not our internal source tag. */
|
|
2063
|
-
function sourceLabel(source) {
|
|
2064
|
-
if (source === "flag") return "--url";
|
|
2065
|
-
if (source === "env:LEVR_MCP_URL") return "LEVR_MCP_URL";
|
|
2066
|
-
return `the API URL (${source.slice(8)})`;
|
|
2067
|
-
}
|
|
2068
|
-
/**
|
|
2069
|
-
* Reject a URL that must not reach a client config or a spawned CLI (ENG-4159).
|
|
2070
|
-
*
|
|
2071
|
-
* Three things this stops:
|
|
2072
|
-
*
|
|
2073
|
-
* 1. **No scheme.** `claude mcp add --transport http --scope user levr <value>`
|
|
2074
|
-
* is spawned with fixed argv since ENG-4156, so a value like `--foo` is read
|
|
2075
|
-
* by that CLI as a FLAG rather than as its URL operand — argument injection.
|
|
2076
|
-
* Requiring a parseable absolute URL removes the shape entirely.
|
|
2077
|
-
* 2. **A non-http(s) scheme.** `file:` / `javascript:` is never a valid MCP
|
|
2078
|
-
* endpoint, but would be written verbatim into every client's config.
|
|
2079
|
-
* 3. **Embedded credentials.** `https://user:pass@host` would be written into
|
|
2080
|
-
* a config file — and since ENG-4151 a config file can be `--scope project`,
|
|
2081
|
-
* which the user is told to COMMIT. That turns a bad URL into a secret in
|
|
2082
|
-
* git history. MCP authenticates via OAuth in the browser; credentials in
|
|
2083
|
-
* the URL are never needed.
|
|
2084
|
-
*/
|
|
2085
|
-
function assertUsableMcpUrl(url, source) {
|
|
2086
|
-
const from = sourceLabel(source);
|
|
2087
|
-
let parsed;
|
|
2088
|
-
try {
|
|
2089
|
-
parsed = new URL(url);
|
|
2090
|
-
} catch {
|
|
2091
|
-
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`);
|
|
2092
|
-
}
|
|
2093
|
-
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.`);
|
|
2094
|
-
if (!parsed.hostname) throw new Error(`Invalid MCP URL from ${from}: ${JSON.stringify(url)} has no host.`);
|
|
2095
|
-
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.`);
|
|
2096
|
-
}
|
|
2097
|
-
/**
|
|
2098
|
-
* Is this MCP URL served by the API the CLI is logged in to? `--workspace`
|
|
2099
|
-
* and the workspace picker read that API's workspaces, so pinning a URL on
|
|
2100
|
-
* another server to one of them would name the wrong workspace, or none
|
|
2101
|
-
* (ENG-6300 D6).
|
|
2102
|
-
*/
|
|
2103
|
-
function servedBySessionApi(mcpUrl) {
|
|
2104
|
-
const apiUrl = getApiUrl();
|
|
2105
|
-
const derived = knownMcpUrl(apiUrl) ?? `${apiUrl}/v1/mcp`;
|
|
2106
|
-
try {
|
|
2107
|
-
return new URL(mcpUrl).origin === new URL(derived).origin;
|
|
2108
|
-
} catch {
|
|
2109
|
-
return false;
|
|
2110
|
-
}
|
|
2111
|
-
}
|
|
2112
|
-
/** `…/v1/mcp/w/<url_key>`: the URL already names one workspace (ENG-6300). */
|
|
2113
|
-
const SCOPED_SUFFIX = /\/w\/[^/]+$/;
|
|
2114
|
-
/** The workspace a scoped MCP URL names (`…/w/<url_key>`), if any. */
|
|
2115
|
-
function workspaceKeyOf(url) {
|
|
2116
|
-
const m = /\/w\/([^/]+)$/.exec(stripSlash(url));
|
|
2117
|
-
if (!m?.[1]) return void 0;
|
|
2118
|
-
try {
|
|
2119
|
-
return decodeURIComponent(m[1]);
|
|
2120
|
-
} catch {
|
|
2121
|
-
return m[1];
|
|
2122
|
-
}
|
|
2123
|
-
}
|
|
2124
|
-
/** Does this MCP URL already name a workspace? */
|
|
2125
|
-
function isScopedMcpUrl(url) {
|
|
2126
|
-
return SCOPED_SUFFIX.test(stripSlash(url));
|
|
2127
|
-
}
|
|
2128
|
-
/**
|
|
2129
|
-
* The MCP URL for one workspace, `<base>/w/<url_key>` (ENG-6300 D6). A base
|
|
2130
|
-
* that already names a workspace is an error rather than a silent re-point:
|
|
2131
|
-
* `--url …/w/a --workspace b` asks for two different workspaces at once.
|
|
2132
|
-
*/
|
|
2133
|
-
function scopedMcpUrl(base, urlKey) {
|
|
2134
|
-
const root = stripSlash(base);
|
|
2135
|
-
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.`);
|
|
2136
|
-
return `${root}/w/${encodeURIComponent(urlKey)}`;
|
|
2137
|
-
}
|
|
2138
|
-
function knownMcpUrl(apiUrl) {
|
|
2139
|
-
try {
|
|
2140
|
-
return KNOWN_MCP_URLS[new URL(apiUrl).host];
|
|
2141
|
-
} catch {
|
|
2142
|
-
return;
|
|
2143
|
-
}
|
|
2144
|
-
}
|
|
2145
|
-
function stripSlash(url) {
|
|
2146
|
-
return url.replace(/\/+$/, "");
|
|
2147
|
-
}
|
|
2148
|
-
|
|
2149
|
-
//#endregion
|
|
2150
|
-
//#region src/workspace/find-workspace.ts
|
|
2151
|
-
/** ` - Name (url_key)` per workspace, for error messages and hints. */
|
|
2152
|
-
function describeWorkspaces(sites) {
|
|
2153
|
-
return sites.map((s) => ` - ${s.workspace_name} (${s.workspace_url_key})`).join("\n");
|
|
2154
|
-
}
|
|
2155
|
-
/**
|
|
2156
|
-
* An exact `url_key` wins; otherwise a case-insensitive name that matches
|
|
2157
|
-
* exactly one workspace. Anything else throws, listing the choices.
|
|
2158
|
-
*/
|
|
2159
|
-
function findWorkspaceByKeyOrName(sites, input) {
|
|
2160
|
-
const wanted = input.trim();
|
|
2161
|
-
if (!wanted) throw new Error("--workspace needs a workspace name or url_key.");
|
|
2162
|
-
const byKey = sites.find((s) => s.workspace_url_key === wanted);
|
|
2163
|
-
if (byKey) return byKey;
|
|
2164
|
-
const lower = wanted.toLowerCase();
|
|
2165
|
-
const byName = sites.filter((s) => s.workspace_name.toLowerCase() === lower);
|
|
2166
|
-
if (byName.length === 1) return byName[0];
|
|
2167
|
-
if (byName.length > 1) throw new Error(`"${wanted}" names more than one workspace. Pass its url_key instead:\n` + describeWorkspaces(byName));
|
|
2168
|
-
if (sites.length === 0) throw new Error("Your account has no workspaces.");
|
|
2169
|
-
throw new Error(`No workspace "${wanted}" in your account. Choose one of:\n` + describeWorkspaces(sites));
|
|
2170
|
-
}
|
|
2171
|
-
|
|
2172
2108
|
//#endregion
|
|
2173
2109
|
//#region src/mcp/run.ts
|
|
2174
2110
|
/**
|
|
@@ -2188,16 +2124,6 @@ function entryState(s, url) {
|
|
|
2188
2124
|
return s.currentUrl === void 0 ? "unrecognized" : "other-url";
|
|
2189
2125
|
}
|
|
2190
2126
|
/**
|
|
2191
|
-
* Allowed client entry names: 1-64 of `A-Za-z0-9_-`, and never starting with
|
|
2192
|
-
* `-`. The name is passed as an argv element to the client's own CLI
|
|
2193
|
-
* (`claude mcp add … <name> <url>`), so a leading `-` would be read there as a
|
|
2194
|
-
* flag — the ENG-4159 argument-injection family.
|
|
2195
|
-
*/
|
|
2196
|
-
const ENTRY_NAME = /^[A-Za-z0-9_-]{1,64}$/;
|
|
2197
|
-
function isValidEntryName(name) {
|
|
2198
|
-
return ENTRY_NAME.test(name) && !name.startsWith("-");
|
|
2199
|
-
}
|
|
2200
|
-
/**
|
|
2201
2127
|
* The scope an install would land in: the requested one, or, for a harness
|
|
2202
2128
|
* that cannot do it, its own default, which is where the fallback goes.
|
|
2203
2129
|
*/
|
|
@@ -2261,21 +2187,6 @@ function clientChoices(detected, scope, url, serverName = DEFAULT_SERVER_NAME) {
|
|
|
2261
2187
|
};
|
|
2262
2188
|
});
|
|
2263
2189
|
}
|
|
2264
|
-
/** Claude Code tool names are `mcp__<entry>__<tool>`; keep that well short. */
|
|
2265
|
-
const BESIDE_NAME_MAX = 30;
|
|
2266
|
-
/**
|
|
2267
|
-
* The name for a second entry beside `serverName`: `<serverName>-<url_key>`,
|
|
2268
|
-
* so it says which workspace it is. Undefined — and "Add beside it" is not
|
|
2269
|
-
* offered — when the target URL names no workspace, or when that name would
|
|
2270
|
-
* be too long or not a valid entry name. It is never shortened: a cut name
|
|
2271
|
-
* could drop the workspace, or give two workspaces the same entry.
|
|
2272
|
-
*/
|
|
2273
|
-
function besideName(serverName, url) {
|
|
2274
|
-
const key = workspaceKeyOf(url);
|
|
2275
|
-
if (!key) return void 0;
|
|
2276
|
-
const name = `${serverName}-${key}`;
|
|
2277
|
-
return name.length <= BESIDE_NAME_MAX && isValidEntryName(name) ? name : void 0;
|
|
2278
|
-
}
|
|
2279
2190
|
/**
|
|
2280
2191
|
* The selected clients whose entry is not this URL's: each needs a decision —
|
|
2281
2192
|
* switch it, add beside it, or leave it — before anything is installed
|
|
@@ -2497,22 +2408,37 @@ function depsFor(entry) {
|
|
|
2497
2408
|
};
|
|
2498
2409
|
}
|
|
2499
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
|
+
/**
|
|
2500
2420
|
* The workspaces of the logged-in user. Reads the session only; it never
|
|
2501
|
-
* 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.
|
|
2502
2423
|
*/
|
|
2503
|
-
async function loadSites() {
|
|
2424
|
+
async function loadSites(workspace) {
|
|
2504
2425
|
let auth;
|
|
2505
2426
|
try {
|
|
2506
2427
|
auth = await resolveToken();
|
|
2507
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
|
+
};
|
|
2508
2434
|
return {
|
|
2509
2435
|
ok: false,
|
|
2510
|
-
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.`
|
|
2511
2437
|
};
|
|
2512
2438
|
}
|
|
2513
2439
|
if (auth.type === "pat") return {
|
|
2514
2440
|
ok: false,
|
|
2515
|
-
error:
|
|
2441
|
+
error: `--workspace needs a login session, not a personal access token. Run '${loginCommandFor(workspace)}'.`
|
|
2516
2442
|
};
|
|
2517
2443
|
configureClient(auth);
|
|
2518
2444
|
try {
|
|
@@ -2577,7 +2503,7 @@ async function mcpAddHandler(flags) {
|
|
|
2577
2503
|
this.process.exitCode = 1;
|
|
2578
2504
|
return;
|
|
2579
2505
|
}
|
|
2580
|
-
const loaded = await loadSites();
|
|
2506
|
+
const loaded = await loadSites(flags.workspace);
|
|
2581
2507
|
if (!loaded.ok) {
|
|
2582
2508
|
this.logger.error(loaded.error);
|
|
2583
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.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
|
|
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
|