Repository: google/clasp

Issue:
Regression: Redirect Port No longer works in 3.X
This is where the redirect port feature was introduced

Commit: https://github.com/google/clasp/commit/d55832e59d63c480ae591f7d1ecba457ebfafb7b#diff-84a5d130fb4cccb80f78601d140f1d5397dc0272f94cea672a8470539fcf6dc7

PR: https://github.com/google/clasp/pull/1020

In the new code, we don't expose a constructor to set the port:
https://github.com/google/clasp/blob/v3.0.0-alpha/src/auth/localhost_auth_code_flow.ts#L13

Looks like this was missed when the code base was refactored/restructured for 3.0.

Definition of Done:
- Update the constructor to enable setting a port, port should still default to 0
- Pass options.redirectPort to [src/auth/auth.ts](https://github.com/google/clasp/blob/master/src/auth/auth.ts#L190C5-L190C71)
- Add validation in src/commands/login.ts
- Add unit tests to prevent this from happening again




Discussion:
@e-dong: I can work on a PR for this@sqrrrl: If you need it, sure. For localhost, ephemeral ports are preferred, but if there's a case where that doesn't work (local firewall or something) and you need to set a fixed port, can add it back.@e-dong: My usecase is running clasp in a docker container, so I would prefer to publish a single port that matches my redirect port. Otherwise I would would have to run the container in host network mode.

I don't mind adding this back in, I'm working on adding some unit tests as well@sqrrrl: Makes sense, thanks for working on it!@e-dong: I think the best of 2 worlds is to generate a random port first and publish that port during `docker run`, so that way I use a different port for every new dev container I start up. I also set it as an env variable within the container so I can reference it when I run `clasp login --redirect-port ${REDIRECT_PORT}`

My PR is ready for review btw whenever you have a chance! I'm in not in a rush since I can install my local version of clasp for now.

Solution patch:
diff --git a/.mocharc.json b/.mocharc.json
index 0502bd8..f38d557 100644
--- a/.mocharc.json
+++ b/.mocharc.json
@@ -5,9 +5,9 @@
   "require": [
     "source-map-support/register.js"
   ],
-  "loader": "ts-node/esm",
+  "import": "./ts-node.register.js",
   "watch-files": [
     "src",
     "test"
   ]
-}
\ No newline at end of file
+}
diff --git a/gemini-extension.json b/gemini-extension.json
index fb5441d..f51bc7c 100644
--- a/gemini-extension.json
+++ b/gemini-extension.json
@@ -5,11 +5,13 @@
   "contextFileName": "gemini.md",
   "mcpServers": {
     "clasp": {
-      "command": "npx",
-      "args": ["-y", "@google/clasp@latest", "mcp"],
+      "command": "clasp",
+      "args": ["mcp"],
+      "trust": true,
       "env": {
         "IS_GEMINI_CLI_EXTENSION": "true"
-      }
+      },
+      "timeout": 60000
     }
   }
 }
diff --git a/package-lock.json b/package-lock.json
index 8b8d525..6b52e15 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -59,6 +59,7 @@
         "c8": "^10.1.3",
         "chai": "^5.2.0",
         "chai-as-promised": "^8.0.1",
+        "esmock": "^2.7.3",
         "mocha": "^11.2.2",
         "mock-fs": "^5.5.0",
         "nock": "^14.0.4",
@@ -141,6 +142,125 @@
         "node": ">=14.21.3"
       }
     },
