diff --git a/package-lock.json b/package-lock.json index 4284ed126..29421d853 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36689,9 +36689,9 @@ } }, "node_modules/zod": { - "version": "3.24.1", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.1.tgz", - "integrity": "sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A==", + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" @@ -36720,6 +36720,9 @@ }, "engines": { "node": ">=14.15.1" + }, + "peerDependencies": { + "zod": "^3.25.76" } }, "packages/async-rewriter2": { @@ -38459,7 +38462,7 @@ "cross-spawn": "^7.0.5", "escape-string-regexp": "^4.0.0", "tar": "^6.1.15", - "zod": "^3.24.1" + "zod": "^3.25.76" }, "devDependencies": { "@mongodb-js/eslint-config-mongosh": "^1.0.0", diff --git a/packages/arg-parser/.depcheckrc b/packages/arg-parser/.depcheckrc index 1f8add4c2..dc0bc2df8 100644 --- a/packages/arg-parser/.depcheckrc +++ b/packages/arg-parser/.depcheckrc @@ -10,5 +10,7 @@ ignores: - eslint-config-mongodb-js # needed as a peer dependency of @mongodb-js/devtools-connect - mongodb + # only used in arg-parser export; should be removed once switched to knip + - yargs-parser ignore-patterns: - .eslintrc.js \ No newline at end of file diff --git a/packages/arg-parser/package.json b/packages/arg-parser/package.json index bd90281eb..90ca74401 100644 --- a/packages/arg-parser/package.json +++ b/packages/arg-parser/package.json @@ -51,6 +51,9 @@ "mongodb-connection-string-url": "^3.0.2", "yargs-parser": "^20.2.4" }, + "peerDependencies": { + "zod": "^3.25.76" + }, "devDependencies": { "@mongodb-js/devtools-connect": "^3.9.4", "@mongodb-js/eslint-config-mongosh": "^1.0.0", diff --git a/packages/arg-parser/src/arg-metadata.ts b/packages/arg-parser/src/arg-metadata.ts new file mode 100644 index 000000000..0bef770ad --- /dev/null +++ b/packages/arg-parser/src/arg-metadata.ts @@ -0,0 +1,91 @@ +import z from 'zod/v4'; + +/** + * Registry for argument options metadata + */ +export const argMetadata = z.registry(); + +/** + * Metadata that can be used to define field's parsing behavior + */ +export type ArgumentMetadata = { + /** If set, sets this field as deprecated and replaces this field with the set field. */ + deprecationReplacement?: string; + /** If set, gets replaced with a different field name (without deprecation) */ + replacement?: string; + /** Whether this argument is unsupported. Always throws an error if set to true. */ + unsupported?: boolean; + /** Aliases for this argument. */ + alias?: string[]; +}; + +/** + * Extract metadata for a field using the custom registry + */ +export function getArgumentMetadata( + schema: z.ZodObject, + fieldName: string +): ArgumentMetadata | undefined { + const fieldSchema = schema.shape[fieldName]; + if (!fieldSchema) { + return undefined; + } + return argMetadata.get(fieldSchema); +} + +/** + * Maps deprecated arguments to their new counterparts, derived from schema metadata. + */ +export function getDeprecatedArgsWithReplacement( + schema: z.ZodObject +): Record> { + const deprecated: Record> = {}; + for (const fieldName of Object.keys(schema.shape)) { + const meta = getArgumentMetadata(schema, fieldName); + if (meta?.deprecationReplacement) { + deprecated[fieldName] = meta.deprecationReplacement; + } + } + return deprecated; +} + +/** + * Get list of unsupported arguments, derived from schema metadata. + */ +export function getUnsupportedArgs(schema: z.ZodObject): string[] { + const unsupported: string[] = []; + for (const fieldName of Object.keys(schema.shape)) { + const meta = getArgumentMetadata(schema, fieldName); + if (meta?.unsupported) { + unsupported.push(fieldName); + } + } + return unsupported; +} + +export class InvalidArgumentError extends Error { + constructor(message: string) { + super(message); + this.name = 'InvalidArgumentError'; + } +} + +export class UnknownArgumentError extends Error { + /** The argument that was not parsed. */ + readonly argument: string; + constructor(argument: string) { + super(`Unknown argument: ${argument}`); + this.name = 'UnknownArgumentError'; + this.argument = argument; + } +} + +export class UnsupportedArgumentError extends Error { + /** The argument that was not supported. */ + readonly argument: string; + constructor(argument: string) { + super(`Unsupported argument: ${argument}`); + this.name = 'UnsupportedArgumentError'; + this.argument = argument; + } +} diff --git a/packages/arg-parser/src/arg-parser.spec.ts b/packages/arg-parser/src/arg-parser.spec.ts index c9ad3edf5..c962b8568 100644 --- a/packages/arg-parser/src/arg-parser.spec.ts +++ b/packages/arg-parser/src/arg-parser.spec.ts @@ -1,6 +1,18 @@ import { MongoshUnimplementedError } from '@mongosh/errors'; import { expect } from 'chai'; -import { getLocale, parseCliArgs, UnknownCliArgumentError } from './arg-parser'; +import { + argMetadata, + CliOptionsSchema, + generateYargsOptionsFromSchema, + getLocale, + parseArgs, + parseArgsWithCliOptions, + UnknownArgumentError, + UnsupportedArgumentError, +} from './arg-parser'; +import { z } from 'zod/v4'; +import { coerceIfBoolean, coerceIfFalse } from './utils'; +import { InvalidArgumentError } from './arg-metadata'; describe('arg-parser', function () { describe('.getLocale', function () { @@ -74,13 +86,14 @@ describe('arg-parser', function () { }); describe('.parse', function () { - const baseArgv = ['node', 'mongosh']; context('when providing only a URI', function () { const uri = 'mongodb://domain.com:20000'; - const argv = [...baseArgv, uri]; + const argv = [uri]; it('returns the URI in the object', function () { - expect(parseCliArgs(argv).connectionSpecifier).to.equal(uri); + expect( + parseArgsWithCliOptions({ args: argv }).parsed.connectionSpecifier + ).to.equal(uri); }); }); @@ -89,741 +102,897 @@ describe('arg-parser', function () { context('when providing general options', function () { context('when providing --ipv6', function () { - const argv = [...baseArgv, uri, '--ipv6']; + const argv = [uri, '--ipv6']; it('returns the URI in the object', function () { - expect(parseCliArgs(argv).connectionSpecifier).to.equal(uri); + expect( + parseArgsWithCliOptions({ args: argv }).parsed.connectionSpecifier + ).to.equal(uri); }); it('sets the ipv6 value in the object', function () { - expect(parseCliArgs(argv).ipv6).to.equal(true); + expect( + parseArgsWithCliOptions({ args: argv }).parsed.ipv6 + ).to.equal(true); }); }); context('when providing -h', function () { - const argv = [...baseArgv, uri, '-h']; + const argv = [uri, '-h']; it('returns the URI in the object', function () { - expect(parseCliArgs(argv).connectionSpecifier).to.equal(uri); + expect( + parseArgsWithCliOptions({ args: argv }).parsed.connectionSpecifier + ).to.equal(uri); }); it('sets the help value in the object', function () { - expect(parseCliArgs(argv).help).to.equal(true); + expect( + parseArgsWithCliOptions({ args: argv }).parsed.help + ).to.equal(true); }); }); context('when providing --help', function () { - const argv = [...baseArgv, uri, '--help']; + const argv = [uri, '--help']; it('returns the URI in the object', function () { - expect(parseCliArgs(argv).connectionSpecifier).to.equal(uri); + expect( + parseArgsWithCliOptions({ args: argv }).parsed.connectionSpecifier + ).to.equal(uri); }); it('sets the help value in the object', function () { - expect(parseCliArgs(argv).help).to.equal(true); + expect( + parseArgsWithCliOptions({ args: argv }).parsed.help + ).to.equal(true); }); }); context('when providing --version', function () { - const argv = [...baseArgv, uri, '--version']; + const argv = [uri, '--version']; it('returns the URI in the object', function () { - expect(parseCliArgs(argv).connectionSpecifier).to.equal(uri); + expect( + parseArgsWithCliOptions({ args: argv }).parsed.connectionSpecifier + ).to.equal(uri); }); it('sets the version value in the object', function () { - expect(parseCliArgs(argv).version).to.equal(true); + expect( + parseArgsWithCliOptions({ args: argv }).parsed.version + ).to.equal(true); }); }); context('when providing --verbose', function () { - const argv = [...baseArgv, uri, '--verbose']; + const argv = [uri, '--verbose']; it('returns the URI in the object', function () { - expect(parseCliArgs(argv).connectionSpecifier).to.equal(uri); + expect( + parseArgsWithCliOptions({ args: argv }).parsed.connectionSpecifier + ).to.equal(uri); }); it('sets the verbose value in the object', function () { - expect(parseCliArgs(argv).verbose).to.equal(true); + expect( + parseArgsWithCliOptions({ args: argv }).parsed.verbose + ).to.equal(true); }); }); context('when providing --shell', function () { - const argv = [...baseArgv, uri, '--shell']; + const argv = [uri, '--shell']; it('returns the URI in the object', function () { - expect(parseCliArgs(argv).connectionSpecifier).to.equal(uri); + expect( + parseArgsWithCliOptions({ args: argv }).parsed.connectionSpecifier + ).to.equal(uri); }); it('sets the shell value in the object', function () { - expect(parseCliArgs(argv).shell).to.equal(true); + expect( + parseArgsWithCliOptions({ args: argv }).parsed.shell + ).to.equal(true); }); }); context('when providing --nodb', function () { - const argv = [...baseArgv, uri, '--nodb']; + const argv = [uri, '--nodb']; it('does not return the URI in the object', function () { - expect(parseCliArgs(argv).connectionSpecifier).to.equal(undefined); - expect(parseCliArgs(argv).fileNames).to.deep.equal([uri]); + expect( + parseArgsWithCliOptions({ args: argv }).parsed.connectionSpecifier + ).to.equal(undefined); + expect( + parseArgsWithCliOptions({ args: argv }).parsed.fileNames + ).to.deep.equal([uri]); }); it('sets the nodb value in the object', function () { - expect(parseCliArgs(argv).nodb).to.equal(true); + expect( + parseArgsWithCliOptions({ args: argv }).parsed.nodb + ).to.equal(true); }); }); context('when providing --norc', function () { - const argv = [...baseArgv, uri, '--norc']; + const argv = [uri, '--norc']; it('returns the URI in the object', function () { - expect(parseCliArgs(argv).connectionSpecifier).to.equal(uri); + expect( + parseArgsWithCliOptions({ args: argv }).parsed.connectionSpecifier + ).to.equal(uri); }); it('sets the norc value in the object', function () { - expect(parseCliArgs(argv).norc).to.equal(true); + expect( + parseArgsWithCliOptions({ args: argv }).parsed.norc + ).to.equal(true); }); }); context('when providing --quiet', function () { - const argv = [...baseArgv, uri, '--quiet']; + const argv = [uri, '--quiet']; it('returns the URI in the object', function () { - expect(parseCliArgs(argv).connectionSpecifier).to.equal(uri); + expect( + parseArgsWithCliOptions({ args: argv }).parsed.connectionSpecifier + ).to.equal(uri); }); it('sets the quiet value in the object', function () { - expect(parseCliArgs(argv).quiet).to.equal(true); + expect( + parseArgsWithCliOptions({ args: argv }).parsed.quiet + ).to.equal(true); }); }); context('when providing --eval (single value)', function () { - const argv = [...baseArgv, uri, '--eval', '1+1']; + const argv = [uri, '--eval', '1+1']; it('returns the URI in the object', function () { - expect(parseCliArgs(argv).connectionSpecifier).to.equal(uri); + expect( + parseArgsWithCliOptions({ args: argv }).parsed.connectionSpecifier + ).to.equal(uri); }); it('sets the eval value in the object', function () { - expect(parseCliArgs(argv).eval).to.deep.equal(['1+1']); + expect( + parseArgsWithCliOptions({ args: argv }).parsed.eval + ).to.deep.equal(['1+1']); }); }); context('when providing --eval (multiple values)', function () { - const argv = [...baseArgv, uri, '--eval', '1+1', '--eval', '2+2']; + const argv = [uri, '--eval', '1+1', '--eval', '2+2']; it('returns the URI in the object', function () { - expect(parseCliArgs(argv).connectionSpecifier).to.equal(uri); + expect( + parseArgsWithCliOptions({ args: argv }).parsed.connectionSpecifier + ).to.equal(uri); }); it('sets the eval value in the object', function () { - expect(parseCliArgs(argv).eval).to.deep.equal(['1+1', '2+2']); + expect( + parseArgsWithCliOptions({ args: argv }).parsed.eval + ).to.deep.equal(['1+1', '2+2']); }); }); context('when providing --retryWrites', function () { - const argv = [...baseArgv, uri, '--retryWrites']; + const argv = [uri, '--retryWrites']; it('returns the URI in the object', function () { - expect(parseCliArgs(argv).connectionSpecifier).to.equal(uri); + expect( + parseArgsWithCliOptions({ args: argv }).parsed.connectionSpecifier + ).to.equal(uri); }); it('sets the retryWrites value in the object', function () { - expect(parseCliArgs(argv).retryWrites).to.equal(true); + expect( + parseArgsWithCliOptions({ args: argv }).parsed.retryWrites + ).to.equal(true); }); }); context('when providing an unknown parameter', function () { - const argv = [...baseArgv, uri, '--what']; + const argv = [uri, '--what']; it('raises an error', function () { - try { - parseCliArgs(argv); - } catch (err: any) { - if (err instanceof UnknownCliArgumentError) { - expect(err.argument).equals('--what'); - return; - } else { - expect.fail('Did not throw an unknown cli error'); - } - } - expect.fail('parsing unknown parameter did not throw'); + expect(() => { + parseArgsWithCliOptions({ args: argv }).parsed; + }).to.throw(UnknownArgumentError, 'Unknown argument: --what'); }); }); }); context('when providing authentication options', function () { context('when providing -u', function () { - const argv = [...baseArgv, uri, '-u', 'richard']; + const argv = [uri, '-u', 'richard']; it('returns the URI in the object', function () { - expect(parseCliArgs(argv).connectionSpecifier).to.equal(uri); + expect( + parseArgsWithCliOptions({ args: argv }).parsed.connectionSpecifier + ).to.equal(uri); }); it('sets the username in the object', function () { - expect(parseCliArgs(argv).username).to.equal('richard'); + expect( + parseArgsWithCliOptions({ args: argv }).parsed.username + ).to.equal('richard'); }); }); context('when providing --username', function () { - const argv = [...baseArgv, uri, '--username', 'richard']; + const argv = [uri, '--username', 'richard']; it('returns the URI in the object', function () { - expect(parseCliArgs(argv).connectionSpecifier).to.equal(uri); + expect( + parseArgsWithCliOptions({ args: argv }).parsed.connectionSpecifier + ).to.equal(uri); }); it('sets the username in the object', function () { - expect(parseCliArgs(argv).username).to.equal('richard'); + expect( + parseArgsWithCliOptions({ args: argv }).parsed.username + ).to.equal('richard'); }); }); context('when providing -p', function () { - const argv = [...baseArgv, uri, '-p', 'pw']; + const argv = [uri, '-p', 'pw']; it('returns the URI in the object', function () { - expect(parseCliArgs(argv).connectionSpecifier).to.equal(uri); + expect( + parseArgsWithCliOptions({ args: argv }).parsed.connectionSpecifier + ).to.equal(uri); }); it('sets the password in the object', function () { - expect(parseCliArgs(argv).password).to.equal('pw'); + expect( + parseArgsWithCliOptions({ args: argv }).parsed.password + ).to.equal('pw'); }); }); context('when providing --password', function () { - const argv = [...baseArgv, uri, '--password', 'pw']; + const argv = [uri, '--password', 'pw']; it('returns the URI in the object', function () { - expect(parseCliArgs(argv).connectionSpecifier).to.equal(uri); + expect( + parseArgsWithCliOptions({ args: argv }).parsed.connectionSpecifier + ).to.equal(uri); }); it('sets the password in the object', function () { - expect(parseCliArgs(argv).password).to.equal('pw'); + expect( + parseArgsWithCliOptions({ args: argv }).parsed.password + ).to.equal('pw'); }); }); context('when providing --authenticationDatabase', function () { - const argv = [...baseArgv, uri, '--authenticationDatabase', 'db']; + const argv = [uri, '--authenticationDatabase', 'db']; it('returns the URI in the object', function () { - expect(parseCliArgs(argv).connectionSpecifier).to.equal(uri); + expect( + parseArgsWithCliOptions({ args: argv }).parsed.connectionSpecifier + ).to.equal(uri); }); it('sets the authenticationDatabase in the object', function () { - expect(parseCliArgs(argv).authenticationDatabase).to.equal('db'); + expect( + parseArgsWithCliOptions({ args: argv }).parsed + .authenticationDatabase + ).to.equal('db'); }); }); context('when providing --authenticationMechanism', function () { - const argv = [ - ...baseArgv, - uri, - '--authenticationMechanism', - 'SCRAM-SHA-256', - ]; + const argv = [uri, '--authenticationMechanism', 'SCRAM-SHA-256']; it('returns the URI in the object', function () { - expect(parseCliArgs(argv).connectionSpecifier).to.equal(uri); + expect( + parseArgsWithCliOptions({ args: argv }).parsed.connectionSpecifier + ).to.equal(uri); }); it('sets the authenticationMechanism in the object', function () { - expect(parseCliArgs(argv).authenticationMechanism).to.equal( - 'SCRAM-SHA-256' - ); + expect( + parseArgsWithCliOptions({ args: argv }).parsed + .authenticationMechanism + ).to.equal('SCRAM-SHA-256'); }); }); context('when providing --gssapiServiceName', function () { - const argv = [...baseArgv, uri, '--gssapiServiceName', 'mongosh']; + const argv = [uri, '--gssapiServiceName', 'mongosh']; it('returns the URI in the object', function () { - expect(parseCliArgs(argv).connectionSpecifier).to.equal(uri); + expect( + parseArgsWithCliOptions({ args: argv }).parsed.connectionSpecifier + ).to.equal(uri); }); it('sets the gssapiServiceName in the object', function () { - expect(parseCliArgs(argv).gssapiServiceName).to.equal('mongosh'); + expect( + parseArgsWithCliOptions({ args: argv }).parsed.gssapiServiceName + ).to.equal('mongosh'); }); }); context('when providing --gssapiHostName', function () { - const argv = [...baseArgv, uri, '--gssapiHostName', 'example.com']; + const argv = [uri, '--gssapiHostName', 'example.com']; it('throws an error since it is not supported', function () { - try { - parseCliArgs(argv); - } catch (e: any) { - expect(e).to.be.instanceOf(MongoshUnimplementedError); - expect(e.message).to.include( - 'Argument --gssapiHostName is not supported in mongosh' - ); - return; - } - expect.fail('Expected error'); + expect( + () => parseArgsWithCliOptions({ args: argv }).parsed + ).to.throw( + UnsupportedArgumentError, + 'Unsupported argument: gssapiHostName' + ); }); - - // it('returns the URI in the object', () => { - // expect(parseCliArgs(argv).connectionSpecifier).to.equal(uri); - // }); - - // it('sets the gssapiHostName in the object', () => { - // expect(parseCliArgs(argv).gssapiHostName).to.equal('example.com'); - // }); }); context('when providing --sspiHostnameCanonicalization', function () { - const argv = [ - ...baseArgv, - uri, - '--sspiHostnameCanonicalization', - 'forward', - ]; + const argv = [uri, '--sspiHostnameCanonicalization', 'forward']; it('returns the URI in the object', function () { - expect(parseCliArgs(argv).connectionSpecifier).to.equal(uri); + expect( + parseArgsWithCliOptions({ args: argv }).parsed.connectionSpecifier + ).to.equal(uri); }); it('sets the gssapiHostName in the object', function () { - expect(parseCliArgs(argv).sspiHostnameCanonicalization).to.equal( - 'forward' - ); + expect( + parseArgsWithCliOptions({ args: argv }).parsed + .sspiHostnameCanonicalization + ).to.equal('forward'); }); }); context('when providing --sspiRealmOverride', function () { - const argv = [ - ...baseArgv, - uri, - '--sspiRealmOverride', - 'example2.com', - ]; + const argv = [uri, '--sspiRealmOverride', 'example2.com']; it('returns the URI in the object', function () { - expect(parseCliArgs(argv).connectionSpecifier).to.equal(uri); + expect( + parseArgsWithCliOptions({ args: argv }).parsed.connectionSpecifier + ).to.equal(uri); }); it('sets the gssapiHostName in the object', function () { - expect(parseCliArgs(argv).sspiRealmOverride).to.equal( - 'example2.com' - ); + expect( + parseArgsWithCliOptions({ args: argv }).parsed.sspiRealmOverride + ).to.equal('example2.com'); }); }); context('when providing --awsIamSessionToken', function () { - const argv = [...baseArgv, uri, '--awsIamSessionToken', 'tok']; + const argv = [uri, '--awsIamSessionToken', 'tok']; it('returns the URI in the object', function () { - expect(parseCliArgs(argv).connectionSpecifier).to.equal(uri); + expect( + parseArgsWithCliOptions({ args: argv }).parsed.connectionSpecifier + ).to.equal(uri); }); it('sets the awsIamSessionToken in the object', function () { - expect(parseCliArgs(argv).awsIamSessionToken).to.equal('tok'); + expect( + parseArgsWithCliOptions({ args: argv }).parsed.awsIamSessionToken + ).to.equal('tok'); }); }); }); context('when providing TLS options', function () { context('when providing --tls', function () { - const argv = [...baseArgv, uri, '--tls']; + const argv = [uri, '--tls']; it('returns the URI in the object', function () { - expect(parseCliArgs(argv).connectionSpecifier).to.equal(uri); + expect( + parseArgsWithCliOptions({ args: argv }).parsed.connectionSpecifier + ).to.equal(uri); }); it('sets the tls in the object', function () { - expect(parseCliArgs(argv).tls).to.equal(true); + expect(parseArgsWithCliOptions({ args: argv }).parsed.tls).to.equal( + true + ); }); }); context('when providing -tls (single dash)', function () { - const argv = [...baseArgv, uri, '-tls']; + const argv = [uri, '-tls']; it('returns the URI in the object', function () { - expect(parseCliArgs(argv).connectionSpecifier).to.equal(uri); + expect( + parseArgsWithCliOptions({ args: argv }).parsed.connectionSpecifier + ).to.equal(uri); }); it('sets the tls in the object', function () { - expect(parseCliArgs(argv).tls).to.equal(true); + expect(parseArgsWithCliOptions({ args: argv }).parsed.tls).to.equal( + true + ); }); }); context('when providing --tlsCertificateKeyFile', function () { - const argv = [...baseArgv, uri, '--tlsCertificateKeyFile', 'test']; + const argv = [uri, '--tlsCertificateKeyFile', 'test']; it('returns the URI in the object', function () { - expect(parseCliArgs(argv).connectionSpecifier).to.equal(uri); + expect( + parseArgsWithCliOptions({ args: argv }).parsed.connectionSpecifier + ).to.equal(uri); }); it('sets the tlsCertificateKeyFile in the object', function () { - expect(parseCliArgs(argv).tlsCertificateKeyFile).to.equal('test'); + expect( + parseArgsWithCliOptions({ args: argv }).parsed + .tlsCertificateKeyFile + ).to.equal('test'); }); }); context( 'when providing -tlsCertificateKeyFile (single dash)', function () { - const argv = [...baseArgv, uri, '-tlsCertificateKeyFile', 'test']; + const argv = [uri, '-tlsCertificateKeyFile', 'test']; it('returns the URI in the object', function () { - expect(parseCliArgs(argv).connectionSpecifier).to.equal(uri); + expect( + parseArgsWithCliOptions({ args: argv }).parsed + .connectionSpecifier + ).to.equal(uri); }); it('sets the tlsCertificateKeyFile in the object', function () { - expect(parseCliArgs(argv).tlsCertificateKeyFile).to.equal('test'); + expect( + parseArgsWithCliOptions({ args: argv }).parsed + .tlsCertificateKeyFile + ).to.equal('test'); }); } ); context('when providing --tlsCertificateKeyFilePassword', function () { - const argv = [ - ...baseArgv, - uri, - '--tlsCertificateKeyFilePassword', - 'test', - ]; + const argv = [uri, '--tlsCertificateKeyFilePassword', 'test']; it('returns the URI in the object', function () { - expect(parseCliArgs(argv).connectionSpecifier).to.equal(uri); + expect( + parseArgsWithCliOptions({ args: argv }).parsed.connectionSpecifier + ).to.equal(uri); }); it('sets the tlsCertificateKeyFilePassword in the object', function () { - expect(parseCliArgs(argv).tlsCertificateKeyFilePassword).to.equal( - 'test' - ); + expect( + parseArgsWithCliOptions({ args: argv }).parsed + .tlsCertificateKeyFilePassword + ).to.equal('test'); }); }); context('when providing --tlsCAFile', function () { - const argv = [...baseArgv, uri, '--tlsCAFile', 'test']; + const argv = [uri, '--tlsCAFile', 'test']; it('returns the URI in the object', function () { - expect(parseCliArgs(argv).connectionSpecifier).to.equal(uri); + expect( + parseArgsWithCliOptions({ args: argv }).parsed.connectionSpecifier + ).to.equal(uri); }); it('sets the tlsCAFile in the object', function () { - expect(parseCliArgs(argv).tlsCAFile).to.equal('test'); + expect( + parseArgsWithCliOptions({ args: argv }).parsed.tlsCAFile + ).to.equal('test'); }); }); context('when providing --tlsCRLFile', function () { - const argv = [...baseArgv, uri, '--tlsCRLFile', 'test']; + const argv = [uri, '--tlsCRLFile', 'test']; it('returns the URI in the object', function () { - expect(parseCliArgs(argv).connectionSpecifier).to.equal(uri); + expect( + parseArgsWithCliOptions({ args: argv }).parsed.connectionSpecifier + ).to.equal(uri); }); it('sets the tlsCRLFile in the object', function () { - expect(parseCliArgs(argv).tlsCRLFile).to.equal('test'); + expect( + parseArgsWithCliOptions({ args: argv }).parsed.tlsCRLFile + ).to.equal('test'); }); }); context('when providing --tlsAllowInvalidHostnames', function () { - const argv = [...baseArgv, uri, '--tlsAllowInvalidHostnames']; + const argv = [uri, '--tlsAllowInvalidHostnames']; it('returns the URI in the object', function () { - expect(parseCliArgs(argv).connectionSpecifier).to.equal(uri); + expect( + parseArgsWithCliOptions({ args: argv }).parsed.connectionSpecifier + ).to.equal(uri); }); it('sets the tlsAllowInvalidHostnames in the object', function () { - expect(parseCliArgs(argv).tlsAllowInvalidHostnames).to.equal(true); + expect( + parseArgsWithCliOptions({ args: argv }).parsed + .tlsAllowInvalidHostnames + ).to.equal(true); }); }); context('when providing --tlsAllowInvalidCertificates', function () { - const argv = [...baseArgv, uri, '--tlsAllowInvalidCertificates']; + const argv = [uri, '--tlsAllowInvalidCertificates']; it('returns the URI in the object', function () { - expect(parseCliArgs(argv).connectionSpecifier).to.equal(uri); + expect( + parseArgsWithCliOptions({ args: argv }).parsed.connectionSpecifier + ).to.equal(uri); }); it('sets the tlsAllowInvalidCertificates in the object', function () { - expect(parseCliArgs(argv).tlsAllowInvalidCertificates).to.equal( - true - ); + expect( + parseArgsWithCliOptions({ args: argv }).parsed + .tlsAllowInvalidCertificates + ).to.equal(true); }); }); context('when providing --sslFIPSMode', function () { - const argv = [...baseArgv, uri, '--sslFIPSMode']; + const argv = [uri, '--sslFIPSMode']; it('throws an error since it is not supported', function () { - try { - parseCliArgs(argv); - } catch (e: any) { - expect(e).to.be.instanceOf(MongoshUnimplementedError); - expect(e.message).to.include( - 'Argument --sslFIPSMode is not supported in mongosh' - ); - return; - } - expect.fail('Expected error'); + expect( + () => parseArgsWithCliOptions({ args: argv }).parsed + ).to.throw( + UnsupportedArgumentError, + 'Unsupported argument: sslFIPSMode' + ); }); // it('returns the URI in the object', () => { - // expect(parseCliArgs(argv).connectionSpecifier).to.equal(uri); + // expect(parseArgsWithCliOptions({ args: argv }).parsed.connectionSpecifier).to.equal(uri); // }); // it('sets the tlsFIPSMode in the object', () => { - // expect(parseCliArgs(argv).tlsFIPSMode).to.equal(true); + // expect(parseArgsWithCliOptions({ args: argv }).parsed.tlsFIPSMode).to.equal(true); // }); }); context('when providing --tlsCertificateSelector', function () { - const argv = [...baseArgv, uri, '--tlsCertificateSelector', 'test']; + const argv = [uri, '--tlsCertificateSelector', 'test']; it('returns the URI in the object', function () { - expect(parseCliArgs(argv).connectionSpecifier).to.equal(uri); + expect( + parseArgsWithCliOptions({ args: argv }).parsed.connectionSpecifier + ).to.equal(uri); }); it('sets the tlsCertificateSelector in the object', function () { - expect(parseCliArgs(argv).tlsCertificateSelector).to.equal('test'); + expect( + parseArgsWithCliOptions({ args: argv }).parsed + .tlsCertificateSelector + ).to.equal('test'); }); }); context('when providing --tlsDisabledProtocols', function () { - const argv = [ - ...baseArgv, - uri, - '--tlsDisabledProtocols', - 'TLS1_0,TLS2_0', - ]; + const argv = [uri, '--tlsDisabledProtocols', 'TLS1_0,TLS2_0']; it('returns the URI in the object', function () { - expect(parseCliArgs(argv).connectionSpecifier).to.equal(uri); + expect( + parseArgsWithCliOptions({ args: argv }).parsed.connectionSpecifier + ).to.equal(uri); }); it('sets the tlsDisabledProtocols in the object', function () { - expect(parseCliArgs(argv).tlsDisabledProtocols).to.equal( - 'TLS1_0,TLS2_0' - ); + expect( + parseArgsWithCliOptions({ args: argv }).parsed + .tlsDisabledProtocols + ).to.equal('TLS1_0,TLS2_0'); }); }); }); context('when providing FLE options', function () { context('when providing --awsAccessKeyId', function () { - const argv = [...baseArgv, uri, '--awsAccessKeyId', 'foo']; + const argv = [uri, '--awsAccessKeyId', 'foo']; it('returns the URI in the object', function () { - expect(parseCliArgs(argv).connectionSpecifier).to.equal(uri); + expect( + parseArgsWithCliOptions({ args: argv }).parsed.connectionSpecifier + ).to.equal(uri); }); it('sets the awsAccessKeyId in the object', function () { - expect(parseCliArgs(argv).awsAccessKeyId).to.equal('foo'); + expect( + parseArgsWithCliOptions({ args: argv }).parsed.awsAccessKeyId + ).to.equal('foo'); }); }); context('when providing --awsSecretAccessKey', function () { - const argv = [...baseArgv, uri, '--awsSecretAccessKey', 'foo']; + const argv = [uri, '--awsSecretAccessKey', 'foo']; it('returns the URI in the object', function () { - expect(parseCliArgs(argv).connectionSpecifier).to.equal(uri); + expect( + parseArgsWithCliOptions({ args: argv }).parsed.connectionSpecifier + ).to.equal(uri); }); it('sets the awsSecretAccessKey in the object', function () { - expect(parseCliArgs(argv).awsSecretAccessKey).to.equal('foo'); + expect( + parseArgsWithCliOptions({ args: argv }).parsed.awsSecretAccessKey + ).to.equal('foo'); }); }); context('when providing --awsSessionToken', function () { - const argv = [...baseArgv, uri, '--awsSessionToken', 'foo']; + const argv = [uri, '--awsSessionToken', 'foo']; it('returns the URI in the object', function () { - expect(parseCliArgs(argv).connectionSpecifier).to.equal(uri); + expect( + parseArgsWithCliOptions({ args: argv }).parsed.connectionSpecifier + ).to.equal(uri); }); it('sets the awsSessionToken in the object', function () { - expect(parseCliArgs(argv).awsSessionToken).to.equal('foo'); + expect( + parseArgsWithCliOptions({ args: argv }).parsed.awsSessionToken + ).to.equal('foo'); }); }); context('when providing --keyVaultNamespace', function () { - const argv = [...baseArgv, uri, '--keyVaultNamespace', 'foo.bar']; + const argv = [uri, '--keyVaultNamespace', 'foo.bar']; it('returns the URI in the object', function () { - expect(parseCliArgs(argv).connectionSpecifier).to.equal(uri); + expect( + parseArgsWithCliOptions({ args: argv }).parsed.connectionSpecifier + ).to.equal(uri); }); it('sets the keyVaultNamespace in the object', function () { - expect(parseCliArgs(argv).keyVaultNamespace).to.equal('foo.bar'); + expect( + parseArgsWithCliOptions({ args: argv }).parsed.keyVaultNamespace + ).to.equal('foo.bar'); }); }); context('when providing --kmsURL', function () { - const argv = [...baseArgv, uri, '--kmsURL', 'example.com']; + const argv = [uri, '--kmsURL', 'example.com']; it('returns the URI in the object', function () { - expect(parseCliArgs(argv).connectionSpecifier).to.equal(uri); + expect( + parseArgsWithCliOptions({ args: argv }).parsed.connectionSpecifier + ).to.equal(uri); }); it('sets the kmsURL in the object', function () { - expect(parseCliArgs(argv).kmsURL).to.equal('example.com'); + expect( + parseArgsWithCliOptions({ args: argv }).parsed.kmsURL + ).to.equal('example.com'); }); }); }); context('when providing versioned API options', function () { context('when providing --apiVersion', function () { - const argv = [...baseArgv, uri, '--apiVersion', '1']; + const argv = [uri, '--apiVersion', '1']; it('returns the URI in the object', function () { - expect(parseCliArgs(argv).connectionSpecifier).to.equal(uri); + expect( + parseArgsWithCliOptions({ args: argv }).parsed.connectionSpecifier + ).to.equal(uri); }); it('sets the apiVersion in the object', function () { - expect(parseCliArgs(argv).apiVersion).to.equal('1'); + expect( + parseArgsWithCliOptions({ args: argv }).parsed.apiVersion + ).to.equal('1'); }); }); context('when providing --apiDeprecationErrors', function () { - const argv = [...baseArgv, uri, '--apiDeprecationErrors']; + const argv = [uri, '--apiDeprecationErrors']; it('returns the URI in the object', function () { - expect(parseCliArgs(argv).connectionSpecifier).to.equal(uri); + expect( + parseArgsWithCliOptions({ args: argv }).parsed.connectionSpecifier + ).to.equal(uri); }); it('sets the apiVersion in the object', function () { - expect(parseCliArgs(argv).apiDeprecationErrors).to.equal(true); + expect( + parseArgsWithCliOptions({ args: argv }).parsed + .apiDeprecationErrors + ).to.equal(true); }); }); context('when providing --apiStrict', function () { - const argv = [...baseArgv, uri, '--apiStrict']; + const argv = [uri, '--apiStrict']; it('returns the URI in the object', function () { - expect(parseCliArgs(argv).connectionSpecifier).to.equal(uri); + expect( + parseArgsWithCliOptions({ args: argv }).parsed.connectionSpecifier + ).to.equal(uri); }); it('sets the apiVersion in the object', function () { - expect(parseCliArgs(argv).apiStrict).to.equal(true); + expect( + parseArgsWithCliOptions({ args: argv }).parsed.apiStrict + ).to.equal(true); }); }); }); context('when providing filenames after an URI', function () { context('when the filenames end in .js', function () { - const argv = [...baseArgv, uri, 'test1.js', 'test2.js']; + const argv = [uri, 'test1.js', 'test2.js']; it('returns the URI in the object', function () { - expect(parseCliArgs(argv).connectionSpecifier).to.equal(uri); + expect( + parseArgsWithCliOptions({ args: argv }).parsed.connectionSpecifier + ).to.equal(uri); }); it('sets the filenames', function () { - expect(parseCliArgs(argv).fileNames?.[0]).to.equal('test1.js'); - expect(parseCliArgs(argv).fileNames?.[1]).to.equal('test2.js'); + expect( + parseArgsWithCliOptions({ args: argv }).parsed.fileNames?.[0] + ).to.equal('test1.js'); + expect( + parseArgsWithCliOptions({ args: argv }).parsed.fileNames?.[1] + ).to.equal('test2.js'); }); }); context('when the filenames end in .mongodb', function () { - const argv = [...baseArgv, uri, 'test1.mongodb', 'test2.mongodb']; + const argv = [uri, 'test1.mongodb', 'test2.mongodb']; it('returns the URI in the object', function () { - expect(parseCliArgs(argv).connectionSpecifier).to.equal(uri); + expect( + parseArgsWithCliOptions({ args: argv }).parsed.connectionSpecifier + ).to.equal(uri); }); it('sets the filenames', function () { - expect(parseCliArgs(argv).fileNames?.[0]).to.equal('test1.mongodb'); - expect(parseCliArgs(argv).fileNames?.[1]).to.equal('test2.mongodb'); + expect( + parseArgsWithCliOptions({ args: argv }).parsed.fileNames?.[0] + ).to.equal('test1.mongodb'); + expect( + parseArgsWithCliOptions({ args: argv }).parsed.fileNames?.[1] + ).to.equal('test2.mongodb'); }); }); context('when the filenames end in other extensions', function () { - const argv = [...baseArgv, uri, 'test1.txt', 'test2.txt']; + const argv = [uri, 'test1.txt', 'test2.txt']; it('returns the URI in the object', function () { - expect(parseCliArgs(argv).connectionSpecifier).to.equal(uri); + expect( + parseArgsWithCliOptions({ args: argv }).parsed.connectionSpecifier + ).to.equal(uri); }); it('sets the filenames', function () { - expect(parseCliArgs(argv).fileNames?.[0]).to.equal('test1.txt'); - expect(parseCliArgs(argv).fileNames?.[1]).to.equal('test2.txt'); + expect( + parseArgsWithCliOptions({ args: argv }).parsed.fileNames?.[0] + ).to.equal('test1.txt'); + expect( + parseArgsWithCliOptions({ args: argv }).parsed.fileNames?.[1] + ).to.equal('test2.txt'); }); }); context('when filenames are specified using -f', function () { - const argv = [...baseArgv, uri, '-f', 'test1.txt', '-f', 'test2.txt']; + const argv = [uri, '-f', 'test1.txt', '-f', 'test2.txt']; it('returns the URI in the object', function () { - expect(parseCliArgs(argv).connectionSpecifier).to.equal(uri); + expect( + parseArgsWithCliOptions({ args: argv }).parsed.connectionSpecifier + ).to.equal(uri); }); it('sets the filenames', function () { - expect(parseCliArgs(argv).fileNames?.[0]).to.equal('test1.txt'); - expect(parseCliArgs(argv).fileNames?.[1]).to.equal('test2.txt'); + expect( + parseArgsWithCliOptions({ args: argv }).parsed.fileNames?.[0] + ).to.equal('test1.txt'); + expect( + parseArgsWithCliOptions({ args: argv }).parsed.fileNames?.[1] + ).to.equal('test2.txt'); }); }); context('when filenames are specified using -f/--file', function () { - const argv = [ - ...baseArgv, - uri, - '-f', - 'test1.txt', - '--file', - 'test2.txt', - ]; + const argv = [uri, '-f', 'test1.txt', '--file', 'test2.txt']; it('returns the URI in the object', function () { - expect(parseCliArgs(argv).connectionSpecifier).to.equal(uri); + expect( + parseArgsWithCliOptions({ args: argv }).parsed.connectionSpecifier + ).to.equal(uri); }); it('sets the filenames', function () { - expect(parseCliArgs(argv).fileNames?.[0]).to.equal('test1.txt'); - expect(parseCliArgs(argv).fileNames?.[1]).to.equal('test2.txt'); + expect( + parseArgsWithCliOptions({ args: argv }).parsed.fileNames?.[0] + ).to.equal('test1.txt'); + expect( + parseArgsWithCliOptions({ args: argv }).parsed.fileNames?.[1] + ).to.equal('test2.txt'); }); }); }); context('when providing filenames without an URI', function () { context('when the filenames end in .js', function () { - const argv = [...baseArgv, 'test1.js', 'test2.js']; + const argv = ['test1.js', 'test2.js']; it('returns no URI in the object', function () { - expect(parseCliArgs(argv).connectionSpecifier).to.equal(undefined); + expect( + parseArgsWithCliOptions({ args: argv }).parsed.connectionSpecifier + ).to.equal(undefined); }); it('sets the filenames', function () { - expect(parseCliArgs(argv).fileNames?.[0]).to.equal('test1.js'); - expect(parseCliArgs(argv).fileNames?.[1]).to.equal('test2.js'); + expect( + parseArgsWithCliOptions({ args: argv }).parsed.fileNames?.[0] + ).to.equal('test1.js'); + expect( + parseArgsWithCliOptions({ args: argv }).parsed.fileNames?.[1] + ).to.equal('test2.js'); }); }); context('when the filenames end in .mongodb', function () { - const argv = [...baseArgv, 'test1.mongodb', 'test2.mongodb']; + const argv = ['test1.mongodb', 'test2.mongodb']; it('returns no URI in the object', function () { - expect(parseCliArgs(argv).connectionSpecifier).to.equal(undefined); + expect( + parseArgsWithCliOptions({ args: argv }).parsed.connectionSpecifier + ).to.equal(undefined); }); it('sets the filenames', function () { - expect(parseCliArgs(argv).fileNames?.[0]).to.equal('test1.mongodb'); - expect(parseCliArgs(argv).fileNames?.[1]).to.equal('test2.mongodb'); + expect( + parseArgsWithCliOptions({ args: argv }).parsed.fileNames?.[0] + ).to.equal('test1.mongodb'); + expect( + parseArgsWithCliOptions({ args: argv }).parsed.fileNames?.[1] + ).to.equal('test2.mongodb'); }); }); context('when the filenames end in other extensions', function () { - const argv = [...baseArgv, 'test1.txt', 'test2.txt']; + const argv = ['test1.txt', 'test2.txt']; it('returns the first filename as an URI', function () { - expect(parseCliArgs(argv).connectionSpecifier).to.equal( - 'test1.txt' - ); + expect( + parseArgsWithCliOptions({ args: argv }).parsed.connectionSpecifier + ).to.equal('test1.txt'); }); it('uses the remainder as filenames', function () { - expect(parseCliArgs(argv).fileNames?.[0]).to.equal('test2.txt'); + expect( + parseArgsWithCliOptions({ args: argv }).parsed.fileNames?.[0] + ).to.equal('test2.txt'); }); }); context('when the first argument is an URI ending in .js', function () { - const argv = [...baseArgv, 'mongodb://domain.foo.js', 'test2.txt']; + const argv = ['mongodb://domain.foo.js', 'test2.txt']; it('returns the first filename as an URI', function () { - expect(parseCliArgs(argv).connectionSpecifier).to.equal( - 'mongodb://domain.foo.js' - ); + expect( + parseArgsWithCliOptions({ args: argv }).parsed.connectionSpecifier + ).to.equal('mongodb://domain.foo.js'); }); it('uses the remainder as filenames', function () { - expect(parseCliArgs(argv).fileNames?.[0]).to.equal('test2.txt'); + expect( + parseArgsWithCliOptions({ args: argv }).parsed.fileNames?.[0] + ).to.equal('test2.txt'); }); }); @@ -831,22 +1000,22 @@ describe('arg-parser', function () { 'when the first argument is an URI ending in .js but --file is used', function () { const argv = [ - ...baseArgv, '--file', 'mongodb://domain.foo.js', 'mongodb://domain.bar.js', ]; it('returns the first filename as an URI', function () { - expect(parseCliArgs(argv).connectionSpecifier).to.equal( - 'mongodb://domain.bar.js' - ); + expect( + parseArgsWithCliOptions({ args: argv }).parsed + .connectionSpecifier + ).to.equal('mongodb://domain.bar.js'); }); it('uses the remainder as filenames', function () { - expect(parseCliArgs(argv).fileNames?.[0]).to.equal( - 'mongodb://domain.foo.js' - ); + expect( + parseArgsWithCliOptions({ args: argv }).parsed.fileNames?.[0] + ).to.equal('mongodb://domain.foo.js'); }); } ); @@ -857,37 +1026,45 @@ describe('arg-parser', function () { context('when providing a DB address', function () { context('when only a db name is provided', function () { const db = 'foo'; - const argv = [...baseArgv, db]; + const argv = [db]; it('sets the db in the object', function () { - expect(parseCliArgs(argv).connectionSpecifier).to.equal(db); + expect( + parseArgsWithCliOptions({ args: argv }).parsed.connectionSpecifier + ).to.equal(db); }); }); context('when a db address is provided without a scheme', function () { const db = '192.168.0.5:9999/foo'; - const argv = [...baseArgv, db]; + const argv = [db]; it('sets the db in the object', function () { - expect(parseCliArgs(argv).connectionSpecifier).to.equal(db); + expect( + parseArgsWithCliOptions({ args: argv }).parsed.connectionSpecifier + ).to.equal(db); }); }); }); context('when providing no DB address', function () { context('when providing a host', function () { - const argv = [...baseArgv, '--host', 'example.com']; + const argv = ['--host', 'example.com']; it('sets the host value in the object', function () { - expect(parseCliArgs(argv).host).to.equal('example.com'); + expect( + parseArgsWithCliOptions({ args: argv }).parsed.host + ).to.equal('example.com'); }); }); context('when providing a port', function () { - const argv = [...baseArgv, '--port', '20000']; + const argv = ['--port', '20000']; it('sets the port value in the object', function () { - expect(parseCliArgs(argv).port).to.equal('20000'); + expect( + parseArgsWithCliOptions({ args: argv }).parsed.port + ).to.equal('20000'); }); }); }); @@ -933,16 +1110,539 @@ describe('arg-parser', function () { }, ] as const) { it(`replaces --${deprecated} with --${replacement}`, function () { - const argv = [...baseArgv, `--${deprecated}`]; + const argv = [`--${deprecated}`]; if (value) { argv.push(value); } - const args = parseCliArgs(argv); + const args = parseArgsWithCliOptions({ args: argv }).parsed; expect(args).to.not.have.property(deprecated); expect(args[replacement]).to.equal(value ?? true); }); } }); }); + + describe('union type fields', function () { + describe('--browser', function () { + it('does not coerce to boolean with --browser=true', function () { + expect( + parseArgsWithCliOptions({ args: ['--browser=true'] }).parsed.browser + ).to.equal('true'); + }); + + it('coerces to boolean with --browser=false', function () { + expect( + parseArgsWithCliOptions({ args: ['--browser=false'] }).parsed.browser + ).to.equal(false); + }); + + it('coerces to false with --no-browser', function () { + expect( + parseArgsWithCliOptions({ args: ['--no-browser'] }).parsed.browser + ).to.equal(false); + }); + + it('uses string if browser=something', function () { + expect( + parseArgsWithCliOptions({ args: ['--browser=something'] }).parsed + .browser + ).to.equal('something'); + }); + + it('throws if just --browser is provided', function () { + expect( + () => parseArgsWithCliOptions({ args: ['--browser'] }).parsed.browser + ).to.throw( + MongoshUnimplementedError, + '--browser can only be true or a string' + ); + }); + }); + + for (const { argument, values } of [ + { argument: 'json', values: ['relaxed', 'canonical'] }, + { argument: 'oidcDumpTokens', values: ['redacted', 'include-secrets'] }, + ] as const) { + describe(`with ${argument}`, function () { + context('with boolean', function () { + it(`get set to true with --${argument}`, function () { + expect( + parseArgsWithCliOptions({ + args: [`--${argument}`], + }).parsed[argument] + ).to.equal(true); + }); + + it(`coerces to true with --${argument}=true`, function () { + expect( + parseArgsWithCliOptions({ + args: [`--${argument}=true`], + }).parsed[argument] + ).to.equal(true); + }); + + it(`coerces to false with --${argument}=false`, function () { + expect( + parseArgsWithCliOptions({ + args: [`--${argument}=false`], + }).parsed[argument] + ).to.equal(false); + }); + }); + + for (const value of values) { + context('with string value', function () { + // This matches the legacy behavior pre-Zod schema migration. + it(`does not work with "--${argument} ${value}"`, function () { + expect( + parseArgsWithCliOptions({ + args: [`--${argument} ${value}`], + }).parsed[argument] + ).to.be.undefined; + }); + + it(`works "--${argument}=${value}"`, function () { + expect( + parseArgsWithCliOptions({ + args: [`--${argument}=${value}`], + }).parsed[argument] + ).to.equal(value); + }); + }); + } + + it('throws an error with invalid value', function () { + expect(() => + parseArgsWithCliOptions({ + args: [`--${argument}`, 'invalid'], + }) + ).to.throw( + MongoshUnimplementedError, + `--${argument} can only have the values ${values.join(', ')}` + ); + }); + }); + } + }); + + const testSchema = z.object({ + name: z.string(), + age: z.number(), + isAdmin: z.boolean(), + roles: z.array(z.string()), + }); + + describe('generateYargsOptions', function () { + it('generates from arbitrary schema', function () { + const options = generateYargsOptionsFromSchema({ + schema: testSchema, + parserOptions: { + configuration: { + 'combine-arrays': true, + }, + }, + }); + + expect(options).to.deep.equal({ + string: ['name'], + number: ['age'], + boolean: ['isAdmin'], + array: ['roles'], + coerce: {}, + alias: {}, + configuration: { + 'combine-arrays': true, + }, + }); + }); + + it('generates the expected options for CliOptions', function () { + const options = generateYargsOptionsFromSchema({ + schema: CliOptionsSchema, + }); + + const expected = { + string: [ + 'apiVersion', + 'authenticationDatabase', + 'authenticationMechanism', + 'awsAccessKeyId', + 'awsIamSessionToken', + 'awsSecretAccessKey', + 'awsSessionToken', + 'browser', + 'csfleLibraryPath', + 'cryptSharedLibPath', + 'db', + 'gssapiHostName', + 'gssapiServiceName', + 'sspiHostnameCanonicalization', + 'sspiRealmOverride', + 'host', + 'jsContext', + 'keyVaultNamespace', + 'kmsURL', + 'locale', + 'oidcFlows', + 'oidcRedirectUri', + 'password', + 'port', + 'sslPEMKeyFile', + 'sslPEMKeyPassword', + 'sslCAFile', + 'sslCertificateSelector', + 'sslCRLFile', + 'sslDisabledProtocols', + 'tlsCAFile', + 'tlsCertificateKeyFile', + 'tlsCertificateKeyFilePassword', + 'tlsCertificateSelector', + 'tlsCRLFile', + 'tlsDisabledProtocols', + 'username', + ], + boolean: [ + 'apiDeprecationErrors', + 'apiStrict', + 'buildInfo', + 'deepInspect', + 'exposeAsyncRewriter', + 'help', + 'ipv6', + 'nodb', + 'norc', + 'oidcTrustedEndpoint', + 'oidcIdTokenAsAccessToken', + 'oidcNoNonce', + 'perfTests', + 'quiet', + 'retryWrites', + 'shell', + 'smokeTests', + 'skipStartupWarnings', + 'ssl', + 'sslAllowInvalidCertificates', + 'sslAllowInvalidHostnames', + 'sslFIPSMode', + 'tls', + 'tlsAllowInvalidCertificates', + 'tlsAllowInvalidHostnames', + 'tlsFIPSMode', + 'tlsUseSystemCA', + 'verbose', + 'version', + ], + array: ['eval', 'file'], + coerce: { + json: coerceIfBoolean, + oidcDumpTokens: coerceIfBoolean, + browser: coerceIfFalse, + }, + alias: { + h: 'help', + p: 'password', + u: 'username', + f: 'file', + 'build-info': 'buildInfo', + oidcRedirectUrl: 'oidcRedirectUri', // I'd get this wrong about 50% of the time + oidcIDTokenAsAccessToken: 'oidcIdTokenAsAccessToken', // ditto + }, + configuration: { + 'camel-case-expansion': false, + 'unknown-options-as-args': true, + 'parse-positional-numbers': false, + 'parse-numbers': false, + 'greedy-arrays': false, + 'short-option-groups': false, + }, + }; + + // Compare arrays without caring about order + expect(options.string?.sort()).to.deep.equal(expected.string.sort()); + expect(options.boolean?.sort()).to.deep.equal(expected.boolean.sort()); + expect(options.array?.sort()).to.deep.equal(expected.array.sort()); + + // Compare non-array properties normally + expect(options.alias).to.deep.equal(expected.alias); + expect(options.configuration).to.deep.equal(expected.configuration); + expect(options.coerce).to.deep.equal(expected.coerce); + }); + }); + + describe('parseArgs', function () { + it('parses any schema, independent of CliOptionsSchema', function () { + const options = parseArgs({ + args: [ + 'hello', + '--port', + '20000', + '--ssl', + '1', + '--unknownField', + '1', + '--deprecatedField', + '100', + ], + schema: z.object({ + port: z.number(), + ssl: z.boolean(), + unknownField: z.string(), + replacedField: z.number(), + deprecatedField: z.number().register(argMetadata, { + deprecationReplacement: 'replacedField', + }), + }), + }); + + expect(options).to.deep.equal({ + positional: ['hello', '1'], + parsed: { + port: 20000, + replacedField: 100, + ssl: true, + unknownField: '1', + }, + deprecated: { + deprecatedField: 'replacedField', + }, + }); + }); + + describe('object fields', function () { + it('parses object fields', function () { + const options = parseArgs({ + args: ['--objectField', '{"foo":"bar"}'], + schema: z.object({ + objectField: z.object({ + foo: z.string(), + }), + }), + }); + + expect(options.parsed).to.deep.equal({ + objectField: { + foo: 'bar', + }, + }); + }); + + it('enforces the schema of the object field', function () { + const schema = z.object({ + objectField: z.object({ + foo: z.number(), + }), + }); + expect( + parseArgs({ + args: ['--objectField', '{"foo":3}'], + schema, + }).parsed.objectField + ).to.deep.equal({ foo: 3 }); + expect(() => + parseArgs({ + args: ['--objectField', '{"foo":"hello"}'], + schema, + }) + ).to.throw(InvalidArgumentError, 'expected number, received string'); + }); + + it('can handle --a.b format', function () { + const schema = z.object({ + a: z.object({ + number: z.number(), + string: z.string(), + boolean: z.boolean(), + }), + }); + expect( + parseArgs({ + args: [ + '--a.number', + '3', + '--a.string', + 'hello', + '--a.boolean', + 'true', + ], + schema, + }).parsed.a + ).to.deep.equal({ + number: 3, + string: 'hello', + boolean: true, + }); + }); + + it('can handle nested object fields', function () { + const schema = z.object({ + parent: z.object({ + child: z.string(), + nested: z.object({ + deep: z.number(), + }), + }), + }); + expect( + parseArgs({ + args: ['--parent.child', 'hello', '--parent.nested.deep', '42'], + schema, + }).parsed.parent + ).to.deep.equal({ + child: 'hello', + nested: { + deep: 42, + }, + }); + }); + + it('can handle multiple types in nested objects', function () { + const schema = z.object({ + config: z.object({ + enabled: z.boolean(), + name: z.string(), + count: z.number(), + tags: z.array(z.string()), + }), + }); + const result = parseArgs({ + args: [ + '--config.enabled', + '--config.name', + 'test', + '--config.count', + '10', + '--config.tags', + 'tag1', + '--config.tags', + 'tag2', + ], + schema, + }); + expect(result.parsed.config).to.deep.equal({ + enabled: true, + name: 'test', + count: 10, + tags: ['tag1', 'tag2'], + }); + }); + + it('generateYargsOptionsFromSchema processes nested objects', function () { + const schema = z.object({ + server: z.object({ + host: z.string(), + port: z.number(), + ssl: z.boolean(), + }), + }); + const options = generateYargsOptionsFromSchema({ schema }); + + expect(options.string).to.include('server.host'); + expect(options.number).to.include('server.port'); + expect(options.boolean).to.include('server.ssl'); + expect(options.coerce).to.have.property('server'); + }); + + it('generateYargsOptionsFromSchema processes deeply nested objects', function () { + const schema = z.object({ + level1: z.object({ + level2: z.object({ + level3: z.string(), + }), + }), + }); + const options = generateYargsOptionsFromSchema({ schema }); + + expect(options.string).to.include('level1.level2.level3'); + expect(options.coerce).to.have.property('level1'); + }); + }); + }); + + describe('parseArgsWithCliOptions', function () { + it('parses the expected options for Cli Options and replacements', function () { + const options = parseArgsWithCliOptions({ + args: ['--port', '20000', '--ssl', '1'], + }); + + expect(options).to.deep.equal({ + positional: [], + parsed: { + connectionSpecifier: '1', + fileNames: [], + port: '20000', + tls: true, + }, + deprecated: { + ssl: 'tls', + }, + }); + }); + + it('parses extended schema', function () { + const options = parseArgsWithCliOptions({ + args: [ + '--port', + '20000', + '--extendedField', + '90', + '--ssl', + 'true', + '--deprecatedField', + '100', + '--complexField', + 'false', + ], + schema: z.object({ + extendedField: z.number(), + replacedField: z.number(), + deprecatedField: z.number().register(argMetadata, { + deprecationReplacement: 'replacedField', + }), + // TODO: The expected behavior right now is pre-processing doesn't happen as part of the arg-parser. + // What we instead focus on is making sure the output is passed as expected type (i.e. z.boolean()) + // The assumption is that external users will pass the output through their schema after this parse. + // With greater testing, we should support schema assertion directly in the parser. + complexField: z.preprocess( + (value: unknown) => value === 'true', + z.boolean() + ), + }), + }); + + expect(options).to.deep.equal({ + positional: [], + parsed: { + port: '20000', + replacedField: 100, + extendedField: 90, + tls: true, + fileNames: [], + complexField: false, + }, + deprecated: { + ssl: 'tls', + deprecatedField: 'replacedField', + }, + }); + }); + + it('throws an error for fields outside of the custom schema', function () { + expect(() => + parseArgsWithCliOptions({ + args: [ + '--port', + '20000', + '--extendedField', + '90', + '--unknownField', + '100', + ], + schema: z.object({ + extendedField: z.enum(['90', '100']), + }), + }) + ).to.throw(UnknownArgumentError, 'Unknown argument: --unknownField'); + }); + }); }); diff --git a/packages/arg-parser/src/arg-parser.ts b/packages/arg-parser/src/arg-parser.ts index fbc646b63..41d958148 100644 --- a/packages/arg-parser/src/arg-parser.ts +++ b/packages/arg-parser/src/arg-parser.ts @@ -1,96 +1,28 @@ -import { CommonErrors, MongoshUnimplementedError } from '@mongosh/errors'; -import i18n from '@mongosh/i18n'; import parser from 'yargs-parser'; -import type { CliOptions } from './cli-options'; +import { z, ZodError } from 'zod/v4'; +import type { Options as YargsOptions } from 'yargs-parser'; +import { + CliOptionsSchema, + processPositionalCliOptions, + validateCliOptions, +} from './cli-options'; +import { + argMetadata, + getArgumentMetadata, + getDeprecatedArgsWithReplacement, + getUnsupportedArgs, + InvalidArgumentError, + UnknownArgumentError, + UnsupportedArgumentError, +} from './arg-metadata'; +import { + coerceIfBoolean, + coerceIfFalse, + coerceObject, + unwrapType, +} from './utils'; -/** - * The yargs-parser options configuration. - */ -const OPTIONS = { - string: [ - 'apiVersion', - 'authenticationDatabase', - 'authenticationMechanism', - 'awsAccessKeyId', - 'awsIamSessionToken', - 'awsSecretAccessKey', - 'awsSessionToken', - 'awsIamSessionToken', - 'browser', - 'csfleLibraryPath', - 'cryptSharedLibPath', - 'db', - 'gssapiHostName', - 'gssapiServiceName', - 'sspiHostnameCanonicalization', - 'sspiRealmOverride', - 'jsContext', - 'host', - 'keyVaultNamespace', - 'kmsURL', - 'locale', - 'oidcFlows', - 'oidcRedirectUri', - 'password', - 'port', - 'sslPEMKeyFile', - 'sslPEMKeyPassword', - 'sslCAFile', - 'sslCertificateSelector', - 'sslCRLFile', - 'sslDisabledProtocols', - 'tlsCAFile', - 'tlsCertificateKeyFile', - 'tlsCertificateKeyFilePassword', - 'tlsCertificateSelector', - 'tlsCRLFile', - 'tlsDisabledProtocols', - 'username', - ], - boolean: [ - 'apiDeprecationErrors', - 'apiStrict', - 'buildInfo', - 'deepInspect', - 'exposeAsyncRewriter', - 'help', - 'ipv6', - 'nodb', - 'norc', - 'oidcTrustedEndpoint', - 'oidcIdTokenAsAccessToken', - 'oidcNoNonce', - 'perfTests', - 'quiet', - 'retryWrites', - 'shell', - 'smokeTests', - 'skipStartupWarnings', - 'ssl', - 'sslAllowInvalidCertificates', - 'sslAllowInvalidHostnames', - 'sslFIPSMode', - 'tls', - 'tlsAllowInvalidCertificates', - 'tlsAllowInvalidHostnames', - 'tlsFIPSMode', - 'tlsUseSystemCA', - 'verbose', - 'version', - ], - array: ['eval', 'file'], - alias: { - h: 'help', - p: 'password', - u: 'username', - f: 'file', - 'build-info': 'buildInfo', - json: 'json', // List explicitly here since it can be a boolean or a string - browser: 'browser', // ditto - oidcDumpTokens: 'oidcDumpTokens', // ditto - oidcRedirectUrl: 'oidcRedirectUri', // I'd get this wrong about 50% of the time - oidcIDTokenAsAccessToken: 'oidcIdTokenAsAccessToken', // ditto - }, +export const defaultParserOptions: Partial = { configuration: { 'camel-case-expansion': false, 'unknown-options-as-args': true, @@ -101,147 +33,253 @@ const OPTIONS = { }, }; -/** - * Maps deprecated arguments to their new counterparts. - */ -const DEPRECATED_ARGS_WITH_REPLACEMENT: Record = { - ssl: 'tls', - sslAllowInvalidCertificates: 'tlsAllowInvalidCertificates', - sslAllowInvalidHostnames: 'tlsAllowInvalidHostnames', - sslFIPSMode: 'tlsFIPSMode', - sslPEMKeyFile: 'tlsCertificateKeyFile', - sslPEMKeyPassword: 'tlsCertificateKeyFilePassword', - sslCAFile: 'tlsCAFile', - sslCertificateSelector: 'tlsCertificateSelector', - sslCRLFile: 'tlsCRLFile', - sslDisabledProtocols: 'tlsDisabledProtocols', -}; +export type ParserOptions = Partial; -/** - * If an unsupported argument is given an error will be thrown. - */ -const UNSUPPORTED_ARGS: Readonly = ['sslFIPSMode', 'gssapiHostName']; +export function parseArgs({ + args, + schema, + parserOptions, +}: { + args: string[]; + schema: T; + parserOptions?: YargsOptions; +}): { + /** Parsed options from the schema, including replaced deprecated arguments. */ + parsed: z.infer & Omit; + /** Record of used deprecated arguments which have been replaced. */ + deprecated: Record>; + /** Positional arguments which were not parsed as options. */ + positional: parser.Arguments['_']; +} { + const options = generateYargsOptionsFromSchema({ + schema, + parserOptions, + }); -/** - * Determine the locale of the shell. - * - * @param {string[]} args - The arguments. - * - * @returns {string} The locale. - */ -export function getLocale(args: string[], env: any): string { - const localeIndex = args.indexOf('--locale'); - if (localeIndex > -1) { - return args[localeIndex + 1]; + const { argv, error } = parser.detailed(args, { + ...options, + }); + const { _: positional, ...parsedArgs } = argv; + + if (error) { + if (error instanceof ZodError) { + throw new InvalidArgumentError(error.message); + } + throw error; } - const lang = env.LANG || env.LANGUAGE || env.LC_ALL || env.LC_MESSAGES; - return lang ? lang.split('.')[0] : lang; -} -function isConnectionSpecifier(arg?: string): boolean { - return ( - typeof arg === 'string' && - (arg.startsWith('mongodb://') || - arg.startsWith('mongodb+srv://') || - !(arg.endsWith('.js') || arg.endsWith('.mongodb'))) - ); -} + const allDeprecatedArgs = getDeprecatedArgsWithReplacement(schema); + const usedDeprecatedArgs = {} as Record>; -/** - * Parses arguments into a JS object. - * - * @param args - The CLI arguments. - * - * @returns The arguments as cli options. - */ -export function parseCliArgs(args: string[]): - | CliOptions & { - smokeTests: boolean; - perfTests: boolean; - buildInfo: boolean; - _argParseWarnings: string[]; - } { - const programArgs = args.slice(2); - i18n.setLocale(getLocale(programArgs, process.env)); - - const parsed = parser(programArgs, OPTIONS) as unknown as CliOptions & { - smokeTests: boolean; - perfTests: boolean; - buildInfo: boolean; - _argParseWarnings: string[]; - _?: string[]; - file?: string[]; - }; - const positionalArguments = parsed._ ?? []; - for (const arg of positionalArguments) { - if (arg.startsWith('-')) { - throw new UnknownCliArgumentError(arg); + for (const deprecated of Object.keys(allDeprecatedArgs)) { + if (deprecated in parsedArgs) { + const replacement = allDeprecatedArgs[deprecated]; + + // This is a complicated type scenario. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (parsedArgs as any)[replacement] = + parsedArgs[deprecated as keyof typeof parsedArgs]; + usedDeprecatedArgs[deprecated] = replacement; + + delete parsedArgs[deprecated as keyof typeof parsedArgs]; + } + } + + for (const arg of positional) { + if (typeof arg === 'string' && arg.startsWith('-')) { + throw new UnknownArgumentError(arg); } } - if (!parsed.nodb && isConnectionSpecifier(positionalArguments[0])) { - parsed.connectionSpecifier = positionalArguments.shift(); + const unsupportedArgs = getUnsupportedArgs(schema); + for (const unsupported of unsupportedArgs) { + if (unsupported in parsedArgs) { + throw new UnsupportedArgumentError(unsupported); + } } - parsed.fileNames = [...(parsed.file ?? []), ...positionalArguments]; - // All positional arguments are either in connectionSpecifier or fileNames, - // and should only be accessed that way now. - delete parsed._; + return { + parsed: parsedArgs as z.infer & Omit, + deprecated: usedDeprecatedArgs, + positional, + }; +} - parsed._argParseWarnings = verifyCliArguments(parsed); +/** Parses the arguments with special handling of mongosh CLI options fields. */ +export function parseArgsWithCliOptions({ + args, + schema: schemaToExtend, + parserOptions, +}: { + args: string[]; + /** Schema to extend the CLI options schema with. */ + schema?: T; + parserOptions?: Partial; +}): ReturnType> { + const schema = + schemaToExtend !== undefined + ? z.object({ + ...CliOptionsSchema.shape, + ...schemaToExtend.shape, + }) + : CliOptionsSchema; + const { parsed, positional, deprecated } = parseArgs({ + args, + schema, + parserOptions, + }); - return parsed; + const processed = processPositionalCliOptions({ + parsed, + positional, + }); + + validateCliOptions(processed); + + return { + parsed: processed as z.infer & Omit, + positional, + deprecated, + }; } -export function verifyCliArguments(args: any /* CliOptions */): string[] { - for (const unsupported of UNSUPPORTED_ARGS) { - if (unsupported in args) { - throw new MongoshUnimplementedError( - `Argument --${unsupported} is not supported in mongosh`, - CommonErrors.InvalidArgument - ); - } - } +/** + * Generate yargs-parser configuration from schema + */ +export function generateYargsOptionsFromSchema({ + schema, + parserOptions = defaultParserOptions, +}: { + schema: z.ZodObject; + parserOptions?: Partial; +}): YargsOptions { + const options: Required< + Pick< + YargsOptions, + 'string' | 'boolean' | 'array' | 'alias' | 'coerce' | 'number' + > & { array: string[] } + > = { + ...parserOptions, + string: [], + boolean: [], + array: [], + alias: {}, + coerce: {}, + number: [], + }; - if (![undefined, true, false, 'relaxed', 'canonical'].includes(args.json)) { - throw new MongoshUnimplementedError( - '--json can only have the values relaxed or canonical', - CommonErrors.InvalidArgument - ); - } + /** + * Recursively process fields in a schema, including nested object fields + */ + function processFields(currentSchema: z.ZodObject, prefix = ''): void { + for (const [fieldName, fieldSchema] of Object.entries( + currentSchema.shape + )) { + const fullFieldName = prefix ? `${prefix}.${fieldName}` : fieldName; + const meta = getArgumentMetadata(currentSchema, fieldName); - if ( - ![undefined, true, false, 'redacted', 'include-secrets'].includes( - args.oidcDumpTokens - ) - ) { - throw new MongoshUnimplementedError( - '--oidcDumpTokens can only have the values redacted or include-secrets', - CommonErrors.InvalidArgument - ); - } + const unwrappedType = unwrapType(fieldSchema); + + // Determine type + if (unwrappedType instanceof z.ZodArray) { + options.array.push(fullFieldName); + } else if (unwrappedType instanceof z.ZodBoolean) { + options.boolean.push(fullFieldName); + } else if (unwrappedType instanceof z.ZodString) { + options.string.push(fullFieldName); + } else if (unwrappedType instanceof z.ZodNumber) { + options.number.push(fullFieldName); + } else if (unwrappedType instanceof z.ZodUnion) { + // Handle union types (like json, browser, oidcDumpTokens) + const unionOptions = ( + unwrappedType as z.ZodUnion<[z.ZodTypeAny, ...z.ZodTypeAny[]]> + ).options; + + const hasString = unionOptions.some( + (opt) => opt instanceof z.ZodString || opt instanceof z.ZodEnum + ); - const messages = []; - for (const deprecated in DEPRECATED_ARGS_WITH_REPLACEMENT) { - if (deprecated in args) { - const replacement = DEPRECATED_ARGS_WITH_REPLACEMENT[deprecated]; - messages.push( - `WARNING: argument --${deprecated} is deprecated and will be removed. Use --${replacement} instead.` - ); + if (hasString) { + const hasFalseLiteral = unionOptions.some( + (opt) => opt instanceof z.ZodLiteral && opt.value === false + ); + const hasBoolean = unionOptions.some( + (opt) => opt instanceof z.ZodBoolean + ); + if (hasFalseLiteral) { + // If set to 'false' coerce into false boolean; string in all other cases + options.coerce[fullFieldName] = coerceIfFalse; + // Setting as string prevents --{field} from being valid. + options.string.push(fullFieldName); + } else if (hasBoolean) { + // If the field is 'true' or 'false', we coerce the value to a boolean. + options.coerce[fullFieldName] = coerceIfBoolean; + } else { + options.string.push(fullFieldName); + } + } + } else if (unwrappedType instanceof z.ZodEnum) { + if ( + unwrappedType.options.every((opt: unknown) => typeof opt === 'string') + ) { + options.string.push(fullFieldName); + } else if ( + unwrappedType.options.every((opt: unknown) => typeof opt === 'number') + ) { + options.number.push(fullFieldName); + } else { + throw new Error( + `${fullFieldName} has unsupported enum options. Currently, only string and number enum options are supported.` + ); + } + } else if (unwrappedType instanceof z.ZodObject) { + // For top-level object fields (no prefix), keep the coerce function + // to support --field '{"key":"value"}' format + if (!prefix) { + options.coerce[fullFieldName] = coerceObject(unwrappedType); + } + // Recursively process nested fields + processFields(unwrappedType, fullFieldName); + } else { + throw new Error( + `Unknown field type: ${ + unwrappedType instanceof Object + ? unwrappedType.constructor.name + : typeof unwrappedType + }` + ); + } - args[replacement] = args[deprecated]; - delete args[deprecated]; + // Add aliases (only for top-level fields) + if (!prefix && meta?.alias) { + for (const a of meta.alias) { + options.alias[a] = fullFieldName; + } + } } } - return messages; + + processFields(schema); + + return options; } -export class UnknownCliArgumentError extends Error { - /** The argument that was not parsed. */ - readonly argument: string; - constructor(argument: string) { - super(`Unknown argument: ${argument}`); - this.name = 'UnknownCliArgumentError'; - this.argument = argument; +/** + * Determine the locale of the shell. + * + * @param {string[]} args - The arguments. + * + * @returns {string} The locale. + */ +export function getLocale(args: string[], env: any): string { + const localeIndex = args.indexOf('--locale'); + if (localeIndex > -1) { + return args[localeIndex + 1]; } + const lang = env.LANG || env.LANGUAGE || env.LC_ALL || env.LC_MESSAGES; + return lang ? lang.split('.')[0] : lang; } + +export { argMetadata, UnknownArgumentError, UnsupportedArgumentError }; +export { type ArgumentMetadata } from './arg-metadata'; +export { type CliOptions, CliOptionsSchema } from './cli-options'; diff --git a/packages/arg-parser/src/cli-options.ts b/packages/arg-parser/src/cli-options.ts index 0dafc7bc5..f5e5139f9 100644 --- a/packages/arg-parser/src/cli-options.ts +++ b/packages/arg-parser/src/cli-options.ts @@ -1,63 +1,244 @@ +import z from 'zod/v4'; +import { argMetadata } from './arg-metadata'; +import { CommonErrors, MongoshUnimplementedError } from '@mongosh/errors'; +import type parser from 'yargs-parser'; + +export const CurrentCliOptionsSchema = z.object({ + // String options + apiVersion: z.string().optional(), + authenticationDatabase: z.string().optional(), + authenticationMechanism: z.string().optional(), + awsAccessKeyId: z.string().optional(), + awsIamSessionToken: z.string().optional(), + awsSecretAccessKey: z.string().optional(), + awsSessionToken: z.string().optional(), + csfleLibraryPath: z.string().optional(), + cryptSharedLibPath: z.string().optional(), + // TODO: This default doesn't do anything on its own but is used as documentation for now. + deepInspect: z.boolean().default(true).optional(), + db: z.string().optional(), + gssapiServiceName: z.string().optional(), + sspiHostnameCanonicalization: z.string().optional(), + sspiRealmOverride: z.string().optional(), + jsContext: z.enum(['repl', 'plain-vm', 'auto']).optional(), + host: z.string().optional(), + keyVaultNamespace: z.string().optional(), + kmsURL: z.string().optional(), + locale: z.string().optional(), + oidcFlows: z.string().optional(), + oidcRedirectUri: z + .string() + .optional() + .register(argMetadata, { + alias: ['oidcRedirectUrl'], + }), + password: z + .string() + .optional() + .register(argMetadata, { alias: ['p'] }), + port: z.string().optional(), + username: z + .string() + .optional() + .register(argMetadata, { alias: ['u'] }), + + // TLS options + tlsCAFile: z.string().optional(), + tlsCertificateKeyFile: z.string().optional(), + tlsCertificateKeyFilePassword: z.string().optional(), + tlsCertificateSelector: z.string().optional(), + tlsCRLFile: z.string().optional(), + tlsDisabledProtocols: z.string().optional(), + + // Boolean options + apiDeprecationErrors: z.boolean().optional(), + apiStrict: z.boolean().optional(), + buildInfo: z + .boolean() + .optional() + .register(argMetadata, { alias: ['build-info'] }), + exposeAsyncRewriter: z.boolean().optional(), + help: z + .boolean() + .optional() + .register(argMetadata, { alias: ['h'] }), + ipv6: z.boolean().optional(), + nodb: z.boolean().optional(), + norc: z.boolean().optional(), + oidcTrustedEndpoint: z.boolean().optional(), + oidcIdTokenAsAccessToken: z + .boolean() + .optional() + .register(argMetadata, { + alias: ['oidcIDTokenAsAccessToken'], + }), + oidcNoNonce: z.boolean().optional(), + quiet: z.boolean().optional(), + retryWrites: z.boolean().optional(), + shell: z.boolean().optional(), + skipStartupWarnings: z.boolean().optional(), + verbose: z.boolean().optional(), + version: z.boolean().optional(), + + // Tests + smokeTests: z.boolean().optional(), + perfTests: z.boolean().optional(), + + // TLS boolean options + tls: z.boolean().optional(), + tlsAllowInvalidCertificates: z.boolean().optional(), + tlsAllowInvalidHostnames: z.boolean().optional(), + tlsFIPSMode: z.boolean().optional(), + tlsUseSystemCA: z.boolean().optional(), + + // Array options + eval: z.array(z.string()).optional(), + file: z + .array(z.string()) + .optional() + .register(argMetadata, { alias: ['f'] }), + + // Options that can be boolean or string + json: z.union([z.boolean(), z.enum(['relaxed', 'canonical'])]).optional(), + oidcDumpTokens: z + .union([z.boolean(), z.enum(['redacted', 'include-secrets'])]) + .optional(), + browser: z.union([z.literal(false), z.string()]).optional(), +}); + +export const DeprecatedCliOptions = z.object({ + ssl: z + .boolean() + .optional() + .register(argMetadata, { deprecationReplacement: 'tls' }), + sslAllowInvalidCertificates: z.boolean().optional().register(argMetadata, { + deprecationReplacement: 'tlsAllowInvalidCertificates', + }), + sslAllowInvalidHostnames: z.boolean().optional().register(argMetadata, { + deprecationReplacement: 'tlsAllowInvalidHostnames', + }), + sslPEMKeyFile: z.string().optional().register(argMetadata, { + deprecationReplacement: 'tlsCertificateKeyFile', + }), + sslPEMKeyPassword: z.string().optional().register(argMetadata, { + deprecationReplacement: 'tlsCertificateKeyFilePassword', + }), + sslCAFile: z.string().optional().register(argMetadata, { + deprecationReplacement: 'tlsCAFile', + }), + sslCertificateSelector: z.string().optional().register(argMetadata, { + deprecationReplacement: 'tlsCertificateSelector', + }), + sslCRLFile: z.string().optional().register(argMetadata, { + deprecationReplacement: 'tlsCRLFile', + }), + sslDisabledProtocols: z.string().optional().register(argMetadata, { + deprecationReplacement: 'tlsDisabledProtocols', + }), +}); + +export const UnsupportedCliOptions = z.object({ + gssapiHostName: z + .string() + .optional() + .register(argMetadata, { unsupported: true }), + sslFIPSMode: z.boolean().optional().register(argMetadata, { + unsupported: true, + }), +}); + +export const CliOptionsSchema = z.object({ + ...CurrentCliOptionsSchema.shape, + ...DeprecatedCliOptions.shape, + ...UnsupportedCliOptions.shape, +}); + +type ExcludedFields = + | keyof typeof DeprecatedCliOptions + | keyof typeof UnsupportedCliOptions; + /** * Valid options that can be parsed from the command line. */ -export interface CliOptions { - // Positional arguments: +export type CliOptions = Omit< + z.infer, + ExcludedFields +> & { + // Positional arguments connectionSpecifier?: string; fileNames?: string[]; +}; + +export function processPositionalCliOptions({ + parsed, + positional, +}: { + parsed: T; + positional: parser.Arguments['_']; +}): T { + const processed = { ...parsed }; + if (typeof positional[0] === 'string') { + if (!processed.nodb && isConnectionSpecifier(positional[0])) { + processed.connectionSpecifier = positional.shift() as string; + } + } + processed.fileNames = [ + ...(processed.file ?? []), + ...(positional as string[]), + ]; + + return processed; +} + +/** + * Validates the CLI options. + * TODO: Use proper schema validation for all fields. + * For now, to minimize impact of adopting Zod, this only validates the enum values. + */ +export function validateCliOptions(parsed: CliOptions): void { + const jsonValidation = CliOptionsSchema.shape.json.safeParse(parsed.json); + if (!jsonValidation.success) { + throw new MongoshUnimplementedError( + '--json can only have the values relaxed, canonical', + CommonErrors.InvalidArgument + ); + } + + const oidcDumpTokensValidation = + CliOptionsSchema.shape.oidcDumpTokens.safeParse(parsed.oidcDumpTokens); + if (!oidcDumpTokensValidation.success) { + throw new MongoshUnimplementedError( + '--oidcDumpTokens can only have the values redacted, include-secrets', + CommonErrors.InvalidArgument + ); + } + + const jsContextValidation = CliOptionsSchema.shape.jsContext.safeParse( + parsed.jsContext + ); + if (!jsContextValidation.success) { + throw new MongoshUnimplementedError( + '--jsContext can only have the values repl, plain-vm, auto', + CommonErrors.InvalidArgument + ); + } + + const browserValidation = CliOptionsSchema.shape.browser.safeParse( + parsed.browser + ); + if (!browserValidation.success) { + throw new MongoshUnimplementedError( + '--browser can only be true or a string', + CommonErrors.InvalidArgument + ); + } +} - // Non-positional arguments: - apiDeprecationErrors?: boolean; - apiStrict?: boolean; - apiVersion?: string; - authenticationDatabase?: string; - authenticationMechanism?: string; - awsAccessKeyId?: string; - awsIamSessionToken?: string; - awsSecretAccessKey?: string; - awsSessionToken?: string; - csfleLibraryPath?: string; - cryptSharedLibPath?: string; - db?: string; - deepInspect?: boolean; // defaults to true - eval?: string[]; - exposeAsyncRewriter?: boolean; // internal testing only - gssapiServiceName?: string; - sspiHostnameCanonicalization?: string; - sspiRealmOverride?: string; - help?: boolean; - host?: string; - ipv6?: boolean; - jsContext?: 'repl' | 'plain-vm' | 'auto'; - json?: boolean | 'canonical' | 'relaxed'; - keyVaultNamespace?: string; - kmsURL?: string; - nodb?: boolean; - norc?: boolean; - password?: string; - port?: string; - quiet?: boolean; - retryWrites?: boolean; - shell?: boolean; - skipStartupWarnings?: boolean; - tls?: boolean; - tlsAllowInvalidCertificates?: boolean; - tlsAllowInvalidHostnames?: boolean; - tlsCAFile?: string; - tlsCertificateKeyFile?: string; - tlsCertificateKeyFilePassword?: string; - tlsCertificateSelector?: string; - tlsCRLFile?: string; - tlsDisabledProtocols?: boolean; - tlsFIPSMode?: boolean; - username?: string; - verbose?: boolean; // No-op since driver v5.0.0 (see also MONGOSH-970) - version?: boolean; - oidcFlows?: string; - oidcRedirectUri?: string; - oidcTrustedEndpoint?: boolean; - oidcIdTokenAsAccessToken?: boolean; - oidcDumpTokens?: boolean | 'redacted' | 'include-secrets'; - oidcNoNonce?: boolean; - browser?: string | false; +function isConnectionSpecifier(arg?: string): boolean { + return ( + typeof arg === 'string' && + (arg.startsWith('mongodb://') || + arg.startsWith('mongodb+srv://') || + !(arg.endsWith('.js') || arg.endsWith('.mongodb'))) + ); } diff --git a/packages/arg-parser/src/utils.ts b/packages/arg-parser/src/utils.ts new file mode 100644 index 000000000..c91416f70 --- /dev/null +++ b/packages/arg-parser/src/utils.ts @@ -0,0 +1,74 @@ +import z from 'zod/v4'; + +export function coerceObject(schema: z.ZodObject): (value: unknown) => unknown { + return (value: unknown) => { + switch (typeof value) { + case 'string': + return schema.parse(JSON.parse(value)); + case 'object': + return value; + default: + return null; + } + }; +} + +export function coerceIfBoolean(value: unknown): unknown { + if (typeof value === 'string') { + if (value === 'true') { + return true; + } + if (value === 'false') { + return false; + } + return value; + } + return value; +} + +export function coerceIfFalse(value: unknown): unknown { + if (value === undefined || value === '') { + return null; + } + + if (typeof value === 'string') { + if (value === 'false') { + return false; + } + return value; + } + return value; +} + +export function unwrapType(type: unknown): z.ZodType { + assertZodType(type); + let unwrappedType = z.clone(type); + while ( + unwrappedType instanceof z.ZodOptional || + unwrappedType instanceof z.ZodDefault || + unwrappedType instanceof z.ZodNullable || + unwrappedType instanceof z.ZodPipe + ) { + if (unwrappedType instanceof z.ZodPipe) { + const nextWrap = unwrappedType.def.out; + assertZodType(nextWrap); + unwrappedType = nextWrap; + } else { + const nextWrap = unwrappedType.unwrap(); + assertZodType(nextWrap); + unwrappedType = nextWrap; + } + } + + return unwrappedType; +} + +function assertZodType(type: unknown): asserts type is z.ZodType { + if (!(type instanceof z.ZodType)) { + throw new Error( + `Unknown schema field type: ${ + type && typeof type === 'object' ? type.constructor.name : typeof type + }` + ); + } +} diff --git a/packages/arg-parser/tsconfig.json b/packages/arg-parser/tsconfig.json index 51ef57e2c..467155d4f 100644 --- a/packages/arg-parser/tsconfig.json +++ b/packages/arg-parser/tsconfig.json @@ -2,6 +2,7 @@ "extends": "@mongodb-js/tsconfig-mongosh/tsconfig.common.json", "compilerOptions": { "outDir": "./lib", + "rootDir": "./src", "allowJs": true }, "include": ["src/**/*"], diff --git a/packages/cli-repl/package.json b/packages/cli-repl/package.json index 1dcdfefd1..020b9217b 100644 --- a/packages/cli-repl/package.json +++ b/packages/cli-repl/package.json @@ -92,7 +92,6 @@ "pretty-repl": "^4.0.1", "semver": "^7.5.4", "strip-ansi": "^6.0.0", - "glibc-version": "^1.0.0", "text-table": "^0.2.0" }, "devDependencies": { diff --git a/packages/cli-repl/src/arg-parser.spec.ts b/packages/cli-repl/src/arg-parser.spec.ts index 916dc64f1..15293b546 100644 --- a/packages/cli-repl/src/arg-parser.spec.ts +++ b/packages/cli-repl/src/arg-parser.spec.ts @@ -1,8 +1,9 @@ import { expect } from 'chai'; -import { parseMongoshCliArgs } from './arg-parser'; +import { parseMongoshArgs } from './arg-parser'; import stripAnsi from 'strip-ansi'; +import { MongoshUnimplementedError } from '@mongosh/errors'; -describe('parseMongoshCliArgs', function () { +describe('parseMongoshArgs', function () { const baseArgv = ['node', 'mongosh']; const uri = 'mongodb://domain.com:2020'; context('when providing an unknown parameter', function () { @@ -10,7 +11,7 @@ describe('parseMongoshCliArgs', function () { it('raises an error', function () { try { - parseMongoshCliArgs(argv); + parseMongoshArgs(argv); } catch (err: any) { return expect(stripAnsi(err.message)).to.contain( 'Error parsing command line: unrecognized option: --what' @@ -20,20 +21,43 @@ describe('parseMongoshCliArgs', function () { }); context('parses standard arguments correctly', function () { + it('parses connectionSpecifier correctly', function () { + const argv = [...baseArgv, uri]; + const args = parseMongoshArgs(argv); + expect(args.parsed.connectionSpecifier).to.equal(uri); + }); + + it('parses fileNames correctly', function () { + const argv = [...baseArgv, uri, 'file1.js', 'file2.js']; + const args = parseMongoshArgs(argv); + expect(args.parsed.fileNames).to.deep.equal(['file1.js', 'file2.js']); + }); + it('sets passed fields', function () { const argv = [...baseArgv, uri, '--tls', '--port', '1234']; - const args = parseMongoshCliArgs(argv); - expect(args['tls']).equals(true); - expect(args['port']).equals('1234'); + const args = parseMongoshArgs(argv); + expect(args.parsed['tls']).equals(true); + expect(args.parsed['port']).equals('1234'); + }); + + it('throws an error for unsupported arguments', function () { + const argv = [...baseArgv, '--gssapiHostName', 'example.com']; + expect(() => parseMongoshArgs(argv)).to.throw( + MongoshUnimplementedError, + 'Argument --gssapiHostName is not supported in mongosh' + ); }); it(`replaces --sslPEMKeyFile with --tlsCertificateKeyFile`, function () { const argv = [...baseArgv, `--sslPEMKeyFile`, `test`]; - const args = parseMongoshCliArgs(argv); + const args = parseMongoshArgs(argv); expect(args).to.not.have.property('sslPEMKeyFile'); - expect(args['tlsCertificateKeyFile']).to.equal('test'); + expect(args.parsed['tlsCertificateKeyFile']).to.equal('test'); + expect(args.warnings).to.deep.equal([ + 'WARNING: argument --sslPEMKeyFile is deprecated and will be removed. Use --tlsCertificateKeyFile instead.', + ]); }); }); }); diff --git a/packages/cli-repl/src/arg-parser.ts b/packages/cli-repl/src/arg-parser.ts index 4ff9762a4..7ac6f4dea 100644 --- a/packages/cli-repl/src/arg-parser.ts +++ b/packages/cli-repl/src/arg-parser.ts @@ -1,29 +1,52 @@ import i18n from '@mongosh/i18n'; import { - parseCliArgs, - UnknownCliArgumentError, + getLocale, + parseArgsWithCliOptions, + UnknownArgumentError, + UnsupportedArgumentError, } from '@mongosh/arg-parser/arg-parser'; import { colorizeForStderr as clr } from './clr'; import { USAGE } from './constants'; +import type { CliOptions } from '@mongosh/arg-parser'; +import { CommonErrors, MongoshUnimplementedError } from '@mongosh/errors'; /** * Unknown translation key. */ const UNKNOWN = 'cli-repl.arg-parser.unknown-option'; -export function parseMongoshCliArgs( - args: string[] -): ReturnType { +export function parseMongoshArgs(argsWithProgram: string[]): { + parsed: CliOptions; + warnings: string[]; +} { try { - return parseCliArgs(args); + const args = argsWithProgram.slice(2); + i18n.setLocale(getLocale(argsWithProgram, process.env)); + + const { parsed, deprecated } = parseArgsWithCliOptions({ args }); + const warnings = Object.entries(deprecated).map( + ([deprecated, replacement]) => + `WARNING: argument --${deprecated} is deprecated and will be removed. Use --${replacement} instead.` + ); + return { + parsed, + warnings, + }; } catch (error) { - if (error instanceof UnknownCliArgumentError) { - throw new Error( + if (error instanceof UnsupportedArgumentError) { + throw new MongoshUnimplementedError( + `Argument --${error.argument} is not supported in mongosh`, + CommonErrors.InvalidArgument + ); + } + if (error instanceof UnknownArgumentError) { + throw new MongoshUnimplementedError( ` ${clr(i18n.__(UNKNOWN), 'mongosh:error')} ${clr( String(error.argument), 'bold' )} - ${USAGE}` + ${USAGE}`, + CommonErrors.InvalidArgument ); } throw error; diff --git a/packages/cli-repl/src/run.ts b/packages/cli-repl/src/run.ts index c4fb29006..fa65a6bcb 100644 --- a/packages/cli-repl/src/run.ts +++ b/packages/cli-repl/src/run.ts @@ -15,7 +15,7 @@ enableFipsIfRequested(); import { markTime } from './startup-timing'; import { CliRepl } from './cli-repl'; -import { parseMongoshCliArgs } from './arg-parser'; +import { parseMongoshArgs } from './arg-parser'; import { runSmokeTests } from './smoke-tests'; import { USAGE } from './constants'; import { baseBuildInfo, buildInfo } from './build-info'; @@ -85,8 +85,8 @@ async function main() { try { (net as any)?.setDefaultAutoSelectFamily?.(true); - const options = parseMongoshCliArgs(process.argv); - for (const warning of options._argParseWarnings) { + const { parsed: options, warnings } = parseMongoshArgs(process.argv); + for (const warning of warnings) { console.warn(warning); } diff --git a/packages/snippet-manager/package.json b/packages/snippet-manager/package.json index 410796691..9db7e8faa 100644 --- a/packages/snippet-manager/package.json +++ b/packages/snippet-manager/package.json @@ -42,8 +42,8 @@ "bson": "^6.10.4", "cross-spawn": "^7.0.5", "escape-string-regexp": "^4.0.0", - "zod": "^3.24.1", - "tar": "^6.1.15" + "tar": "^6.1.15", + "zod": "^3.25.76" }, "devDependencies": { "@mongodb-js/eslint-config-mongosh": "^1.0.0",