@zereight/mcp-gitlab 2.1.67 → 2.1.69

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.
Files changed (39) hide show
  1. package/README.ko.md +8 -4
  2. package/README.md +14 -5
  3. package/README.pt-BR.md +589 -0
  4. package/README.zh-CN.md +8 -4
  5. package/build/cli/args.js +298 -0
  6. package/build/cli/compact-mcp-result.js +87 -0
  7. package/build/cli/curated.js +243 -0
  8. package/build/cli/exposure.js +72 -0
  9. package/build/cli/help.js +108 -0
  10. package/build/cli/output.js +65 -0
  11. package/build/cli/replay-command.js +53 -0
  12. package/build/cli/router.js +158 -0
  13. package/build/cli/tables.js +117 -0
  14. package/build/cli-boolean-flags.js +38 -0
  15. package/build/cli-command.js +14 -4
  16. package/build/config.js +30 -7
  17. package/build/index.js +329 -44
  18. package/build/oauth.js +93 -10
  19. package/build/schemas.js +187 -0
  20. package/build/scripts/generate-tool-docs.js +7 -1
  21. package/build/scripts/tool-coverage/coverage.js +2 -0
  22. package/build/test/cli/args.test.js +175 -0
  23. package/build/test/cli/boolean-flags.test.js +106 -0
  24. package/build/test/cli/compact-mcp-result.test.js +93 -0
  25. package/build/test/cli/exposure.test.js +104 -0
  26. package/build/test/cli/load-config-snapshot.js +5 -0
  27. package/build/test/cli/output.test.js +74 -0
  28. package/build/test/cli/replay-command.test.js +37 -0
  29. package/build/test/cli/router.test.js +183 -0
  30. package/build/test/oauth-device-flow-tests.js +10 -0
  31. package/build/test/oauth-startup.test.js +204 -0
  32. package/build/test/test-dynamic-project-scope.js +162 -0
  33. package/build/test/test-geteffectiveprojectid.js +4 -2
  34. package/build/test/test-permission-mode.js +87 -0
  35. package/build/test/test-snippets.js +735 -0
  36. package/build/test/test-toolset-filtering.js +3 -0
  37. package/build/tools/denied-regex.js +38 -0
  38. package/build/tools/registry.js +42 -1
  39. package/package.json +1 -1
package/build/oauth.js CHANGED
@@ -19,6 +19,28 @@ function escapeHtml(str) {
19
19
  }
20
20
  // Track pending auth requests across multiple MCP instances
21
21
  const pendingAuthRequests = new Map();
22
+ const OAUTH_REFRESH_TIMEOUT_MS = 10000;
23
+ /**
24
+ * Decide whether a failed token-refresh response means the refresh token is
25
+ * genuinely dead (interactive re-login would fix it) versus a transient or
26
+ * configuration problem (where opening a browser is wrong).
27
+ *
28
+ * Returns true ONLY for a 400/401 whose JSON body has error "invalid_grant"
29
+ * or "invalid_token". Everything else — config errors (invalid_client,
30
+ * invalid_request), 5xx, 429, and non-JSON bodies — returns false.
31
+ */
32
+ export function isAuthInvalidTokenResponse(status, bodyText) {
33
+ if (status !== 400 && status !== 401) {
34
+ return false;
35
+ }
36
+ try {
37
+ const body = JSON.parse(bodyText);
38
+ return body.error === "invalid_grant" || body.error === "invalid_token";
39
+ }
40
+ catch {
41
+ return false;
42
+ }
43
+ }
22
44
  /**
23
45
  * Check if a port is already in use
24
46
  */
@@ -170,10 +192,14 @@ export class GitLabOAuth {
170
192
  "Content-Type": "application/x-www-form-urlencoded",
171
193
  },
172
194
  body: params.toString(),
195
+ signal: AbortSignal.timeout(OAUTH_REFRESH_TIMEOUT_MS),
173
196
  });
174
197
  if (!response.ok) {
175
- const errorText = await response.text();
176
- throw new Error(`Token refresh failed: ${response.status} ${errorText}`);
198
+ const bodyText = await response.text();
199
+ const authInvalid = isAuthInvalidTokenResponse(response.status, bodyText);
200
+ throw Object.assign(new Error(`Token refresh failed: ${response.status} ${bodyText}`), {
201
+ authInvalid,
202
+ });
177
203
  }
178
204
  const data = (await response.json());