+    "node_modules/@biomejs/cli-darwin-x64": {
+      "version": "1.9.4",
+      "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-1.9.4.tgz",
+      "integrity": "sha512-ngYBh/+bEedqkSevPVhLP4QfVPCpb+4BBe2p7Xs32dBgs7rh9nY2AIYUL6BgLw1JVXV8GlpKmb/hNiuIxfPfZg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT OR Apache-2.0",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=14.21.3"
+      }
+    },
+    "node_modules/@biomejs/cli-linux-arm64": {
+      "version": "1.9.4",
+      "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-1.9.4.tgz",
+      "integrity": "sha512-fJIW0+LYujdjUgJJuwesP4EjIBl/N/TcOX3IvIHJQNsAqvV2CHIogsmA94BPG6jZATS4Hi+xv4SkBBQSt1N4/g==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT OR Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=14.21.3"
+      }
+    },
+    "node_modules/@biomejs/cli-linux-arm64-musl": {
+      "version": "1.9.4",
+      "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-1.9.4.tgz",
+      "integrity": "sha512-v665Ct9WCRjGa8+kTr0CzApU0+XXtRgwmzIf1SeKSGAv+2scAlW6JR5PMFo6FzqqZ64Po79cKODKf3/AAmECqA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT OR Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=14.21.3"
+      }
+    },
+    "node_modules/@biomejs/cli-linux-x64": {
+      "version": "1.9.4",
+      "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-1.9.4.tgz",
+      "integrity": "sha512-lRCJv/Vi3Vlwmbd6K+oQ0KhLHMAysN8lXoCI7XeHlxaajk06u7G+UsFSO01NAs5iYuWKmVZjmiOzJ0OJmGsMwg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT OR Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=14.21.3"
+      }
+    },
+    "node_modules/@biomejs/cli-linux-x64-musl": {
+      "version": "1.9.4",
+      "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-1.9.4.tgz",
+      "integrity": "sha512-gEhi/jSBhZ2m6wjV530Yy8+fNqG8PAinM3oV7CyO+6c3CEh16Eizm21uHVsyVBEB6RIM8JHIl6AGYCv6Q6Q9Tg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT OR Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=14.21.3"
+      }
+    },
+    "node_modules/@biomejs/cli-win32-arm64": {
+      "version": "1.9.4",
+      "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-1.9.4.tgz",
+      "integrity": "sha512-tlbhLk+WXZmgwoIKwHIHEBZUwxml7bRJgk0X2sPyNR3S93cdRq6XulAZRQJ17FYGGzWne0fgrXBKpl7l4M87Hg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT OR Apache-2.0",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=14.21.3"
+      }
+    },
+    "node_modules/@biomejs/cli-win32-x64": {
+      "version": "1.9.4",
+      "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-1.9.4.tgz",
+      "integrity": "sha512-8Y5wMhVIPaWe6jw2H+KlEm4wP/f7EW3810ZLmDlrEEy5KvBsb9ECEfu/kMWD484ijfQ8+nIi0giMgu9g1UAuuA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT OR Apache-2.0",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=14.21.3"
+      }
+    },
     "node_modules/@commander-js/extra-typings": {
       "version": "13.1.0",
       "dev": true,
@@ -883,6 +1003,7 @@
     "node_modules/@types/node": {
       "version": "22.16.5",
       "license": "MIT",
+      "peer": true,
       "dependencies": {
         "undici-types": "~6.21.0"
       }
@@ -1313,6 +1434,7 @@
       "version": "5.2.1",
       "dev": true,
       "license": "MIT",
+      "peer": true,
       "dependencies": {
         "assertion-error": "^2.0.1",
         "check-error": "^2.1.1",
@@ -1521,6 +1643,7 @@
     "node_modules/commander": {
       "version": "13.1.0",
       "license": "MIT",
+      "peer": true,
       "engines": {
         "node": ">=18"
       }
@@ -1787,6 +1910,16 @@
         "url": "https://github.com/sponsors/sindresorhus"
       }
     },
+    "node_modules/esmock": {
+      "version": "2.7.3",
+      "resolved": "https://registry.npmjs.org/esmock/-/esmock-2.7.3.tgz",
+      "integrity": "sha512-/M/YZOjgyLaVoY6K83pwCsGE1AJQnj4S4GyXLYgi/Y79KL8EeW6WU7Rmjc89UO7jv6ec8+j34rKeWOfiLeEu0A==",
+      "dev": true,
+      "license": "ISC",
+      "engines": {
+        "node": ">=14.16.0"
+      }
+    },
     "node_modules/etag": {
       "version": "1.8.1",
       "license": "MIT",
@@ -1814,6 +1947,7 @@
     "node_modules/express": {
       "version": "5.1.0",
       "license": "MIT",
+      "peer": true,
       "dependencies": {
         "accepts": "^2.0.0",
         "body-parser": "^2.2.0",
@@ -2686,7 +2820,9 @@
       "license": "MIT"
     },
     "node_modules/js-yaml": {
-      "version": "4.1.0",
+      "version": "4.1.1",
+      "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
+      "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
@@ -3442,6 +3578,7 @@
     "node_modules/picomatch": {
       "version": "4.0.3",
       "license": "MIT",
+      "peer": true,
       "engines": {
         "node": ">=12"
       },
@@ -3835,6 +3972,8 @@
     },
     "node_modules/sinon": {
       "version": "20.0.0",
+      "resolved": "https://registry.npmjs.org/sinon/-/sinon-20.0.0.tgz",
+      "integrity": "sha512-+FXOAbdnj94AQIxH0w1v8gzNxkawVvNqE3jUzRLptR71Oykeu2RrQXXl/VQjKay+Qnh73fDt/oDfMo6xMeDQbQ==",
       "dev": true,
       "license": "BSD-3-Clause",
       "dependencies": {
@@ -4259,6 +4398,7 @@
       "version": "5.8.3",
       "devOptional": true,
       "license": "Apache-2.0",
+      "peer": true,
       "bin": {
         "tsc": "bin/tsc",
         "tsserver": "bin/tsserver"
@@ -4663,6 +4803,7 @@
     "node_modules/zod": {
       "version": "3.25.76",
       "license": "MIT",
+      "peer": true,
       "funding": {
         "url": "https://github.com/sponsors/colinhacks"
       }
diff --git a/package.json b/package.json
index 39d9dd4..6b692dc 100644
--- a/package.json
+++ b/package.json
@@ -102,6 +102,7 @@
     "c8": "^10.1.3",
     "chai": "^5.2.0",
     "chai-as-promised": "^8.0.1",
+    "esmock": "^2.7.3",
     "mocha": "^11.2.2",
     "mock-fs": "^5.5.0",
     "nock": "^14.0.4",
diff --git a/src/auth/auth.ts b/src/auth/auth.ts
index 3c4275f..c8b799b 100644
--- a/src/auth/auth.ts
+++ b/src/auth/auth.ts
@@ -187,7 +187,7 @@ export async function authorize(options: AuthorizationOptions) {
     flow = new ServerlessAuthorizationCodeFlow(options.oauth2Client);
   } else {
     debug('Starting auth with local server flow');
-    flow = new LocalServerAuthorizationCodeFlow(options.oauth2Client);
+    flow = new LocalServerAuthorizationCodeFlow(options.oauth2Client, options.redirectPort);
   }
 
   const client = await flow.authorize(options.scopes);
diff --git a/src/auth/localhost_auth_code_flow.ts b/src/auth/localhost_auth_code_flow.ts
index d1e7d84..cecf51c 100644
--- a/src/auth/localhost_auth_code_flow.ts
+++ b/src/auth/localhost_auth_code_flow.ts
@@ -35,8 +35,9 @@ export class LocalServerAuthorizationCodeFlow extends AuthorizationCodeFlow {
   protected server: Server | undefined;
   protected port = 0;
 
-  constructor(oauth2client: OAuth2Client) {
+  constructor(oauth2client: OAuth2Client, port?: number) {
     super(oauth2client);
+    this.port = port as number;
   }
 
   /**
diff --git a/src/auth/serverless_auth_code_flow.ts b/src/auth/serverless_auth_code_flow.ts
index 12f9947..a846435 100644
--- a/src/auth/serverless_auth_code_flow.ts
+++ b/src/auth/serverless_auth_code_flow.ts
@@ -64,8 +64,7 @@ export class ServerlessAuthorizationCodeFlow extends AuthorizationCodeFlow {
     console.log(urlMessage);
 
     const promptMessage = intl.formatMessage({
-      defaultMessage:
-        'After authorizing, copy the URL from your browser and paste it here:',
+      defaultMessage: 'After authorizing, copy the URL from your browser and paste it here:',
     });
 
     const answer = await inquirer.prompt([
diff --git a/src/commands/login.ts b/src/commands/login.ts
index 02248b4..02bcf06 100644
--- a/src/commands/login.ts
+++ b/src/commands/login.ts
@@ -23,6 +23,7 @@ import {AuthInfo, authorize, getUnauthorizedOuth2Client, getUserInfo} from '../a
 import {Clasp} from '../core/clasp.js';
 import {intl} from '../intl.js';
 import {GlobalOptions} from './utils.js';
+import {validateOptionInt} from './validate.js';
 
 const DEFAULT_SCOPES = [
   // Default to clasp scopes
@@ -54,7 +55,9 @@ export const command = new Command('login')
     '--use-project-scopes',
     'Use the scopes from the current project manifest. Used only when authorizing access for the run command.',
   )
-  .option('--redirect-port <port>', 'Specify a custom port for the redirect URL.')
+  .option('--redirect-port <port>', 'Specify a custom port for the redirect URL.', val =>
+    validateOptionInt(val, 0, 65535),
+  )
   .action(async function (this: Command): Promise<void> {
     const options: CommandOptions = this.optsWithGlobals();
     const auth: AuthInfo = options.authInfo;
@@ -77,6 +80,7 @@ export const command = new Command('login')
     }
 
     const useLocalhost = Boolean(options.localhost);
+
     const redirectPort = options.redirectPort;
 
     const oauth2Client = getUnauthorizedOuth2Client(options.creds);
diff --git a/src/commands/validate.ts b/src/commands/validate.ts
new file mode 100644
index 0000000..5d0a755
--- /dev/null
+++ b/src/commands/validate.ts
@@ -0,0 +1,42 @@
+// Copyright 2025 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// This module provides validatition functions for commandline options and arguments
+
+import {InvalidOptionArgumentError} from 'commander';
+
+/**
+ * Validates if the value is an integer.
+ * If the startInclusive and endInclusive parameters are provided, additional check against the bounds
+ *
+ * @returns {number} The valid integer value.
+ * @throws {InvalidOptionArgumentError} If value is not an integer or out of bounds.
+ */
+export const validateOptionInt = (val: any, startInclusive?: number, endInclusive?: number): number => {
+  let errorMsg = '';
+
+  // Commander already handles the case where the option was provided with no argument
+  if (val) {
+    if (!Number.isInteger(Number(val))) {
+      errorMsg = `'${val}' is not a valid integer.`;
+    } else {
+      if (startInclusive != null && endInclusive != null && (val < startInclusive || val > endInclusive)) {
+        errorMsg = `'${val}' should be >= ${startInclusive} and <= ${endInclusive}.`;
+      }
+    }
+
+    if (errorMsg) throw new InvalidOptionArgumentError(errorMsg);
+  }
+  return val;
+};
diff --git a/ts-node.register.js b/ts-node.register.js
new file mode 100644
index 0000000..58c446c
--- /dev/null
+++ b/ts-node.register.js
@@ -0,0 +1,18 @@
+// Copyright 2025 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import { pathToFileURL } from "node:url"
+import { register } from "node:module"
+
+register("ts-node/esm", pathToFileURL("./"))
diff --git a/tsconfig.json b/tsconfig.json
index 96bc129..03e839a 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -20,6 +20,7 @@
     "strictPropertyInitialization": true,
     "stripInternal": true,
     "target": "es2019",
+    "typeRoots": ["./node_modules/@types"],
     "importHelpers": true,
     "plugins": [
       {
@@ -29,7 +30,7 @@
         "overrideIdFn": "[sha512:contenthash:base64:6]",
         "ast": true
       }
-    ] 
+    ]
   },
   "include": [
     "src/**/*.ts",



Test patch:
diff --git a/test/commands/login.ts b/test/commands/login.ts
new file mode 100644
index 0000000..d0a9c94
--- /dev/null
+++ b/test/commands/login.ts
@@ -0,0 +1,115 @@
+// Copyright 2025 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// This file contains tests for the 'logout' command.
+import path from 'path';
+import {fileURLToPath} from 'url';
+import {expect} from 'chai';
+import esmock from 'esmock';
+import {after, before, describe, it} from 'mocha';
+import mockfs from 'mock-fs';
+import {useChaiExtensions} from '../helpers.js';
+import {resetMocks, setupMocks} from '../mocks.js';
+import type {CommandResult} from './utils.js';
+
+useChaiExtensions();
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = path.dirname(__filename);
+
+const genMockRunCommand = async (): Promise<(args: String[]) => Promise<CommandResult>> => {
+  const mockedLoginModule = await esmock('../../src/commands/login.js', {
+    '../../src/auth/auth.js': {authorize: () => {}},
+  });
+  const mockedProgramModule = await esmock('../../src/commands/program.js', {
+    '../../src/commands/login.js': mockedLoginModule,
+  });
+  const {runCommand: mockedRunCommand} = await esmock('./utils.js', {
+    '../../src/commands/program.js': mockedProgramModule,
+  });
+  return mockedRunCommand;
+};
+
+let runCommand: (args: String[]) => Promise<CommandResult>;
+
+describe('Login command', function () {
+  before(async function () {
+    setupMocks();
+    // Load filesystem to enable esmock to resolve imports
+    mockfs({
+      src: mockfs.load(path.resolve(__dirname, '../../src')),
+      test: mockfs.load(path.resolve(__dirname, '../')),
+      node_modules: mockfs.load(path.resolve(__dirname, '../../node_modules')),
+    });
+    runCommand = await genMockRunCommand();
+  });
+
+  after(function () {
+    resetMocks();
+  });
+
+  describe('Test defaults', function () {
+    it('no args', async function () {
+      const result: CommandResult = await runCommand(['login']);
+      expect(result.exitCode).to.equal(0);
+    });
+  });
+
+  describe('Test option redirectPort', function () {
+    it('Test setting a valid integer', async function () {
+      const port = '8080';
+      const result: CommandResult = await runCommand(['login', '--redirect-port', port]);
+      expect(result.exitCode).to.equal(0);
+    });
+
+    it('Test validation of invalid lower bound', async function () {
+      const port = '-1';
+      const result: CommandResult = await runCommand(['login', '--redirect-port', port]);
+      expect(result.exitCode).to.equal(1);
+      expect(result.stdout).to.match(/code:.*commander.invalidArgument/);
+      expect(result.message).to.have.string(`'${port}' should be >= 0 and <= 65535`);
+    });
+
+    it('Test validation of invalid upper bound', async function () {
+      const port = '65536';
+      const result: CommandResult = await runCommand(['login', '--redirect-port', port]);
+      expect(result.exitCode).to.equal(1);
+      expect(result.stdout).to.match(/code:.*commander.invalidArgument/);
+      expect(result.message).to.have.string(`'${port}' should be >= 0 and <= 65535`);
+    });
+
+    it('Test validation of missing parameter', async function () {
+      const result: CommandResult = await runCommand(['login', '--redirect-port']);
+      expect(result.exitCode).to.equal(1);
+      expect(result.stdout).to.match(/code:.*commander\.optionMissingArgument/);
+      expect(result.message).to.have.string('argument missing');
+    });
+
+    it('Test validation of invalid float', async function () {
+      const port = '8080.5';
+      const result: CommandResult = await runCommand(['login', '--redirect-port', port]);
+      expect(result.exitCode).to.equal(1);
+      expect(result.stdout).to.match(/code:.*commander.invalidArgument/);
+      expect(result.message).to.have.string(`'${port}' is not a valid integer`);
+    });
+
+    it('Test validation of invalid string', async function () {
+      const port = 'eight-thousand-eighty';
+      const result: CommandResult = await runCommand(['login', '--redirect-port', port]);
+      expect(result.exitCode).to.equal(1);
+      expect(result.stdout).to.match(/code:.*commander.invalidArgument/);
+      expect(result.message).to.have.string(`'${port}' is not a valid integer`);
+    });
+  });
+});