@zereight/mcp-gitlab 2.1.69 → 2.1.70
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ko.md +8 -4
- package/README.md +234 -233
- package/README.pt-BR.md +4 -4
- package/README.zh-CN.md +4 -4
- package/build/index.js +87 -53
- package/build/test/cli/credentials.test.js +153 -0
- package/build/test/gitlab-api-tool-errors.test.js +112 -0
- package/build/test/oauth-download-url.test.js +157 -0
- package/build/test/test-geteffectiveprojectid.js +16 -24
- package/build/test/test-pipeline-schedules.js +3 -1
- package/build/test/utils/url.test.js +14 -1
- package/build/utils/url.js +15 -0
- package/package.json +1 -1
package/README.pt-BR.md
CHANGED
|
@@ -17,7 +17,7 @@ Suporta PAT, OAuth, modo somente leitura, URLs de API dinâmicas e autorização
|
|
|
17
17
|
|
|
18
18
|
### Por que usar este GitLab MCP?
|
|
19
19
|
|
|
20
|
-
- **
|
|
20
|
+
- **267 ferramentas + `discover_tools`** — comece com um toolset pequeno e ative mais categorias em tempo de execução
|
|
21
21
|
- **Revisão de MR em 2 etapas** — `list_merge_request_changed_files` → `get_merge_request_file_diff` em lote
|
|
22
22
|
- **Agent Skill integrado** — orientação de workflows em `skills/gitlab-mcp/`
|
|
23
23
|
- **Autenticação flexível** — Personal Access Token, fluxo OAuth2 local no navegador, proxy MCP OAuth e autorização remota por requisição
|
|
@@ -31,7 +31,7 @@ Suporta PAT, OAuth, modo somente leitura, URLs de API dinâmicas e autorização
|
|
|
31
31
|
| | @zereight/mcp-gitlab | GitLab MCP A (comunidade, estilo CQRS) |
|
|
32
32
|
| ------------------------- | ---------------------------------------------- | --------------------------------------------------------- |
|
|
33
33
|
| **Mais indicado para** | Workflows de agentes de IA | Múltiplas instâncias corporativas / ferramentas agrupadas |
|
|
34
|
-
| **Modelo de ferramentas** | ~
|
|
34
|
+
| **Modelo de ferramentas** | ~267 ferramentas granulares + `discover_tools` | ~50–60 ferramentas agrupadas `browse_*` / `manage_*` |
|
|
35
35
|
| **Revisão de MR** | Diff em lote em 2 etapas | Varia |
|
|
36
36
|
| **Node.js** | >=18.17 | Frequentemente >=24 |
|
|
37
37
|
| **Licença** | MIT | Varia |
|
|
@@ -111,7 +111,7 @@ O caminho no store é fixado pelo seu lock file; atualize-o com `nix flake updat
|
|
|
111
111
|
|
|
112
112
|
Os exemplos usam `zereight-mcp-gitlab`, um alias com menor chance de conflito que o binário legado `mcp-gitlab`. Se o seu cliente MCP não o encontrar, use o caminho absoluto retornado por `which zereight-mcp-gitlab`.
|
|
113
113
|
|
|
114
|
-
Não quer instalar globalmente? Fixe o `npx` na versão estável anterior (a versão recomendada por esta documentação), por exemplo `npx -y @zereight/mcp-gitlab@2.1.
|
|
114
|
+
Não quer instalar globalmente? Fixe o `npx` na versão estável anterior (a versão recomendada por esta documentação), por exemplo `npx -y @zereight/mcp-gitlab@2.1.69`. Se quiser sempre a versão mais recente, use `npx -y @zereight/mcp-gitlab@latest`. O servidor exibe um aviso no stderr durante a inicialização quando existe uma versão mais nova disponível (desative com `GITLAB_DISABLE_VERSION_CHECK=true`).
|
|
115
115
|
|
|
116
116
|
#### Usando argumentos de CLI (para clientes com problemas com variáveis de ambiente)
|
|
117
117
|
|
|
@@ -150,7 +150,7 @@ Os argumentos de CLI têm precedência sobre as variáveis de ambiente.
|
|
|
150
150
|
|
|
151
151
|
`zereight-mcp-gitlab auth` é um subcomando, não uma flag do servidor MCP. Ele executa o device flow do GitLab e encerra. Veja [Argumentos de CLI](./docs/getting-started/cli-arguments.md#auth).
|
|
152
152
|
|
|
153
|
-
O mesmo binário também é uma CLI do GitLab no estilo `gh` (`tool <name>` ou formas curadas como `mr list`). Use-o para ver a carga completa no terminal. As respostas do MCP permanecem no chat, a menos que você defina `GITLAB_MCP_COMPACT_RESULTS=true` (ou `--compact-results`), o que substitui respostas grandes de ferramentas por uma prévia e um comando CLI. A CLI humana nunca é compactada. Os mesmos filtros de permission mode, toolsets, denied-tools regex e tool-policy se aplicam. Ferramentas destrutivas e ferramentas com `GITLAB_TOOL_POLICY_APPROVE` exigem `--yes`. Consulte [CLI Humana](./docs/getting-started/cli-arguments.md#human-cli).
|
|
153
|
+
O mesmo binário também é uma CLI do GitLab no estilo `gh` (`tool <name>` ou formas curadas como `mr list`). **Não é necessário registrar o MCP** — defina um PAT, ou execute `auth` e defina `GITLAB_USE_OAUTH=true`: veja [CLI sem MCP](./docs/getting-started/cli-without-mcp.md). Use-o para ver a carga completa no terminal. As respostas do MCP permanecem no chat, a menos que você defina `GITLAB_MCP_COMPACT_RESULTS=true` (ou `--compact-results`), o que substitui respostas grandes de ferramentas por uma prévia e um comando CLI. A CLI humana nunca é compactada. Os mesmos filtros de permission mode, toolsets, denied-tools regex e tool-policy se aplicam. Ferramentas destrutivas e ferramentas com `GITLAB_TOOL_POLICY_APPROVE` exigem `--yes`. Consulte [CLI Humana](./docs/getting-started/cli-arguments.md#human-cli).
|
|
154
154
|
|
|
155
155
|
> **Filtragem granular de ferramentas:** use `GITLAB_PERMISSION_MODE=modify` para permitir criação/atualização enquanto
|
|
156
156
|
> bloqueia todas as ferramentas de exclusão e as ferramentas destrutivas de teardown (`cancel_pipeline`,
|
package/README.zh-CN.md
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
|
|
18
18
|
### 为什么使用这个 GitLab MCP?
|
|
19
19
|
|
|
20
|
-
- **
|
|
20
|
+
- **267 个工具 + `discover_tools`** — 从小型 toolset 开始,运行时按需激活类别
|
|
21
21
|
- **MR 两步审查** — `list_merge_request_changed_files` → 批量 `get_merge_request_file_diff`
|
|
22
22
|
- **内置 Agent Skill** — `skills/gitlab-mcp/` 工作流指南
|
|
23
23
|
- **认证灵活** — Personal Access Token、本地 OAuth2 浏览器流程、MCP OAuth 代理、按请求远程授权
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
| | @zereight/mcp-gitlab | GitLab MCP A(社区 CQRS 型) |
|
|
32
32
|
|---|----------------------|------------------------------|
|
|
33
33
|
| **更适合** | AI 代理工作流 | 企业多实例 / 分组工具 |
|
|
34
|
-
| **工具模型** | ~
|
|
34
|
+
| **工具模型** | ~267 个细粒度工具 + `discover_tools` | ~50–60 个 `browse_*` / `manage_*` 分组工具 |
|
|
35
35
|
| **MR 审查** | 两步批量 diff | 因服务器而异 |
|
|
36
36
|
| **Node.js** | >=18.17 | 通常 >=24 |
|
|
37
37
|
| **许可证** | MIT | 因服务器而异 |
|
|
@@ -111,7 +111,7 @@ command = lib.getExe inputs.gitlab-mcp.packages.${system}.default;
|
|
|
111
111
|
|
|
112
112
|
示例使用 `zereight-mcp-gitlab`,这是比旧的 `mcp-gitlab` 更不容易冲突的别名。如果 MCP 客户端找不到它,请使用 `which zereight-mcp-gitlab` 输出的绝对路径。
|
|
113
113
|
|
|
114
|
-
如果不想全局安装,请将 `npx` 固定到上一个稳定版本(即文档推荐的版本),例如 `npx -y @zereight/mcp-gitlab@2.1.
|
|
114
|
+
如果不想全局安装,请将 `npx` 固定到上一个稳定版本(即文档推荐的版本),例如 `npx -y @zereight/mcp-gitlab@2.1.69`。如果始终想使用最新版本,请改用 `npx -y @zereight/mcp-gitlab@latest`。有新版本发布时,服务器会在启动时通过 stderr 提示(可用 `GITLAB_DISABLE_VERSION_CHECK=true` 关闭)。
|
|
115
115
|
|
|
116
116
|
#### 使用 CLI 参数(适用于环境变量有问题的客户端)
|
|
117
117
|
|
|
@@ -150,7 +150,7 @@ CLI 参数优先于环境变量。
|
|
|
150
150
|
|
|
151
151
|
`zereight-mcp-gitlab auth` 是子命令,不是 MCP 服务器参数。它运行 GitLab device flow 后退出。参见 [CLI 参数](./docs/getting-started/cli-arguments.md#auth)。
|
|
152
152
|
|
|
153
|
-
同一二进制也是 `gh` 风格的 GitLab CLI(`tool <name>`,或 `mr list` 这类 curated
|
|
153
|
+
同一二进制也是 `gh` 风格的 GitLab CLI(`tool <name>`,或 `mr list` 这类 curated 命令)。**无需注册 MCP**——设置 PAT,或运行 `auth` 后设置 `GITLAB_USE_OAUTH=true`:参见 [无需 MCP 使用 CLI](./docs/getting-started/cli-without-mcp.md)。完整结果在终端用 CLI 查看。MCP 回复仍进入对话。设置 `GITLAB_MCP_COMPACT_RESULTS=true`(或 `--compact-results`)后,过大的工具回复会变成预览加上 CLI 命令。Human CLI 本身不会被 compact。permission mode、toolsets、denied-tools regex 和 tool-policy 与 MCP 相同。破坏性工具以及 `GITLAB_TOOL_POLICY_APPROVE` 工具需要 `--yes`。参见 [Human CLI](./docs/getting-started/cli-arguments.md#human-cli)。
|
|
154
154
|
|
|
155
155
|
> **细粒度工具过滤:**使用 `GITLAB_PERMISSION_MODE=modify` 允许创建/更新,同时阻止所有删除工具以及
|
|
156
156
|
> 破坏性的拆除(teardown)工具(`cancel_pipeline`、`cancel_pipeline_job`、`stop_environment`、
|
package/build/index.js
CHANGED
|
@@ -38,7 +38,8 @@ function buildDownloadUrl(type, params) {
|
|
|
38
38
|
header = "JOB-TOKEN";
|
|
39
39
|
headerValue = String(staticToken);
|
|
40
40
|
}
|
|
41
|
-
else if (IS_OLD) {
|
|
41
|
+
else if (IS_OLD && !OAUTH_ACCESS_TOKEN) {
|
|
42
|
+
// OAuth tokens always use Bearer (see buildAuthHeaders)
|
|
42
43
|
header = "Private-Token";
|
|
43
44
|
headerValue = String(staticToken);
|
|
44
45
|
}
|
|
@@ -93,7 +94,7 @@ import { formatPrometheusMetrics } from "./server/metrics.js";
|
|
|
93
94
|
import { SERVER_VERSION } from "./server/version.js";
|
|
94
95
|
import { isInitializationRequestBody, isUnauthenticatedDiscoveryRequestBody, readAcceptHeader, readMcpSessionIdHeader, redactSessionIdForLog, } from "./server/request-helpers.js";
|
|
95
96
|
export { readMcpSessionIdHeader } from "./server/request-helpers.js";
|
|
96
|
-
import { encodeGitLabPath, encodeGitLabPathSegment, normalizeGitLabApiUrl, } from "./utils/url.js";
|
|
97
|
+
import { encodeGitLabPath, encodeGitLabPathSegment, isCleartextRemoteUrl, normalizeGitLabApiUrl, } from "./utils/url.js";
|
|
97
98
|
import { estimateMergeCommitCount, filterDiffsByPatterns, openSafeOutputWriteStream, readSafeExistingFile, summarizeWebhookEvents, } from "./utils/helpers.js";
|
|
98
99
|
import { graphqlQueryContainsWriteOperation, graphqlQueryContainsDeleteOperation, } from "./utils/graphql-query.js";
|
|
99
100
|
import { resolveNestedWikiUpdateTitle } from "./utils/wiki-title.js";
|
|
@@ -356,7 +357,7 @@ function createServer() {
|
|
|
356
357
|
durationMs,
|
|
357
358
|
error: safeError instanceof Error ? safeError.message : String(safeError),
|
|
358
359
|
}, `tool_call_error: ${toolName} (${durationMs}ms)`);
|
|
359
|
-
|
|
360
|
+
return safeError;
|
|
360
361
|
};
|
|
361
362
|
try {
|
|
362
363
|
const authData = (REMOTE_AUTHORIZATION || GITLAB_MCP_OAUTH) && sessionId
|
|
@@ -547,8 +548,20 @@ function createServer() {
|
|
|
547
548
|
return logCompletion(result);
|
|
548
549
|
}
|
|
549
550
|
catch (error) {
|
|
550
|
-
|
|
551
|
-
|
|
551
|
+
const isGitLabApiError = error instanceof GitLabApiError;
|
|
552
|
+
const safeError = logError(error);
|
|
553
|
+
if (isGitLabApiError) {
|
|
554
|
+
return {
|
|
555
|
+
content: [
|
|
556
|
+
{
|
|
557
|
+
type: "text",
|
|
558
|
+
text: safeError instanceof Error ? safeError.message : String(safeError),
|
|
559
|
+
},
|
|
560
|
+
],
|
|
561
|
+
isError: true,
|
|
562
|
+
};
|
|
563
|
+
}
|
|
564
|
+
throw safeError;
|
|
552
565
|
}
|
|
553
566
|
});
|
|
554
567
|
return mcpServer;
|
|
@@ -1097,12 +1110,15 @@ function buildAuthHeaders() {
|
|
|
1097
1110
|
// OAuth token takes priority over PAT when both are set.
|
|
1098
1111
|
// NOTE: Changed in PR #400 — previously GITLAB_JOB_TOKEN had highest priority.
|
|
1099
1112
|
// If both GITLAB_PERSONAL_ACCESS_TOKEN and GITLAB_JOB_TOKEN are set, PAT wins.
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1113
|
+
// OAuth tokens always travel as `Authorization: Bearer`, even with GITLAB_IS_OLD: the HTTP
|
|
1114
|
+
// client strips `Authorization` on cross-origin redirects but not `Private-Token`.
|
|
1115
|
+
if (OAUTH_ACCESS_TOKEN) {
|
|
1116
|
+
return { Authorization: `Bearer ${OAUTH_ACCESS_TOKEN}` };
|
|
1103
1117
|
}
|
|
1104
|
-
if (
|
|
1105
|
-
return
|
|
1118
|
+
if (GITLAB_PERSONAL_ACCESS_TOKEN) {
|
|
1119
|
+
return IS_OLD
|
|
1120
|
+
? { "Private-Token": String(GITLAB_PERSONAL_ACCESS_TOKEN) }
|
|
1121
|
+
: { Authorization: `Bearer ${GITLAB_PERSONAL_ACCESS_TOKEN}` };
|
|
1106
1122
|
}
|
|
1107
1123
|
// Fall back to CI job token
|
|
1108
1124
|
if (GITLAB_JOB_TOKEN) {
|
|
@@ -1325,6 +1341,12 @@ if (!isCliInvocation(process.argv) &&
|
|
|
1325
1341
|
logger.info("Either set GITLAB_PERSONAL_ACCESS_TOKEN or enable OAuth with GITLAB_USE_OAUTH=true");
|
|
1326
1342
|
process.exit(1);
|
|
1327
1343
|
}
|
|
1344
|
+
class GitLabApiError extends Error {
|
|
1345
|
+
constructor(message) {
|
|
1346
|
+
super(message);
|
|
1347
|
+
this.name = "GitLabApiError";
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1328
1350
|
/**
|
|
1329
1351
|
* Utility function for handling GitLab API errors
|
|
1330
1352
|
* API 에러 처리를 위한 유틸리티 함수 (Utility function for handling API errors)
|
|
@@ -1341,12 +1363,12 @@ async function handleGitLabError(response) {
|
|
|
1341
1363
|
// before the common tool-call boundary returns/logs it.
|
|
1342
1364
|
logger.error("GitLab API Rate Limit Exceeded");
|
|
1343
1365
|
logger.error("User API Key Rate limit exceeded. Please try again later.");
|
|
1344
|
-
const error = new
|
|
1366
|
+
const error = new GitLabApiError(`GitLab API Rate Limit Exceeded: ${errorBody}`);
|
|
1345
1367
|
throw error;
|
|
1346
1368
|
}
|
|
1347
1369
|
else {
|
|
1348
1370
|
// Handle other API errors
|
|
1349
|
-
const error = new
|
|
1371
|
+
const error = new GitLabApiError(`GitLab API error: ${response.status} ${response.statusText}\n${errorBody}`);
|
|
1350
1372
|
throw error;
|
|
1351
1373
|
}
|
|
1352
1374
|
}
|
|
@@ -1411,7 +1433,7 @@ async function forkProject(projectId, namespace) {
|
|
|
1411
1433
|
});
|
|
1412
1434
|
// Handle case where project already exists
|
|
1413
1435
|
if (response.status === 409) {
|
|
1414
|
-
throw new
|
|
1436
|
+
throw new GitLabApiError("Project already exists in the target namespace");
|
|
1415
1437
|
}
|
|
1416
1438
|
await handleGitLabError(response);
|
|
1417
1439
|
const data = await response.json();
|
|
@@ -1482,7 +1504,7 @@ async function getFileContents(projectId, filePath, ref) {
|
|
|
1482
1504
|
});
|
|
1483
1505
|
// Handle file not found
|
|
1484
1506
|
if (response.status === 404) {
|
|
1485
|
-
throw new
|
|
1507
|
+
throw new GitLabApiError(`File not found: ${filePath}`);
|
|
1486
1508
|
}
|
|
1487
1509
|
await handleGitLabError(response);
|
|
1488
1510
|
const data = await response.json();
|
|
@@ -1515,7 +1537,7 @@ async function createIssue(projectId, options) {
|
|
|
1515
1537
|
// Handle bad request
|
|
1516
1538
|
if (response.status === 400) {
|
|
1517
1539
|
const errorBody = await response.text();
|
|
1518
|
-
throw new
|
|
1540
|
+
throw new GitLabApiError(`Invalid request: ${errorBody}`);
|
|
1519
1541
|
}
|
|
1520
1542
|
await handleGitLabError(response);
|
|
1521
1543
|
const data = await response.json();
|
|
@@ -1751,7 +1773,7 @@ async function executeGraphQL(query, variables = {}) {
|
|
|
1751
1773
|
});
|
|
1752
1774
|
if (!response.ok) {
|
|
1753
1775
|
const errorBody = await response.text();
|
|
1754
|
-
throw new
|
|
1776
|
+
throw new GitLabApiError(`GraphQL request failed (${response.status}): ${errorBody}`);
|
|
1755
1777
|
}
|
|
1756
1778
|
const json = await response.json();
|
|
1757
1779
|
if (json.errors && json.errors.length > 0) {
|
|
@@ -3200,11 +3222,11 @@ async function createMergeRequest(projectId, options) {
|
|
|
3200
3222
|
});
|
|
3201
3223
|
if (response.status === 400) {
|
|
3202
3224
|
const errorBody = await response.text();
|
|
3203
|
-
throw new
|
|
3225
|
+
throw new GitLabApiError(`Invalid request: ${errorBody}`);
|
|
3204
3226
|
}
|
|
3205
3227
|
if (!response.ok) {
|
|
3206
3228
|
const errorBody = await response.text();
|
|
3207
|
-
throw new
|
|
3229
|
+
throw new GitLabApiError(`GitLab API error: ${response.status} ${response.statusText}\n${errorBody}`);
|
|
3208
3230
|
}
|
|
3209
3231
|
const data = await response.json();
|
|
3210
3232
|
return GitLabMergeRequestSchema.parse(data);
|
|
@@ -3292,7 +3314,7 @@ async function deleteMergeRequestDiscussionNote(projectId, mergeRequestIid, disc
|
|
|
3292
3314
|
});
|
|
3293
3315
|
if (!response.ok) {
|
|
3294
3316
|
const errorText = await response.text();
|
|
3295
|
-
throw new
|
|
3317
|
+
throw new GitLabApiError(`GitLab API error: ${response.status} ${response.statusText}\n${errorText}`);
|
|
3296
3318
|
}
|
|
3297
3319
|
}
|
|
3298
3320
|
/**
|
|
@@ -3452,7 +3474,7 @@ async function deleteMergeRequestNote(projectId, mergeRequestIid, noteId) {
|
|
|
3452
3474
|
});
|
|
3453
3475
|
if (!response.ok) {
|
|
3454
3476
|
const errorText = await response.text();
|
|
3455
|
-
throw new
|
|
3477
|
+
throw new GitLabApiError(`GitLab API error: ${response.status} ${response.statusText}\n${errorText}`);
|
|
3456
3478
|
}
|
|
3457
3479
|
}
|
|
3458
3480
|
// --- Emoji Reactions (REST) ---
|
|
@@ -3623,7 +3645,7 @@ async function createOrUpdateFile(projectId, filePath, content, commitMessage, b
|
|
|
3623
3645
|
});
|
|
3624
3646
|
if (!response.ok) {
|
|
3625
3647
|
const errorBody = await response.text();
|
|
3626
|
-
throw new
|
|
3648
|
+
throw new GitLabApiError(`GitLab API error: ${response.status} ${response.statusText}\n${errorBody}`);
|
|
3627
3649
|
}
|
|
3628
3650
|
const data = await response.json();
|
|
3629
3651
|
return GitLabCreateUpdateFileResponseSchema.parse(data);
|
|
@@ -3652,11 +3674,11 @@ async function createCommit(projectId, message, branch, actions) {
|
|
|
3652
3674
|
});
|
|
3653
3675
|
if (response.status === 400) {
|
|
3654
3676
|
const errorBody = await response.text();
|
|
3655
|
-
throw new
|
|
3677
|
+
throw new GitLabApiError(`Invalid request: ${errorBody}`);
|
|
3656
3678
|
}
|
|
3657
3679
|
if (!response.ok) {
|
|
3658
3680
|
const errorBody = await response.text();
|
|
3659
|
-
throw new
|
|
3681
|
+
throw new GitLabApiError(`GitLab API error: ${response.status} ${response.statusText}\n${errorBody}`);
|
|
3660
3682
|
}
|
|
3661
3683
|
const data = await response.json();
|
|
3662
3684
|
return GitLabCommitSchema.parse(data);
|
|
@@ -3682,7 +3704,7 @@ async function searchProjects(query, page = 1, perPage = 20) {
|
|
|
3682
3704
|
});
|
|
3683
3705
|
if (!response.ok) {
|
|
3684
3706
|
const errorBody = await response.text();
|
|
3685
|
-
throw new
|
|
3707
|
+
throw new GitLabApiError(`GitLab API error: ${response.status} ${response.statusText}\n${errorBody}`);
|
|
3686
3708
|
}
|
|
3687
3709
|
const unfiltered = (await response.json());
|
|
3688
3710
|
const projects = filterProjectsByAllowlist(unfiltered);
|
|
@@ -3769,7 +3791,7 @@ async function createRepository(options) {
|
|
|
3769
3791
|
});
|
|
3770
3792
|
if (!response.ok) {
|
|
3771
3793
|
const errorBody = await response.text();
|
|
3772
|
-
throw new
|
|
3794
|
+
throw new GitLabApiError(`GitLab API error: ${response.status} ${response.statusText}\n${errorBody}`);
|
|
3773
3795
|
}
|
|
3774
3796
|
const data = await response.json();
|
|
3775
3797
|
return GitLabRepositorySchema.parse(data);
|
|
@@ -4195,7 +4217,7 @@ async function getBranchDiffs(projectId, from, to, straight) {
|
|
|
4195
4217
|
});
|
|
4196
4218
|
if (!response.ok) {
|
|
4197
4219
|
const errorBody = await response.text();
|
|
4198
|
-
throw new
|
|
4220
|
+
throw new GitLabApiError(`GitLab API error: ${response.status} ${response.statusText}\n${errorBody}`);
|
|
4199
4221
|
}
|
|
4200
4222
|
const data = await response.json();
|
|
4201
4223
|
return GitLabCompareResultSchema.parse(data);
|
|
@@ -4428,7 +4450,7 @@ noteableIid, body) {
|
|
|
4428
4450
|
});
|
|
4429
4451
|
if (!response.ok) {
|
|
4430
4452
|
const errorText = await response.text();
|
|
4431
|
-
throw new
|
|
4453
|
+
throw new GitLabApiError(`GitLab API error: ${response.status} ${response.statusText}\n${errorText}`);
|
|
4432
4454
|
}
|
|
4433
4455
|
return await response.json();
|
|
4434
4456
|
}
|
|
@@ -4443,7 +4465,7 @@ async function getDraftNote(project_id, merge_request_iid, draft_note_id) {
|
|
|
4443
4465
|
const response = await fetch(`${getEffectiveApiUrl()}/projects/${encodeGitLabPathSegment(effectiveProjectId)}/merge_requests/${encodeGitLabPathSegment(merge_request_iid)}/draft_notes/${encodeGitLabPathSegment(draft_note_id)}`, { ...getFetchConfig() });
|
|
4444
4466
|
if (!response.ok) {
|
|
4445
4467
|
const errorText = await response.text();
|
|
4446
|
-
throw new
|
|
4468
|
+
throw new GitLabApiError(`GitLab API error: ${response.status} ${response.statusText}\n${errorText}`);
|
|
4447
4469
|
}
|
|
4448
4470
|
const data = await response.json();
|
|
4449
4471
|
return GitLabDraftNoteSchema.parse(data);
|
|
@@ -4457,7 +4479,7 @@ async function listDraftNotes(projectId, mergeRequestIid) {
|
|
|
4457
4479
|
});
|
|
4458
4480
|
if (!response.ok) {
|
|
4459
4481
|
const errorText = await response.text();
|
|
4460
|
-
throw new
|
|
4482
|
+
throw new GitLabApiError(`GitLab API error: ${response.status} ${response.statusText}\n${errorText}`);
|
|
4461
4483
|
}
|
|
4462
4484
|
const data = await response.json();
|
|
4463
4485
|
return z.array(GitLabDraftNoteSchema).parse(data);
|
|
@@ -4492,7 +4514,7 @@ async function createDraftNote(projectId, mergeRequestIid, body, inReplyToDiscus
|
|
|
4492
4514
|
});
|
|
4493
4515
|
if (!response.ok) {
|
|
4494
4516
|
const errorText = await response.text();
|
|
4495
|
-
throw new
|
|
4517
|
+
throw new GitLabApiError(`GitLab API error: ${response.status} ${response.statusText}\n${errorText}`);
|
|
4496
4518
|
}
|
|
4497
4519
|
const data = await response.json();
|
|
4498
4520
|
return GitLabDraftNoteSchema.parse(data);
|
|
@@ -4527,7 +4549,7 @@ async function updateDraftNote(projectId, mergeRequestIid, draftNoteId, body, po
|
|
|
4527
4549
|
});
|
|
4528
4550
|
if (!response.ok) {
|
|
4529
4551
|
const errorText = await response.text();
|
|
4530
|
-
throw new
|
|
4552
|
+
throw new GitLabApiError(`GitLab API error: ${response.status} ${response.statusText}\n${errorText}`);
|
|
4531
4553
|
}
|
|
4532
4554
|
const data = await response.json();
|
|
4533
4555
|
return GitLabDraftNoteSchema.parse(data);
|
|
@@ -4548,7 +4570,7 @@ async function deleteDraftNote(projectId, mergeRequestIid, draftNoteId) {
|
|
|
4548
4570
|
});
|
|
4549
4571
|
if (!response.ok) {
|
|
4550
4572
|
const errorText = await response.text();
|
|
4551
|
-
throw new
|
|
4573
|
+
throw new GitLabApiError(`GitLab API error: ${response.status} ${response.statusText}\n${errorText}`);
|
|
4552
4574
|
}
|
|
4553
4575
|
}
|
|
4554
4576
|
/**
|
|
@@ -4567,7 +4589,7 @@ async function publishDraftNote(projectId, mergeRequestIid, draftNoteId) {
|
|
|
4567
4589
|
});
|
|
4568
4590
|
if (!response.ok) {
|
|
4569
4591
|
const errorText = await response.text();
|
|
4570
|
-
throw new
|
|
4592
|
+
throw new GitLabApiError(`GitLab API error: ${response.status} ${response.statusText}\n${errorText}`);
|
|
4571
4593
|
}
|
|
4572
4594
|
// Handle empty response (204 No Content) or successful response
|
|
4573
4595
|
const responseText = await response.text();
|
|
@@ -4648,7 +4670,7 @@ async function bulkPublishDraftNotes(projectId, mergeRequestIid, options = {}) {
|
|
|
4648
4670
|
});
|
|
4649
4671
|
if (!response.ok) {
|
|
4650
4672
|
const errorText = await response.text();
|
|
4651
|
-
throw new
|
|
4673
|
+
throw new GitLabApiError(`GitLab API error: ${response.status} ${response.statusText}\n${errorText}`);
|
|
4652
4674
|
}
|
|
4653
4675
|
// Handle empty response (204 No Content) or successful response
|
|
4654
4676
|
const responseText = await response.text();
|
|
@@ -4679,7 +4701,7 @@ async function resolveMergeRequestThread(projectId, mergeRequestIid, discussionI
|
|
|
4679
4701
|
});
|
|
4680
4702
|
if (!response.ok) {
|
|
4681
4703
|
const errorText = await response.text();
|
|
4682
|
-
throw new
|
|
4704
|
+
throw new GitLabApiError(`GitLab API error: ${response.status} ${response.statusText}\n${errorText}`);
|
|
4683
4705
|
}
|
|
4684
4706
|
}
|
|
4685
4707
|
/**
|
|
@@ -5271,7 +5293,7 @@ async function getPipeline(projectId, pipelineId, signal) {
|
|
|
5271
5293
|
signal,
|
|
5272
5294
|
});
|
|
5273
5295
|
if (response.status === 404) {
|
|
5274
|
-
throw new
|
|
5296
|
+
throw new GitLabApiError(`Pipeline not found`);
|
|
5275
5297
|
}
|
|
5276
5298
|
await handleGitLabError(response);
|
|
5277
5299
|
const data = await response.json();
|
|
@@ -5344,7 +5366,7 @@ async function getDeployment(projectId, deploymentId) {
|
|
|
5344
5366
|
...getFetchConfig(),
|
|
5345
5367
|
});
|
|
5346
5368
|
if (response.status === 404) {
|
|
5347
|
-
throw new
|
|
5369
|
+
throw new GitLabApiError(`Deployment not found`);
|
|
5348
5370
|
}
|
|
5349
5371
|
await handleGitLabError(response);
|
|
5350
5372
|
const data = await response.json();
|
|
@@ -5396,7 +5418,7 @@ async function getEnvironment(projectId, environmentId) {
|
|
|
5396
5418
|
...getFetchConfig(),
|
|
5397
5419
|
});
|
|
5398
5420
|
if (response.status === 404) {
|
|
5399
|
-
throw new
|
|
5421
|
+
throw new GitLabApiError(`Environment not found`);
|
|
5400
5422
|
}
|
|
5401
5423
|
await handleGitLabError(response);
|
|
5402
5424
|
const data = await response.json();
|
|
@@ -5449,7 +5471,7 @@ async function listPipelineJobs(projectId, pipelineId, options = {}) {
|
|
|
5449
5471
|
...getFetchConfig(),
|
|
5450
5472
|
});
|
|
5451
5473
|
if (response.status === 404) {
|
|
5452
|
-
throw new
|
|
5474
|
+
throw new GitLabApiError(`Pipeline not found`);
|
|
5453
5475
|
}
|
|
5454
5476
|
await handleGitLabError(response);
|
|
5455
5477
|
const data = await response.json();
|
|
@@ -5472,7 +5494,7 @@ async function listPipelineTriggerJobs(projectId, pipelineId, options = {}) {
|
|
|
5472
5494
|
...getFetchConfig(),
|
|
5473
5495
|
});
|
|
5474
5496
|
if (response.status === 404) {
|
|
5475
|
-
throw new
|
|
5497
|
+
throw new GitLabApiError(`Pipeline not found`);
|
|
5476
5498
|
}
|
|
5477
5499
|
await handleGitLabError(response);
|
|
5478
5500
|
const data = await response.json();
|
|
@@ -5486,7 +5508,7 @@ async function getPipelineJob(projectId, jobId, signal) {
|
|
|
5486
5508
|
signal,
|
|
5487
5509
|
});
|
|
5488
5510
|
if (response.status === 404) {
|
|
5489
|
-
throw new
|
|
5511
|
+
throw new GitLabApiError(`Job not found`);
|
|
5490
5512
|
}
|
|
5491
5513
|
await handleGitLabError(response);
|
|
5492
5514
|
const data = await response.json();
|
|
@@ -5514,7 +5536,7 @@ async function getPipelineJobOutput(projectId, jobId, limit, offset) {
|
|
|
5514
5536
|
},
|
|
5515
5537
|
});
|
|
5516
5538
|
if (response.status === 404) {
|
|
5517
|
-
throw new
|
|
5539
|
+
throw new GitLabApiError(`Job trace not found or job is not finished yet`);
|
|
5518
5540
|
}
|
|
5519
5541
|
await handleGitLabError(response);
|
|
5520
5542
|
const fullTrace = await response.text();
|
|
@@ -5576,7 +5598,7 @@ async function listJobArtifacts(projectId, jobId, options = {}) {
|
|
|
5576
5598
|
...getFetchConfig(),
|
|
5577
5599
|
});
|
|
5578
5600
|
if (response.status === 404) {
|
|
5579
|
-
throw new
|
|
5601
|
+
throw new GitLabApiError(`Job artifacts not found. The job may not have produced artifacts or the job ID is invalid.`);
|
|
5580
5602
|
}
|
|
5581
5603
|
await handleGitLabError(response);
|
|
5582
5604
|
const data = await response.json();
|
|
@@ -5596,7 +5618,7 @@ async function downloadJobArtifacts(projectId, jobId, localPath) {
|
|
|
5596
5618
|
const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeGitLabPathSegment(effectiveProjectId)}/jobs/${encodeGitLabPathSegment(jobId)}/artifacts`);
|
|
5597
5619
|
const response = await fetchWithValidatedRedirects(url.toString(), downloadRedirectOptions());
|
|
5598
5620
|
if (response.status === 404) {
|
|
5599
|
-
throw new
|
|
5621
|
+
throw new GitLabApiError(`Job artifacts not found. The job may not have produced artifacts or the job ID is invalid.`);
|
|
5600
5622
|
}
|
|
5601
5623
|
await handleGitLabError(response);
|
|
5602
5624
|
const filename = `artifacts_job_${encodeGitLabPathSegment(jobId)}.zip`;
|
|
@@ -5622,7 +5644,7 @@ async function getJobArtifactFile(projectId, jobId, artifactPath) {
|
|
|
5622
5644
|
const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeGitLabPathSegment(effectiveProjectId)}/jobs/${encodeGitLabPathSegment(jobId)}/artifacts/${encodedArtifactPath}`);
|
|
5623
5645
|
const response = await fetchWithValidatedRedirects(url.toString(), downloadRedirectOptions());
|
|
5624
5646
|
if (response.status === 404) {
|
|
5625
|
-
throw new
|
|
5647
|
+
throw new GitLabApiError(`Artifact file not found: ${artifactPath}`);
|
|
5626
5648
|
}
|
|
5627
5649
|
await handleGitLabError(response);
|
|
5628
5650
|
return await response.text();
|
|
@@ -6038,10 +6060,10 @@ async function getRepositoryTree(options) {
|
|
|
6038
6060
|
queryParams.append("pagination", options.pagination);
|
|
6039
6061
|
const response = await fetch(`${getEffectiveApiUrl()}/projects/${encodeGitLabPathSegment(getEffectiveProjectId(options.project_id))}/repository/tree?${queryParams.toString()}`, { ...getFetchConfig() });
|
|
6040
6062
|
if (response.status === 404) {
|
|
6041
|
-
throw new
|
|
6063
|
+
throw new GitLabApiError("Repository or path not found");
|
|
6042
6064
|
}
|
|
6043
6065
|
if (!response.ok) {
|
|
6044
|
-
throw new
|
|
6066
|
+
throw new GitLabApiError(`Failed to get repository tree: ${response.statusText}`);
|
|
6045
6067
|
}
|
|
6046
6068
|
const data = await response.json();
|
|
6047
6069
|
const items = z.array(GitLabTreeItemSchema).parse(data);
|
|
@@ -11987,6 +12009,22 @@ async function initializeServerByTransportMode(mode) {
|
|
|
11987
12009
|
}
|
|
11988
12010
|
}
|
|
11989
12011
|
}
|
|
12012
|
+
/**
|
|
12013
|
+
* Construct the OAuth client when OAuth is enabled. No network: the token is
|
|
12014
|
+
* acquired lazily on the first tool call via ensureValidOAuthToken, which
|
|
12015
|
+
* avoids blocking startup and opening a browser on transient boot-time errors.
|
|
12016
|
+
* Shared by the MCP server and the human CLI.
|
|
12017
|
+
*/
|
|
12018
|
+
function initOAuthClient() {
|
|
12019
|
+
if (!USE_OAUTH) {
|
|
12020
|
+
return;
|
|
12021
|
+
}
|
|
12022
|
+
if (isCleartextRemoteUrl(GITLAB_API_URL)) {
|
|
12023
|
+
throw new Error(`Refusing to send OAuth tokens over cleartext HTTP to ${GITLAB_API_URL}. ` +
|
|
12024
|
+
"Use an https:// GITLAB_API_URL (http:// is only allowed for localhost).");
|
|
12025
|
+
}
|
|
12026
|
+
oauthClient = createGitLabOAuthClient(GITLAB_API_URL.replace(/\/api\/v4$/, ""));
|
|
12027
|
+
}
|
|
11990
12028
|
/**
|
|
11991
12029
|
* Initialize and run the server
|
|
11992
12030
|
* Main entry point for server startup
|
|
@@ -11999,12 +12037,7 @@ async function runServer() {
|
|
|
11999
12037
|
if (USE_OAUTH) {
|
|
12000
12038
|
logger.info("Using OAuth authentication...");
|
|
12001
12039
|
try {
|
|
12002
|
-
|
|
12003
|
-
// Construct the client synchronously (no network). The token is
|
|
12004
|
-
// acquired lazily on the first tool call via ensureValidOAuthToken,
|
|
12005
|
-
// by which point the network is ready. This avoids blocking startup
|
|
12006
|
-
// and avoids opening a browser on transient boot-time network errors.
|
|
12007
|
-
oauthClient = createGitLabOAuthClient(gitlabBaseUrl);
|
|
12040
|
+
initOAuthClient();
|
|
12008
12041
|
logger.info("OAuth enabled; token acquired lazily on first use.");
|
|
12009
12042
|
}
|
|
12010
12043
|
catch (error) {
|
|
@@ -12074,6 +12107,7 @@ async function main() {
|
|
|
12074
12107
|
process.exit(2);
|
|
12075
12108
|
}
|
|
12076
12109
|
try {
|
|
12110
|
+
initOAuthClient();
|
|
12077
12111
|
const result = await handleToolCall({ name: cli.toolName, arguments: cli.args });
|
|
12078
12112
|
const masked = maskCliToolResult(cli.toolName, result);
|
|
12079
12113
|
const formatted = formatToolOutput({
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import * as fs from "node:fs";
|
|
4
|
+
import * as http from "node:http";
|
|
5
|
+
import * as os from "node:os";
|
|
6
|
+
import * as path from "node:path";
|
|
7
|
+
import * as url from "node:url";
|
|
8
|
+
import { describe, it } from "node:test";
|
|
9
|
+
const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
|
|
10
|
+
const CLI_ENTRY = path.resolve(__dirname, "../../build/index.js");
|
|
11
|
+
const CLI_TIMEOUT_MS = 30_000;
|
|
12
|
+
const MR_FIXTURE = {
|
|
13
|
+
id: 1,
|
|
14
|
+
iid: 1,
|
|
15
|
+
project_id: 7,
|
|
16
|
+
title: "Hello MR",
|
|
17
|
+
description: "d",
|
|
18
|
+
state: "opened",
|
|
19
|
+
web_url: "http://example.test/mr/1",
|
|
20
|
+
source_branch: "feature",
|
|
21
|
+
target_branch: "main",
|
|
22
|
+
author: { id: 1, username: "u", name: "U", state: "active", avatar_url: null, web_url: "http://example.test/u" },
|
|
23
|
+
created_at: "2026-01-01T00:00:00Z",
|
|
24
|
+
updated_at: "2026-01-01T00:00:00Z",
|
|
25
|
+
merged_at: null,
|
|
26
|
+
closed_at: null,
|
|
27
|
+
merge_commit_sha: null,
|
|
28
|
+
};
|
|
29
|
+
function buildCleanEnv(extra) {
|
|
30
|
+
const env = {};
|
|
31
|
+
for (const [key, value] of Object.entries(process.env)) {
|
|
32
|
+
if (value !== undefined && !key.startsWith("GITLAB_")) {
|
|
33
|
+
env[key] = value;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return { ...env, ...extra };
|
|
37
|
+
}
|
|
38
|
+
function startFakeGitLabAsync(onRequest) {
|
|
39
|
+
const server = http.createServer((req, res) => {
|
|
40
|
+
onRequest(req.headers.authorization);
|
|
41
|
+
res.setHeader("content-type", "application/json");
|
|
42
|
+
res.end(JSON.stringify(MR_FIXTURE));
|
|
43
|
+
});
|
|
44
|
+
return new Promise(resolve => server.listen(0, "127.0.0.1", () => resolve(server)));
|
|
45
|
+
}
|
|
46
|
+
function getPort(server) {
|
|
47
|
+
const address = server.address();
|
|
48
|
+
if (address === null || typeof address === "string") {
|
|
49
|
+
throw new Error("fake GitLab server is not listening on a TCP port");
|
|
50
|
+
}
|
|
51
|
+
return address.port;
|
|
52
|
+
}
|
|
53
|
+
function runCliAsync(env) {
|
|
54
|
+
return new Promise((resolve, reject) => {
|
|
55
|
+
const child = spawn(process.execPath, [CLI_ENTRY, "mr", "view", "--project-id", "g/p", "--mr-iid", "1"], {
|
|
56
|
+
env,
|
|
57
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
58
|
+
timeout: CLI_TIMEOUT_MS,
|
|
59
|
+
killSignal: "SIGKILL",
|
|
60
|
+
});
|
|
61
|
+
let stdout = "";
|
|
62
|
+
let stderr = "";
|
|
63
|
+
child.stdout.on("data", chunk => {
|
|
64
|
+
stdout += String(chunk);
|
|
65
|
+
});
|
|
66
|
+
child.stderr.on("data", chunk => {
|
|
67
|
+
stderr += String(chunk);
|
|
68
|
+
});
|
|
69
|
+
child.on("error", reject);
|
|
70
|
+
child.on("close", exitCode => resolve({ exitCode, stdout, stderr }));
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
function buildOAuthEnv(apiUrl, tmpDir) {
|
|
74
|
+
const tokenPath = path.join(tmpDir, "token.json");
|
|
75
|
+
fs.writeFileSync(tokenPath, JSON.stringify({
|
|
76
|
+
access_token: "oauth-cli-test",
|
|
77
|
+
refresh_token: "refresh",
|
|
78
|
+
expires_in: 7200,
|
|
79
|
+
created_at: Date.now(),
|
|
80
|
+
token_type: "Bearer",
|
|
81
|
+
}));
|
|
82
|
+
return {
|
|
83
|
+
GITLAB_API_URL: apiUrl,
|
|
84
|
+
GITLAB_USE_OAUTH: "true",
|
|
85
|
+
GITLAB_OAUTH_CLIENT_ID: "client-id",
|
|
86
|
+
GITLAB_OAUTH_TOKEN_PATH: tokenPath,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
async function runMrViewAgainstFakeGitLabAsync(buildEnv) {
|
|
90
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "gitlab-cli-credentials-"));
|
|
91
|
+
let authorization;
|
|
92
|
+
const server = await startFakeGitLabAsync(value => {
|
|
93
|
+
authorization = value;
|
|
94
|
+
});
|
|
95
|
+
try {
|
|
96
|
+
const apiUrl = `http://127.0.0.1:${getPort(server)}/api/v4`;
|
|
97
|
+
const result = await runCliAsync(buildCleanEnv(buildEnv(apiUrl, tmpDir)));
|
|
98
|
+
return { ...result, authorization };
|
|
99
|
+
}
|
|
100
|
+
finally {
|
|
101
|
+
server.close();
|
|
102
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
describe("When the human CLI runs without an MCP client", () => {
|
|
106
|
+
describe("with a personal access token in the environment", () => {
|
|
107
|
+
it("should send the token as a bearer credential", async () => {
|
|
108
|
+
const run = await runMrViewAgainstFakeGitLabAsync(apiUrl => ({
|
|
109
|
+
GITLAB_API_URL: apiUrl,
|
|
110
|
+
GITLAB_PERSONAL_ACCESS_TOKEN: "glpat-cli-test",
|
|
111
|
+
}));
|
|
112
|
+
assert.equal(run.exitCode, 0);
|
|
113
|
+
assert.equal(run.authorization, "Bearer glpat-cli-test");
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
describe("with an OAuth token stored by the auth command", () => {
|
|
117
|
+
it("should send the stored token as a bearer credential", async () => {
|
|
118
|
+
const run = await runMrViewAgainstFakeGitLabAsync(buildOAuthEnv);
|
|
119
|
+
assert.equal(run.exitCode, 0);
|
|
120
|
+
assert.equal(run.authorization, "Bearer oauth-cli-test");
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
describe("with an OAuth token and GITLAB_IS_OLD enabled", () => {
|
|
124
|
+
it("should still send the token as a bearer credential", async () => {
|
|
125
|
+
const run = await runMrViewAgainstFakeGitLabAsync((apiUrl, tmpDir) => ({
|
|
126
|
+
...buildOAuthEnv(apiUrl, tmpDir),
|
|
127
|
+
GITLAB_IS_OLD: "true",
|
|
128
|
+
}));
|
|
129
|
+
assert.equal(run.exitCode, 0);
|
|
130
|
+
assert.equal(run.authorization, "Bearer oauth-cli-test");
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
describe("with OAuth enabled against a cleartext remote URL", () => {
|
|
134
|
+
it("should refuse to run before any network call", async () => {
|
|
135
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "gitlab-cli-credentials-"));
|
|
136
|
+
try {
|
|
137
|
+
const result = await runCliAsync(buildCleanEnv(buildOAuthEnv("http://gitlab.example.invalid/api/v4", tmpDir)));
|
|
138
|
+
assert.equal(result.exitCode, 1);
|
|
139
|
+
assert.match(result.stderr, /cleartext HTTP/);
|
|
140
|
+
}
|
|
141
|
+
finally {
|
|
142
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
describe("with no credentials at all", () => {
|
|
147
|
+
it("should exit with a usage error before any network call", async () => {
|
|
148
|
+
const run = await runMrViewAgainstFakeGitLabAsync(apiUrl => ({ GITLAB_API_URL: apiUrl }));
|
|
149
|
+
assert.equal(run.exitCode, 2);
|
|
150
|
+
assert.equal(run.authorization, undefined);
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
});
|