179
205
  return {
@@ -230,7 +256,13 @@ export class GitLabOAuth {
230
256
  * Uses a shared server if port is already in use
231
257
  */
232
258
  async startOAuthFlow() {
233
- const callbackPort = parseInt(new URL(this.config.redirectUri).port || "8888");
259
+ let callbackPort = 8888;
260
+ try {
261
+ callbackPort = parseInt(new URL(this.config.redirectUri).port || "8888", 10);
262
+ }
263
+ catch {
264
+ // Fallback to default port on malformed redirectUri
265
+ }
234
266
  const requestId = crypto.randomUUID();
235
267
  // Check if port is already in use
236
268
  const portInUse = await isPortInUse(callbackPort);
@@ -529,8 +561,14 @@ export class GitLabOAuth {
529
561
  this.saveToken(tokenData);
530
562
  }
531
563
  catch (error) {
532
- logger.error({ err: error }, "Token refresh failed. Starting new OAuth flow...");
533
- tokenData = await this.startOAuthFlow();
564
+ if (error.authInvalid === true) {
565
+ logger.error({ err: error }, "Refresh token invalid, starting new OAuth flow...");
566
+ tokenData = await this.startOAuthFlow();
567
+ }
568
+ else {
569
+ logger.error({ err: error }, "Token refresh failed (transient/network), not opening browser.");
570
+ throw error;
571
+ }
534
572
  }
535
573
  }
536
574
  else {
@@ -588,11 +626,11 @@ export class GitLabOAuth {
588
626
  }
589
627
  }
590
628
  /**
591
- * Create and initialize a GitLabOAuth client.
592
- * Performs initial authentication (triggers browser flow if needed).
593
- * Returns the client instance and the initial access token.
629
+ * Construct a GitLabOAuth client from environment configuration.
630
+ * Does NOT perform any network access — no token is acquired here.
631
+ * Throws if neither GITLAB_OAUTH_CLIENT_ID nor GITLAB_OAUTH_TOKEN_SCRIPT is set.
594
632
  */
595
- export async function initializeOAuthClient(gitlabUrl = "https://gitlab.com") {
633
+ export function createGitLabOAuthClient(gitlabUrl = "https://gitlab.com") {
596
634
  const clientId = process.env.GITLAB_OAUTH_CLIENT_ID;
597
635
  const clientSecret = process.env.GITLAB_OAUTH_CLIENT_SECRET;
598
636
  const redirectUri = process.env.GITLAB_OAUTH_REDIRECT_URI || "http://127.0.0.1:8888/callback";
@@ -601,7 +639,7 @@ export async function initializeOAuthClient(gitlabUrl = "https://gitlab.com") {
601
639
  if (!clientId && !tokenScript) {
602
640
  throw new Error("GITLAB_OAUTH_CLIENT_ID or GITLAB_OAUTH_TOKEN_SCRIPT environment variable is required for OAuth authentication");
603
641
  }
604
- const oauth = new GitLabOAuth({
642
+ return new GitLabOAuth({
605
643
  clientId: clientId || "external-token-script",
606
644
  clientSecret,
607
645
  redirectUri,
@@ -610,6 +648,51 @@ export async function initializeOAuthClient(gitlabUrl = "https://gitlab.com") {
610
648
  tokenStoragePath,
611
649
  tokenScript,
612
650
  });
651
+ }
652
+ const inFlightTokenPromises = new WeakMap();
653
+ /**
654
+ * Ensure the caller holds a usable OAuth access token, delivering it via onToken.
655
+ * Returns cached token without network when still valid.
656
+ *
657
+ * NOTE: the fast path requires BOTH a held token and a valid stored token.
658
+ * Returning early on hasValidToken() alone would skip onToken after lazy
659
+ * startup (caller holds nothing yet) and leave requests unauthenticated.
660
+ * Concurrent callers coalesce on an in-flight promise to avoid racing
661
+ * duplicate refresh requests (which would fail on token rotation).
662
+ */
663
+ export async function ensureOAuthToken(client, currentToken, onToken) {
664
+ if (!client)
665
+ return;
666
+ // Fast path: token already resolved for this process and still valid.
667
+ if (currentToken && client.hasValidToken())
668
+ return;
669
+ let promise = inFlightTokenPromises.get(client);
670
+ if (!promise) {
671
+ const needsRefresh = !client.hasValidToken();
672
+ if (needsRefresh) {
673
+ logger.info("OAuth token expired or missing, refreshing...");
674
+ }
675
+ promise = (async () => {
676
+ const token = await client.getAccessToken();
677
+ if (needsRefresh) {
678
+ logger.info("OAuth token refreshed successfully");
679
+ }
680
+ return token;
681
+ })().finally(() => {
682
+ inFlightTokenPromises.delete(client);
683
+ });
684
+ inFlightTokenPromises.set(client, promise);
685
+ }
686
+ const token = await promise;
687
+ onToken(token);
688
+ }
689
+ /**
690
+ * Create and initialize a GitLabOAuth client.
691
+ * Performs initial authentication (triggers browser flow if needed).
692
+ * Returns the client instance and the initial access token.
693
+ */
694
+ export async function initializeOAuthClient(gitlabUrl = "https://gitlab.com") {
695
+ const oauth = createGitLabOAuthClient(gitlabUrl);
613
696
  // Single call: triggers browser flow if needed, or reads cached token
614
697
  const accessToken = await oauth.getAccessToken();
615
698
  return { client: oauth, accessToken };
package/build/schemas.js CHANGED
@@ -3847,6 +3847,193 @@ export const GitLabTagSchema = z.object({
3847
3847
  protected: z.boolean(),
3848
3848
  created_at: z.string().nullable(),
3849
3849
  });
3850
+ // --- Snippet schemas ---
3851
+ export const ListSnippetsSchema = z
3852
+ .object({
3853
+ project_id: z
3854
+ .coerce.string()
3855
+ .optional()
3856
+ .describe("Project ID or URL-encoded path. Omit for personal snippets, or the configured project when GITLAB_PROJECT_ID / GITLAB_ALLOWED_PROJECT_IDS is set."),
3857
+ })
3858
+ .merge(PaginationOptionsSchema);
3859
+ export const GetSnippetSchema = z.object({
3860
+ project_id: z
3861
+ .coerce.string()
3862
+ .optional()
3863
+ .describe("Project ID or URL-encoded path. Omit for personal snippets, or the configured project when GITLAB_PROJECT_ID / GITLAB_ALLOWED_PROJECT_IDS is set."),
3864
+ snippet_id: z.coerce.number().describe("The snippet ID"),
3865
+ include_content: coerceBooleanString
3866
+ .optional()
3867
+ .default(false)
3868
+ .describe("Whether to fetch the raw file content (default: false)"),
3869
+ ref: z
3870
+ .string()
3871
+ .optional()
3872
+ .describe("Branch, tag, or commit to fetch content from. Inferred from the snippet's raw_url when omitted."),
3873
+ });
3874
+ export const SnippetFileSchema = z.object({
3875
+ file_path: z.string().describe("File path within the snippet (e.g., 'src/script.py')"),
3876
+ content: z.string().describe("File content"),
3877
+ });
3878
+ export const SnippetFileUpdateActionSchema = z
3879
+ .object({
3880
+ action: z
3881
+ .enum(["create", "update", "delete", "move"])
3882
+ .describe("Type of action to perform on the file"),
3883
+ file_path: z
3884
+ .string()
3885
+ .optional()
3886
+ .describe("File path of the snippet file (required for all actions). For 'move', this is the new path; for 'create'/'update'/'delete', the target path."),
3887
+ previous_path: z
3888
+ .string()
3889
+ .optional()
3890
+ .describe("Previous path of the snippet file (required for 'move')."),
3891
+ content: z
3892
+ .string()
3893
+ .optional()
3894
+ .describe("Content of the snippet file (required for 'create' and 'update')."),
3895
+ })
3896
+ .superRefine((data, ctx) => {
3897
+ if (data.file_path === undefined) {
3898
+ ctx.addIssue({
3899
+ code: z.ZodIssueCode.custom,
3900
+ message: "file_path is required for all snippet file actions",
3901
+ path: ["file_path"],
3902
+ });
3903
+ }
3904
+ if ((data.action === "create" || data.action === "update") && data.content === undefined) {
3905
+ ctx.addIssue({
3906
+ code: z.ZodIssueCode.custom,
3907
+ message: `content is required for action: '${data.action}'`,
3908
+ path: ["content"],
3909
+ });
3910
+ }
3911
+ if (data.action === "move" && data.previous_path === undefined) {
3912
+ ctx.addIssue({
3913
+ code: z.ZodIssueCode.custom,
3914
+ message: "previous_path is required for action: 'move'",
3915
+ path: ["previous_path"],
3916
+ });
3917
+ }
3918
+ })
3919
+ .describe("Multi-file snippet update action. Mirrors GitLab's PUT /snippets/:id files[] payload.");
3920
+ export const CreateSnippetSchema = z
3921
+ .object({
3922
+ project_id: z
3923
+ .coerce.string()
3924
+ .optional()
3925
+ .describe("Project ID or URL-encoded path. Omit for a personal snippet, or the configured project when GITLAB_PROJECT_ID / GITLAB_ALLOWED_PROJECT_IDS is set."),
3926
+ title: z.string().describe("Snippet title"),
3927
+ file_name: z
3928
+ .string()
3929
+ .optional()
3930
+ .describe("File name for a single-file snippet (e.g., 'script.py'). Use together with content. Mutually exclusive with files[]."),
3931
+ content: z
3932
+ .string()
3933
+ .optional()
3934
+ .describe("File content for a single-file snippet. Use together with file_name."),
3935
+ files: z
3936
+ .array(SnippetFileSchema)
3937
+ .optional()
3938
+ .describe("Array of files for a multi-file snippet. Each item needs file_path and content. Mutually exclusive with file_name/content."),
3939
+ description: z.string().optional().describe("Optional snippet description"),
3940
+ visibility: z
3941
+ .enum(["private", "internal", "public"])
3942
+ .optional()
3943
+ .describe("Snippet visibility (default: private)"),
3944
+ })
3945
+ .refine(data => (data.files !== undefined && data.files.length > 0) ||
3946
+ (data.file_name !== undefined && data.content !== undefined), {
3947
+ message: "Provide either files[] (multi-file) or both file_name and content (single-file)",
3948
+ path: ["files"],
3949
+ })
3950
+ .refine(data => !(data.files !== undefined &&
3951
+ (data.file_name !== undefined || data.content !== undefined)), {
3952
+ message: "Cannot mix files[] with file_name/content — pick one shape",
3953
+ path: ["files"],
3954
+ });
3955
+ export const UpdateSnippetSchema = z
3956
+ .object({
3957
+ project_id: z
3958
+ .coerce.string()
3959
+ .optional()
3960
+ .describe("Project ID or URL-encoded path. Omit for personal snippets, or the configured project when GITLAB_PROJECT_ID / GITLAB_ALLOWED_PROJECT_IDS is set."),
3961
+ snippet_id: z.coerce.number().describe("The snippet ID to update"),
3962
+ title: z.string().optional().describe("New title"),
3963
+ file_name: z
3964
+ .string()
3965
+ .optional()
3966
+ .describe("File name to update. Provide together with content to replace a single-file snippet's contents."),
3967
+ content: z.string().optional().describe("New file content (requires file_name)"),
3968
+ files: z
3969
+ .array(SnippetFileUpdateActionSchema)
3970
+ .min(1, "files[] must be non-empty when provided")
3971
+ .optional()
3972
+ .describe("Multi-file update actions. Each item has 'action' (create/update/delete/move) plus the relevant path/content fields. Use this for renames (action: 'move' with previous_path), deletions, and additions. Mutually exclusive with file_name/content."),
3973
+ description: z.string().optional().describe("New description"),
3974
+ visibility: z
3975
+ .enum(["private", "internal", "public"])
3976
+ .optional()
3977
+ .describe("New visibility"),
3978
+ })
3979
+ .refine(data => data.title !== undefined ||
3980
+ data.content !== undefined ||
3981
+ data.file_name !== undefined ||
3982
+ data.files !== undefined ||
3983
+ data.description !== undefined ||
3984
+ data.visibility !== undefined, { message: "At least one field must be provided to update" })
3985
+ .refine(data => !(data.files !== undefined &&
3986
+ (data.file_name !== undefined || data.content !== undefined)), {
3987
+ message: "Cannot mix files[] with file_name/content — pick one shape",
3988
+ path: ["files"],
3989
+ })
3990
+ .refine(data => !(data.content !== undefined && data.file_name === undefined), {
3991
+ message: "content requires file_name",
3992
+ path: ["content"],
3993
+ })
3994
+ .refine(data => !(data.file_name !== undefined && data.content === undefined), {
3995
+ message: "file_name requires content — renaming a file alone is not supported (the modern GitLab snippet API requires a files[] action with previous_path)",
3996
+ path: ["file_name"],
3997
+ });
3998
+ export const DeleteSnippetSchema = z.object({
3999
+ project_id: z
4000
+ .coerce.string()
4001
+ .optional()
4002
+ .describe("Project ID or URL-encoded path. Omit for personal snippets, or the configured project when GITLAB_PROJECT_ID / GITLAB_ALLOWED_PROJECT_IDS is set."),
4003
+ snippet_id: z.coerce.number().describe("The snippet ID to delete"),
4004
+ });
4005
+ export const GitLabSnippetAuthorSchema = z
4006
+ .object({
4007
+ id: z.number().optional(),
4008
+ username: z.string().optional(),
4009
+ name: z.string().optional(),
4010
+ state: z.string().optional(),
4011
+ avatar_url: z.string().nullable().optional(),
4012
+ web_url: z.string().optional(),
4013
+ })
4014
+ .passthrough();
4015
+ export const GitLabSnippetFileSchema = z
4016
+ .object({
4017
+ path: z.string(),
4018
+ raw_url: z.string().optional(),
4019
+ })
4020
+ .passthrough();
4021
+ export const GitLabSnippetSchema = z
4022
+ .object({
4023
+ id: z.number(),
4024
+ title: z.string(),
4025
+ description: z.string().nullable().optional(),
4026
+ visibility: z.string().optional(),
4027
+ author: GitLabSnippetAuthorSchema.optional(),
4028
+ updated_at: z.string().optional(),
4029
+ created_at: z.string().optional(),
4030
+ web_url: z.string().optional(),
4031
+ raw_url: z.string().optional(),
4032
+ file_name: z.string().nullable().optional(),
4033
+ files: z.array(GitLabSnippetFileSchema).optional(),
4034
+ project_id: z.number().nullable().optional(),
4035
+ })
4036
+ .passthrough();
3850
4037
  export const GitLabTagSignatureSchema = z.object({
3851
4038
  signature_type: z.literal("X509"),
3852
4039
  verification_status: z.string(),
@@ -24,13 +24,14 @@ const LEGACY_TOGGLE_ENV = {
24
24
  function isDefaultToolset(id) {
25
25
  return TOOLSET_DEFINITIONS.find(d => d.id === id)?.isDefault ?? false;
26
26
  }
27
+ /** Returns the availability note for a toolset group, or undefined when it is on by default. */
27
28
  function computeToggleNote(id) {
28
29
  if (isDefaultToolset(id))
29
30
  return undefined;
30
31
  // Synthetic group for tools not in any TOOLSET_DEFINITIONS entry.
31
32
  // discover_tools is always exposed; execute_graphql is opt-in via GITLAB_TOOLS.
32
33
  if (id === "meta") {
33
- return "Mixed availability. `discover_tools` is always exposed (the server re-adds it after every toolset filter). `execute_graphql` is not part of any toolset — enable it explicitly with `GITLAB_TOOLS=execute_graphql`.";
34
+ return "Mixed availability. `discover_tools` is always exposed (the server re-adds it after every toolset filter). `execute_graphql` is not part of any toolset — enable it explicitly with `GITLAB_TOOLS=execute_graphql`. Tools excluded from the list (toolset / `GITLAB_TOOLS` / `GITLAB_DENIED_TOOLS_REGEX`) are also rejected on direct calls; `GITLAB_TOOL_POLICY_HIDDEN` tools stay callable unless they match `GITLAB_DENIED_TOOLS_REGEX`.";
34
35
  }
35
36
  const legacy = LEGACY_TOGGLE_ENV[id];
36
37
  if (legacy) {
@@ -123,6 +124,10 @@ const GROUP_META = {
123
124
  title: "GitLab Orbit",
124
125
  blurb: "Query the Orbit SDLC knowledge graph (Beta; Premium/Ultimate). Graph queries consume GitLab credits; schema, status, and tool listing are free.",
125
126
  },
127
+ snippets: {
128
+ title: "Snippets",
129
+ blurb: "Snippet CRUD — list, get (with optional file content), create, update, and delete personal or project snippets.",
130
+ },
126
131
  };
127
132
  const GROUP_ORDER = [
128
133
  "projects",
@@ -146,6 +151,7 @@ const GROUP_ORDER = [
146
151
  "dependency_proxy",
147
152
  "vulnerabilities",
148
153
  "orbit",
154
+ "snippets",
149
155
  ];
150
156
  // Authoritative classification — uses the `readOnlyTools` set from
151
157
  // tools/registry.ts. That set is what the server itself consults to decide
@@ -50,6 +50,7 @@ export const GROUP_ORDER = [
50
50
  "dependency_proxy",
51
51
  "vulnerabilities",
52
52
  "orbit",
53
+ "snippets",
53
54
  ];
54
55
  export const GROUP_TITLE = {
55
56
  merge_requests: "Merge Requests",
@@ -73,6 +74,7 @@ export const GROUP_TITLE = {
73
74
  dependency_proxy: "Dependency Proxy",
74
75
  vulnerabilities: "Vulnerabilities",
75
76
  orbit: "GitLab Orbit",
77
+ snippets: "Snippets",
76
78
  meta: "Meta & GraphQL",
77
79
  };
78
80
  export const CANARY_INVOKED = [
@@ -0,0 +1,175 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import { allTools } from "../../tools/registry.js";
4
+ import { CreateWebhookSchema, GetPipelineJobOutputSchema, ProtectBranchSchema, } from "../../schemas.js";
5
+ import { applyFlagAliases, CliUsageError, parseArgv, parseToolArgs } from "../../cli/args.js";
6
+ function argv(...args) {
7
+ return ["node", "index.js", ...args];
8
+ }
9
+ function schemaFor(toolName) {
10
+ const tool = allTools.find(entry => entry.name === toolName);
11
+ assert.ok(tool, `missing tool ${toolName}`);
12
+ return tool.inputSchema;
13
+ }
14
+ describe("When parseArgv reads a command line", () => {
15
+ describe("with a generic tool invocation", () => {
16
+ it("should keep positionals and kebab flags as snake_case tool args", () => {
17
+ const parsed = parseArgv(argv("tool", "list_issues", "--project-id", "123", "--state", "opened"));
18
+ assert.deepEqual(parsed.positionals, ["tool", "list_issues"]);
19
+ assert.equal(parsed.toolFlags.project_id, "123");
20
+ assert.equal(parsed.toolFlags.state, "opened");
21
+ assert.equal(parsed.output, undefined);
22
+ });
23
+ });
24
+ describe("with global flags mixed in", () => {
25
+ it("should not treat --token --output --yes as tool flags", () => {
26
+ const parsed = parseArgv(argv("tool", "whoami", "--token", "glpat-xxxxxxxxxxxxxxxxxxxx", "--output=json", "--yes"));
27
+ assert.equal(parsed.output, "json");
28
+ assert.equal(parsed.yes, true);
29
+ assert.deepEqual(parsed.toolFlags, {});
30
+ });
31
+ });
32
+ describe("with --args-json", () => {
33
+ it("should parse the object and ignore prototype keys", () => {
34
+ const parsed = parseArgv(argv("tool", "create_issue", "--args-json", '{"title":"bug","__proto__":{"x":1}}'));
35
+ assert.equal(parsed.argsJson?.title, "bug");
36
+ assert.equal(Object.prototype.hasOwnProperty.call(parsed.argsJson, "__proto__"), false);
37
+ });
38
+ });
39
+ describe("with --use-oauth before a curated command", () => {
40
+ it("should keep mr list as positionals instead of consuming mr as the flag value", () => {
41
+ const parsed = parseArgv(argv("--use-oauth", "mr", "list"));
42
+ assert.deepEqual(parsed.positionals, ["mr", "list"]);
43
+ });
44
+ });
45
+ describe("with --read-only false before a curated command", () => {
46
+ it("should keep false as the flag value and mr list as positionals", () => {
47
+ const parsed = parseArgv(argv("--read-only", "false", "mr", "list"));
48
+ assert.deepEqual(parsed.positionals, ["mr", "list"]);
49
+ });
50
+ });
51
+ });
52
+ describe("When parseToolArgs validates a real schema", () => {
53
+ describe("with whoami", () => {
54
+ it("should accept an empty flag set", () => {
55
+ const args = parseToolArgs({
56
+ schema: schemaFor("whoami"),
57
+ flags: {},
58
+ argsJson: undefined,
59
+ extraArgs: undefined,
60
+ });
61
+ assert.deepEqual(args, {});
62
+ });
63
+ });
64
+ describe("with create_issue missing title", () => {
65
+ it("should throw a usage error for the required flag", () => {
66
+ assert.throws(() => parseToolArgs({
67
+ schema: schemaFor("create_issue"),
68
+ flags: { project_id: "123" },
69
+ argsJson: undefined,
70
+ extraArgs: undefined,
71
+ }), (error) => error instanceof CliUsageError && error.message.includes("--title"));
72
+ });
73
+ });
74
+ describe("with flags overlaying --args-json", () => {
75
+ it("should let flags win", () => {
76
+ const args = parseToolArgs({
77
+ schema: schemaFor("create_issue"),
78
+ flags: { title: "from-flag" },
79
+ argsJson: { project_id: "123", title: "from-json" },
80
+ extraArgs: undefined,
81
+ });
82
+ assert.equal(args.project_id, "123");
83
+ assert.equal(args.title, "from-flag");
84
+ });
85
+ });
86
+ describe("with an unknown flag", () => {
87
+ it("should throw a usage error", () => {
88
+ assert.throws(() => parseToolArgs({
89
+ schema: schemaFor("whoami"),
90
+ flags: { nope: "1" },
91
+ argsJson: undefined,
92
+ extraArgs: undefined,
93
+ }), (error) => error instanceof CliUsageError && error.message === "Unknown flag: --nope");
94
+ });
95
+ });
96
+ });
97
+ describe("When parseToolArgs coerces scalar flags from the schema", () => {
98
+ describe("with a number flag", () => {
99
+ it("should pass a number that the tool schema accepts", () => {
100
+ const args = parseToolArgs({
101
+ schema: schemaFor("get_pipeline_job_output"),
102
+ flags: { project_id: "1", job_id: "9", limit: "10" },
103
+ argsJson: undefined,
104
+ extraArgs: undefined,
105
+ });
106
+ assert.equal(GetPipelineJobOutputSchema.safeParse(args).success, true);
107
+ });
108
+ });
109
+ describe("with a boolean flag", () => {
110
+ it("should pass a boolean that the tool schema accepts", () => {
111
+ const args = parseToolArgs({
112
+ schema: schemaFor("create_webhook"),
113
+ flags: { project_id: "1", url: "https://example.com/hook", push_events: "false" },
114
+ argsJson: undefined,
115
+ extraArgs: undefined,
116
+ });
117
+ assert.equal(CreateWebhookSchema.safeParse(args).success, true);
118
+ });
119
+ });
120
+ describe("with an integer flag", () => {
121
+ it("should pass an integer that the tool schema accepts", () => {
122
+ const args = parseToolArgs({
123
+ schema: schemaFor("protect_branch"),
124
+ flags: { project_id: "1", branch_name: "main", push_access_level: "40" },
125
+ argsJson: undefined,
126
+ extraArgs: undefined,
127
+ });
128
+ assert.strictEqual(args.push_access_level, 40);
129
+ assert.equal(ProtectBranchSchema.safeParse(args).success, true);
130
+ });
131
+ });
132
+ describe("with a field that also accepts strings", () => {
133
+ it("should keep the raw string", () => {
134
+ const args = parseToolArgs({
135
+ schema: schemaFor("list_webhook_events"),
136
+ flags: { project_id: "1", hook_id: "2", status: "500" },
137
+ argsJson: undefined,
138
+ extraArgs: undefined,
139
+ });
140
+ assert.equal(args.status, "500");
141
+ });
142
+ });
143
+ describe("with a non-boolean value for a boolean flag", () => {
144
+ it("should throw a usage error", () => {
145
+ assert.throws(() => parseToolArgs({
146
+ schema: schemaFor("create_webhook"),
147
+ flags: { project_id: "1", url: "https://example.com/hook", push_events: "yes" },
148
+ argsJson: undefined,
149
+ extraArgs: undefined,
150
+ }), (error) => error instanceof CliUsageError && error.message === "--push-events must be true or false");
151
+ });
152
+ });
153
+ describe("with a fraction for an integer flag", () => {
154
+ it("should throw a usage error", () => {
155
+ assert.throws(() => parseToolArgs({
156
+ schema: schemaFor("protect_branch"),
157
+ flags: { project_id: "1", branch_name: "main", push_access_level: "40.5" },
158
+ argsJson: undefined,
159
+ extraArgs: undefined,
160
+ }), (error) => error instanceof CliUsageError &&
161
+ error.message === "--push-access-level must be an integer");
162
+ });
163
+ });
164
+ });
165
+ describe("When applyFlagAliases rewrites curated shorts", () => {
166
+ describe("with --mr-iid", () => {
167
+ it("should map onto merge_request_iid", () => {
168
+ const mapped = applyFlagAliases({ mr_iid: "45", project_id: "123" }, {
169
+ mr_iid: "merge_request_iid",
170
+ });
171
+ assert.equal(mapped.merge_request_iid, "45");
172
+ assert.equal(mapped.project_id, "123");
173
+ });
174
+ });
175
+ });
@@ -0,0 +1,106 @@
1
+ import assert from "node:assert/strict";
2
+ import { execFileSync } from "node:child_process";
3
+ import * as path from "node:path";
4
+ import * as url from "node:url";
5
+ import { describe, it } from "node:test";
6
+ import { isBooleanFlagLiteral, nextBooleanFlagValue } from "../../cli-boolean-flags.js";
7
+ const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
8
+ const SNAPSHOT_PATH = path.resolve(__dirname, "load-config-snapshot.ts");
9
+ function loadConfig(argv) {
10
+ const childEnv = {};
11
+ for (const [key, value] of Object.entries(process.env)) {
12
+ if (value !== undefined) {
13
+ childEnv[key] = value;
14
+ }
15
+ }
16
+ delete childEnv.GITLAB_READ_ONLY_MODE;
17
+ delete childEnv.GITLAB_USE_OAUTH;
18
+ const stdout = execFileSync(process.execPath, ["--import", "tsx/esm", SNAPSHOT_PATH, ...argv], {
19
+ env: childEnv,
20
+ encoding: "utf8",
21
+ stdio: ["ignore", "pipe", "pipe"],
22
+ });
23
+ return readBooleanConfigSnapshot(JSON.parse(stdout));
24
+ }
25
+ function readBooleanConfigSnapshot(value) {
26
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
27
+ throw new Error("config snapshot was not an object");
28
+ }
29
+ if (!("GITLAB_READ_ONLY_MODE" in value) || typeof value.GITLAB_READ_ONLY_MODE !== "boolean") {
30
+ throw new Error("GITLAB_READ_ONLY_MODE missing");
31
+ }
32
+ if (!("USE_OAUTH" in value) || typeof value.USE_OAUTH !== "boolean") {
33
+ throw new Error("USE_OAUTH missing");
34
+ }
35
+ return {
36
+ GITLAB_READ_ONLY_MODE: value.GITLAB_READ_ONLY_MODE,
37
+ USE_OAUTH: value.USE_OAUTH,
38
+ };
39
+ }
40
+ describe("When nextBooleanFlagValue reads the following token", () => {
41
+ describe("with a positional command", () => {
42
+ it("should treat the flag as true and leave the command", () => {
43
+ assert.deepEqual(nextBooleanFlagValue("mr"), { value: "true", consumeNext: false });
44
+ });
45
+ });
46
+ describe("with an explicit false", () => {
47
+ it("should consume the literal", () => {
48
+ assert.deepEqual(nextBooleanFlagValue("false"), { value: "false", consumeNext: true });
49
+ });
50
+ });
51
+ describe("with a missing token", () => {
52
+ it("should default to true", () => {
53
+ assert.deepEqual(nextBooleanFlagValue(undefined), { value: "true", consumeNext: false });
54
+ });
55
+ });
56
+ });
57
+ describe("When isBooleanFlagLiteral inspects a token", () => {
58
+ describe("with true", () => {
59
+ it("should accept it", () => {
60
+ assert.equal(isBooleanFlagLiteral("true"), true);
61
+ });
62
+ });
63
+ describe("with a command name", () => {
64
+ it("should reject it", () => {
65
+ assert.equal(isBooleanFlagLiteral("mr"), false);
66
+ });
67
+ });
68
+ });
69
+ describe("When config.ts parses space-separated boolean flags", () => {
70
+ describe("with --read-only as the last argument", () => {
71
+ it("should turn read-only mode on", () => {
72
+ const cfg = loadConfig(["--read-only"]);
73
+ assert.equal(cfg.GITLAB_READ_ONLY_MODE, true);
74
+ });
75
+ });
76
+ describe("with --read-only followed by another flag", () => {
77
+ it("should turn read-only mode on", () => {
78
+ const cfg = loadConfig(["--read-only", "--use-pipeline"]);
79
+ assert.equal(cfg.GITLAB_READ_ONLY_MODE, true);
80
+ });
81
+ });
82
+ describe("with an empty --read-only= assignment", () => {
83
+ it("should leave read-only mode off", () => {
84
+ const cfg = loadConfig(["--read-only="]);
85
+ assert.equal(cfg.GITLAB_READ_ONLY_MODE, false);
86
+ });
87
+ });
88
+ describe("with --read-only false", () => {
89
+ it("should keep read-only off", () => {
90
+ const cfg = loadConfig(["--read-only", "false"]);
91
+ assert.equal(cfg.GITLAB_READ_ONLY_MODE, false);
92
+ });
93
+ });
94
+ describe("with --read-only before a command", () => {
95
+ it("should turn read-only on without consuming mr", () => {
96
+ const cfg = loadConfig(["--read-only", "mr", "list"]);
97
+ assert.equal(cfg.GITLAB_READ_ONLY_MODE, true);
98
+ });
99
+ });
100
+ describe("with --use-oauth false", () => {
101
+ it("should keep oauth off", () => {
102
+ const cfg = loadConfig(["--use-oauth", "false"]);
103
+ assert.equal(cfg.USE_OAUTH, false);
104
+ });
105
+ });
106
+ });