diff --git a/packages/cli-repl/README.md b/packages/cli-repl/README.md index a09b0f20c2..35ad489273 100644 --- a/packages/cli-repl/README.md +++ b/packages/cli-repl/README.md @@ -16,7 +16,7 @@ CLI interface for [MongoDB Shell][mongosh], an extension to Node.js REPL with Mo --version Show version information --shell Run the shell after executing files --nodb Don't connect to mongod on startup - no 'db address' [arg] expected - --norc Will not run the '.mongorc.js' file on start up + --norc Will not run the '.mongoshrc.js' file on start up --eval [arg] Evaluate javascript --retryWrites Automatically retry write operations upon transient network errors diff --git a/packages/cli-repl/src/cli-repl.spec.ts b/packages/cli-repl/src/cli-repl.spec.ts index c3e3ce3cc2..14d2a56ce1 100644 --- a/packages/cli-repl/src/cli-repl.spec.ts +++ b/packages/cli-repl/src/cli-repl.spec.ts @@ -42,6 +42,7 @@ describe('CliRepl', () => { shellHomePaths: { shellRoamingDataPath: tmpdir.path, shellLocalDataPath: tmpdir.path, + shellRcPath: tmpdir.path, }, onExit: (code: number) => { exitCode = code; @@ -233,6 +234,73 @@ describe('CliRepl', () => { process.env.MONGOSH_SKIP_NODE_VERSION_CHECK = origVersionCheckEnvVar || ''; } }); + + context('mongoshrc', () => { + it('loads .mongoshrc if it is present', async() => { + await fs.writeFile(path.join(tmpdir.path, '.mongoshrc.js'), 'print("hi from mongoshrc")'); + cliRepl = new CliRepl(cliReplOptions); + await cliRepl.start('', {}); + expect(output).to.include('hi from mongoshrc'); + }); + + it('does not load .mongoshrc if --norc is passed', async() => { + await fs.writeFile(path.join(tmpdir.path, '.mongoshrc.js'), 'print("hi from mongoshrc")'); + cliReplOptions.shellCliOptions.norc = true; + cliRepl = new CliRepl(cliReplOptions); + await cliRepl.start('', {}); + expect(output).not.to.include('hi from mongoshrc'); + }); + + it('warns if .mongorc.js is present but not .mongoshrc.js', async() => { + await fs.writeFile(path.join(tmpdir.path, '.mongorc.js'), 'print("hi from mongorc")'); + cliRepl = new CliRepl(cliReplOptions); + await cliRepl.start('', {}); + expect(output).to.include('Found ~/.mongorc.js, but not ~/.mongoshrc.js. ~/.mongorc.js will not be loaded.'); + expect(output).to.include('You may want to copy or rename ~/.mongorc.js to ~/.mongoshrc.js.'); + expect(output).not.to.include('hi from mongorc'); + }); + + it('warns if .mongoshrc is present but not .mongoshrc.js', async() => { + await fs.writeFile(path.join(tmpdir.path, '.mongoshrc'), 'print("hi from misspelled")'); + cliRepl = new CliRepl(cliReplOptions); + await cliRepl.start('', {}); + expect(output).to.include('Found ~/.mongoshrc, but not ~/.mongoshrc.js.'); + expect(output).not.to.include('hi from misspelled'); + }); + + it('does not warn with --quiet if .mongorc.js is present but not .mongoshrc.js', async() => { + await fs.writeFile(path.join(tmpdir.path, '.mongorc.js'), 'print("hi from mongorc")'); + cliReplOptions.shellCliOptions.quiet = true; + cliRepl = new CliRepl(cliReplOptions); + await cliRepl.start('', {}); + expect(output).not.to.include('Found ~/.mongorc.js, but not ~/.mongoshrc.js'); + expect(output).not.to.include('hi from mongorc'); + }); + + it('does not warn with --quiet if .mongoshrc is present but not .mongoshrc.js', async() => { + await fs.writeFile(path.join(tmpdir.path, '.mongoshrc'), 'print("hi from misspelled")'); + cliReplOptions.shellCliOptions.quiet = true; + cliRepl = new CliRepl(cliReplOptions); + await cliRepl.start('', {}); + expect(output).not.to.include('Found ~/.mongoshrc, but not ~/.mongoshrc.js'); + expect(output).not.to.include('hi from misspelled'); + }); + + it('loads .mongoshrc recursively if wanted', async() => { + const rcPath = path.join(tmpdir.path, '.mongoshrc.js'); + await fs.writeFile( + rcPath, + `globalThis.a = (globalThis.a + 1 || 0); + if (a === 5) { + print('reached five'); + } else { + load(JSON.stringify(${rcPath}) + }`); + cliRepl = new CliRepl(cliReplOptions); + await cliRepl.start('', {}); + expect(output).to.include('reached five'); + }); + }); }); verifyAutocompletion({ diff --git a/packages/cli-repl/src/cli-repl.ts b/packages/cli-repl/src/cli-repl.ts index 9149d9ca51..c6171829cc 100644 --- a/packages/cli-repl/src/cli-repl.ts +++ b/packages/cli-repl/src/cli-repl.ts @@ -150,7 +150,60 @@ class CliRepl { } const initialServiceProvider = await this.connect(driverUri, driverOptions); - await this.mongoshRepl.start(initialServiceProvider); + const initialized = await this.mongoshRepl.initialize(initialServiceProvider); + await this.loadRcFiles(); + await this.mongoshRepl.startRepl(initialized); + } + + async loadRcFiles(): Promise { + if (this.cliOptions.norc) { + return; + } + const legacyPath = this.shellHomeDirectory.rcPath('.mongorc.js'); + const mongoshrcPath = this.shellHomeDirectory.rcPath('.mongoshrc.js'); + const mongoshrcMisspelledPath = this.shellHomeDirectory.rcPath('.mongoshrc'); + + let hasMongoshRc = false; + try { + await fs.stat(mongoshrcPath); + hasMongoshRc = true; + } catch { /* file not present */ } + if (hasMongoshRc) { + try { + await this.mongoshRepl.loadExternalFile(mongoshrcPath); + } catch (err) { + this.output.write(this.mongoshRepl.writer(err) + '\n'); + } + return; + } + + if (this.cliOptions.quiet) { + return; + } + + let hasLegacyRc = false; + try { + await fs.stat(legacyPath); + hasLegacyRc = true; + } catch { /* file not present */ } + if (hasLegacyRc) { + const msg = + 'Warning: Found ~/.mongorc.js, but not ~/.mongoshrc.js. ~/.mongorc.js will not be loaded.\n' + + ' You may want to copy or rename ~/.mongorc.js to ~/.mongoshrc.js.\n'; + this.output.write(this.clr(msg, ['bold', 'yellow'])); + return; + } + + let hasMisspelledFilename = false; + try { + await fs.stat(mongoshrcMisspelledPath); + hasMisspelledFilename = true; + } catch { /* file not present */ } + if (hasMisspelledFilename) { + const msg = + 'Warning: Found ~/.mongoshrc, but not ~/.mongoshrc.js. Did you forget to add .js?\n'; + this.output.write(this.clr(msg, ['bold', 'yellow'])); + } } /** diff --git a/packages/cli-repl/src/config-directory.spec.ts b/packages/cli-repl/src/config-directory.spec.ts index fe497179d5..ba0c7b6ce6 100644 --- a/packages/cli-repl/src/config-directory.spec.ts +++ b/packages/cli-repl/src/config-directory.spec.ts @@ -24,7 +24,8 @@ describe('home directory management', () => { base = path.resolve(__dirname, '..', '..', '..', 'tmp', 'test', `${Date.now()}`, `${Math.random()}`); shellHomeDirectory = new ShellHomeDirectory({ shellRoamingDataPath: base, - shellLocalDataPath: base + shellLocalDataPath: base, + shellRcPath: base }); manager = new ConfigManager(shellHomeDirectory); manager.on('error', onError = sinon.spy()); diff --git a/packages/cli-repl/src/config-directory.ts b/packages/cli-repl/src/config-directory.ts index 916c7a39df..9797d03b66 100644 --- a/packages/cli-repl/src/config-directory.ts +++ b/packages/cli-repl/src/config-directory.ts @@ -6,6 +6,7 @@ import { EventEmitter } from 'events'; export type ShellHomePaths = { shellRoamingDataPath: string; shellLocalDataPath: string; + shellRcPath: string; }; export class ShellHomeDirectory { @@ -31,6 +32,10 @@ export class ShellHomeDirectory { localPath(subpath: string): string { return path.join(this.paths.shellLocalDataPath, subpath); } + + rcPath(subpath: string): string { + return path.join(this.paths.shellRcPath, subpath); + } } export class ConfigManager extends EventEmitter { @@ -118,6 +123,7 @@ export function getStoragePaths(): ShellHomePaths { shellRoamingDataPath ??= homedir; return { shellLocalDataPath, - shellRoamingDataPath + shellRoamingDataPath, + shellRcPath: os.homedir() }; } diff --git a/packages/cli-repl/src/constants.ts b/packages/cli-repl/src/constants.ts index e876167133..07f92740ed 100644 --- a/packages/cli-repl/src/constants.ts +++ b/packages/cli-repl/src/constants.ts @@ -23,6 +23,7 @@ export const USAGE = ` --port [arg] ${i18n.__('cli-repl.args.port')} --version ${i18n.__('cli-repl.args.version')} --nodb ${i18n.__('cli-repl.args.nodb')} + --norc ${i18n.__('cli-repl.args.norc')} --retryWrites ${i18n.__('cli-repl.args.retryWrites')} ${clr(i18n.__('cli-repl.args.authenticationOptions'), ['bold', 'yellow'])} diff --git a/packages/cli-repl/src/mongosh-repl.spec.ts b/packages/cli-repl/src/mongosh-repl.spec.ts index 22612d7bb1..74b8cdb635 100644 --- a/packages/cli-repl/src/mongosh-repl.spec.ts +++ b/packages/cli-repl/src/mongosh-repl.spec.ts @@ -88,7 +88,8 @@ describe('MongoshNodeRepl', () => { context('with default options', () => { beforeEach(async() => { - await mongoshRepl.start(serviceProvider); + const initialized = await mongoshRepl.initialize(serviceProvider); + await mongoshRepl.startRepl(initialized); }); it('shows a nice message to say hello', () => { @@ -223,7 +224,8 @@ describe('MongoshNodeRepl', () => { ...mongoshReplOptions, nodeReplOptions: { terminal: true } }); - await mongoshRepl.start(serviceProvider); + const initialized = await mongoshRepl.initialize(serviceProvider); + await mongoshRepl.startRepl(initialized); }); it('provides an editor action', async() => { @@ -424,7 +426,8 @@ describe('MongoshNodeRepl', () => { Object.assign(outputStream, fakeTTYProps); Object.assign(input, fakeTTYProps); mongoshRepl = new MongoshNodeRepl(mongoshReplOptions); - await mongoshRepl.start(serviceProvider); + const initialized = await mongoshRepl.initialize(serviceProvider); + await mongoshRepl.startRepl(initialized); expect(mongoshRepl.getFormatOptions().colors).to.equal(true); }); @@ -515,7 +518,7 @@ describe('MongoshNodeRepl', () => { }); it('warns about the unavailable history file support', async() => { - await mongoshRepl.start(serviceProvider); + await mongoshRepl.initialize(serviceProvider); expect(output).to.include('Error processing history file'); }); }); @@ -523,7 +526,7 @@ describe('MongoshNodeRepl', () => { context('when the config says to skip the telemetry greeting message', () => { beforeEach(async() => { config.disableGreetingMessage = true; - await mongoshRepl.start(serviceProvider); + await mongoshRepl.initialize(serviceProvider); }); it('skips telemetry intro', () => { @@ -538,7 +541,7 @@ describe('MongoshNodeRepl', () => { nodb: true }; mongoshRepl = new MongoshNodeRepl(mongoshReplOptions); - await mongoshRepl.start(serviceProvider); + await mongoshRepl.initialize(serviceProvider); }); it('does not show warnings', () => { @@ -555,7 +558,7 @@ describe('MongoshNodeRepl', () => { sp.runCommandWithCheck.withArgs(ADMIN_DB, { getLog: 'startupWarnings' }, {}).resolves({ ok: 1, log: logLines }); - await mongoshRepl.start(serviceProvider); + await mongoshRepl.initialize(serviceProvider); expect(output).to.contain('The server generated these startup warnings when booting'); logLines.forEach(l => { @@ -567,7 +570,7 @@ describe('MongoshNodeRepl', () => { sp.runCommandWithCheck.withArgs(ADMIN_DB, { getLog: 'startupWarnings' }, {}).resolves({ ok: 1, log: ['Not JSON'] }); - await mongoshRepl.start(serviceProvider); + await mongoshRepl.initialize(serviceProvider); expect(output).to.contain('The server generated these startup warnings when booting'); expect(output).to.contain('Unexpected log line format: Not JSON'); @@ -578,7 +581,7 @@ describe('MongoshNodeRepl', () => { sp.runCommandWithCheck.withArgs(ADMIN_DB, { getLog: 'startupWarnings' }, {}).resolves({ ok: 1, log: [] }); - await mongoshRepl.start(serviceProvider); + await mongoshRepl.initialize(serviceProvider); expect(output).to.not.contain('The server generated these startup warnings when booting'); expect(error).to.be.null; @@ -590,7 +593,7 @@ describe('MongoshNodeRepl', () => { sp.runCommandWithCheck.withArgs(ADMIN_DB, { getLog: 'startupWarnings' }, {}).rejects(expectedError); - await mongoshRepl.start(serviceProvider); + await mongoshRepl.initialize(serviceProvider); expect(output).to.not.contain('The server generated these startup warnings when booting'); expect(output).to.not.contain('Error'); @@ -602,7 +605,7 @@ describe('MongoshNodeRepl', () => { sp.runCommandWithCheck.withArgs(ADMIN_DB, { getLog: 'startupWarnings' }, {}).resolves(undefined); - await mongoshRepl.start(serviceProvider); + await mongoshRepl.initialize(serviceProvider); expect(output).to.not.contain('The server generated these startup warnings when booting'); expect(output).to.not.contain('Error'); @@ -624,12 +627,14 @@ describe('MongoshNodeRepl', () => { } }); - await mongoshRepl.start(serviceProvider); + const initialized = await mongoshRepl.initialize(serviceProvider); + await mongoshRepl.startRepl(initialized); expect(output).to.contain('Enterprise > '); }); it('defaults if an error occurs', async() => { - await mongoshRepl.start(serviceProvider); + const initialized = await mongoshRepl.initialize(serviceProvider); + await mongoshRepl.startRepl(initialized); expect(output).to.contain('> '); mongoshRepl.runtimeState().internalState.getDefaultPrompt = () => { throw new Error('no prompt'); }; diff --git a/packages/cli-repl/src/mongosh-repl.ts b/packages/cli-repl/src/mongosh-repl.ts index cab7f88017..ded768d1a1 100644 --- a/packages/cli-repl/src/mongosh-repl.ts +++ b/packages/cli-repl/src/mongosh-repl.ts @@ -42,6 +42,9 @@ export type MongoshNodeReplOptions = { nodeReplOptions?: Partial; }; +// Used to make sure start() can only be called after initialize(). +export type InitializationToken = { __initialized: 'yes' }; + type MongoshRuntimeState = { shellEvaluator: ShellEvaluator; internalState: ShellInternalState; @@ -81,7 +84,7 @@ class MongoshNodeRepl implements EvaluationListener { this._runtimeState = null; } - async start(serviceProvider: ServiceProvider): Promise { + async initialize(serviceProvider: ServiceProvider): Promise { const internalState = new ShellInternalState(serviceProvider, this.bus, this.shellCliOptions); const shellEvaluator = new ShellEvaluator(internalState); internalState.setEvaluationListener(this); @@ -95,7 +98,7 @@ class MongoshNodeRepl implements EvaluationListener { start: prettyRepl.start, input: this.lineByLineInput as unknown as Readable, output: this.output, - prompt: await this.getShellPrompt(internalState), + prompt: '', writer: this.writer.bind(this), breakEvalOnSigint: true, preview: false, @@ -233,9 +236,17 @@ class MongoshNodeRepl implements EvaluationListener { }); internalState.setCtx(repl.context); + return { __initialized: 'yes' }; + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async startRepl(_initializationToken: InitializationToken): Promise { + const { repl, internalState } = this.runtimeState(); // Only start reading from the input *after* we set up everything, including // internalState.setCtx(). this.lineByLineInput.start(); + repl.setPrompt(await this.getShellPrompt(internalState)); + repl.displayPrompt(); } /** @@ -334,6 +345,10 @@ class MongoshNodeRepl implements EvaluationListener { }; } + async loadExternalFile(filename: string): Promise { + await this.runtimeState().internalState.shellApi.load(filename); + } + /** * Format the result to a string so it can be written to the output stream. */ diff --git a/packages/cli-repl/test/e2e.spec.ts b/packages/cli-repl/test/e2e.spec.ts index bc5e243f21..495d5aacb1 100644 --- a/packages/cli-repl/test/e2e.spec.ts +++ b/packages/cli-repl/test/e2e.spec.ts @@ -491,7 +491,7 @@ describe('e2e', function() { }); }); - describe('config and logging', async() => { + describe('config, logging and rc file', async() => { let shell: TestShell; let homedir: string; let configPath: string; @@ -500,7 +500,7 @@ describe('e2e', function() { let historyPath: string; let readConfig: () => Promise; let readLogfile: () => Promise; - let startTestShell: () => Promise; + let startTestShell: (...extraArgs: string[]) => Promise; let env: Record; beforeEach(() => { @@ -522,9 +522,9 @@ describe('e2e', function() { } readConfig = async() => JSON.parse(await fs.readFile(configPath, 'utf8')); readLogfile = async() => readReplLogfile(logPath); - startTestShell = async() => { + startTestShell = async(...extraArgs: string[]) => { const shell = TestShell.start({ - args: [ '--nodb' ], + args: [ '--nodb', ...extraArgs ], env: env, forceTerminal: true }); @@ -623,6 +623,22 @@ describe('e2e', function() { expect(await fs.readFile(historyPath, 'utf8')).to.match(/^a = 42$/m); }); }); + + describe('mongoshrc', () => { + beforeEach(async() => { + await fs.writeFile(path.join(homedir, '.mongoshrc.js'), 'print("hi from mongoshrc")'); + }); + + it('loads .mongoshrc.js if it is there', async() => { + shell = await startTestShell(); + shell.assertContainsOutput('hi from mongoshrc'); + }); + + it('does not load .mongoshrc.js if --norc is passed', async() => { + shell = await startTestShell('--norc'); + shell.assertNotContainsOutput('hi from mongoshrc'); + }); + }); }); context('in a restricted environment', () => { diff --git a/packages/i18n/src/locales/en_US.ts b/packages/i18n/src/locales/en_US.ts index 746850cdbe..13d3529aec 100644 --- a/packages/i18n/src/locales/en_US.ts +++ b/packages/i18n/src/locales/en_US.ts @@ -17,7 +17,7 @@ const translations: Catalog = { version: 'Show version information', shell: 'Run the shell after executing files', nodb: "Don't connect to mongod on startup - no 'db address' [arg] expected", - norc: "Will not run the '.mongorc.js' file on start up", + norc: "Will not run the '.mongoshrc.js' file on start up", eval: 'Evaluate javascript', retryWrites: 'Automatically retry write operations upon transient network errors', authenticationOptions: 'Authentication Options:',