diff --git a/.gitignore b/.gitignore index a641a4b3..3391be3e 100644 --- a/.gitignore +++ b/.gitignore @@ -14,4 +14,5 @@ experiments .log coverage test-results.xml -.history \ No newline at end of file +.history +/test/test-homedir \ No newline at end of file diff --git a/src/commands/shell.mjs b/src/commands/shell.mjs index a08f7333..67c7a61e 100644 --- a/src/commands/shell.mjs +++ b/src/commands/shell.mjs @@ -1,5 +1,6 @@ //@ts-check +import path from "node:path"; import repl from "node:repl"; import { container } from "../cli.mjs"; @@ -7,15 +8,28 @@ import { // ensureDbScopeClient, commonConfigurableQueryOptions, } from "../lib/command-helpers.mjs"; +import { dirExists, fileExists } from "../lib/file-util.mjs"; import { performQuery } from "./eval.mjs"; async function doShell(argv) { + const fs = container.resolve("fs"); const logger = container.resolve("logger"); let completionPromise; if (argv.dbPath) logger.stdout(`Starting shell for database ${argv.dbPath}`); logger.stdout("Type Ctrl+D or .exit to exit the shell"); + // Setup history file + const homedir = container.resolve("homedir"); + const historyDir = path.join(homedir.toString(), ".fauna"); + if (!dirExists(historyDir)) { + fs.mkdirSync(historyDir, { recursive: true }); + } + const historyFile = path.join(historyDir, "history"); + if (!fileExists(historyFile)) { + fs.writeFileSync(historyFile, "{}"); + } + /** @type {import('node:repl').ReplOptions} */ const replArgs = { prompt: `${argv.db_path || ""}> `, @@ -27,9 +41,18 @@ async function doShell(argv) { input: container.resolve("stdinStream"), eval: await buildCustomEval(argv), terminal: true, + historySize: 1000 }; const shell = repl.start(replArgs); + + // Setup history + shell.setupHistory(historyFile, (err) => { + if (err) { + logger.stderr(`Error setting up history: ${err.message}`); + } + }); + // eslint-disable-next-line no-console shell.on("error", console.error); @@ -55,6 +78,25 @@ async function doShell(argv) { shell.prompt(); }, }, + { + cmd: "clearhistory", + help: "Clear command history", + action: async () => { + try { + await fs.writeFileSync(historyFile, ''); + logger.stdout('History cleared'); + // Reinitialize history + shell.setupHistory(historyFile, (err) => { + if (err) { + logger.stderr(`Error reinitializing history: ${err.message}`); + } + }); + } catch (err) { + logger.stderr(`Error clearing history: ${err.message}`); + } + shell.prompt(); + }, + } ].forEach(({ cmd, ...cmdOptions }) => shell.defineCommand(cmd, cmdOptions)); return completionPromise; diff --git a/src/config/setup-test-container.mjs b/src/config/setup-test-container.mjs index 1bb8d247..af1e1e29 100644 --- a/src/config/setup-test-container.mjs +++ b/src/config/setup-test-container.mjs @@ -51,7 +51,10 @@ export function setupTestContainer() { // wrap it in a spy so we can record calls, but use the // real implementation parseYargs: awilix.asValue(spy(parseYargs)), - fs: awilix.asValue(stub(fs)), + // Stubbing node:fs globally breaks tests where we want fs to work normally. + // Let tests decide how fs should be stubbed out if they don't want the real + // implementation. + fs: awilix.asValue(fs), fsp: awilix.asValue({ unlink: stub(), writeFile: stub(), diff --git a/src/lib/file-util.mjs b/src/lib/file-util.mjs index b1ecd300..ba07692c 100644 --- a/src/lib/file-util.mjs +++ b/src/lib/file-util.mjs @@ -1,6 +1,4 @@ //@ts-check - -import fs from "node:fs"; import path from "node:path"; import { container } from "../cli.mjs"; @@ -22,15 +20,8 @@ export function fixPath(path) { * @returns {boolean} */ export function dirExists(path) { - const stat = fs.statSync(fixPath(path), { - // returns undefined instead of throwing if the file doesn't exist - throwIfNoEntry: false, - }); - if (stat === undefined || !stat.isDirectory()) { - return false; - } else { - return true; - } + const fs = container.resolve("fs"); + return fs.existsSync(fixPath(path)); } /** @@ -39,6 +30,7 @@ export function dirExists(path) { * @returns {boolean} */ export function dirIsWriteable(path) { + const fs = container.resolve("fs"); try { fs.accessSync(fixPath(path), fs.constants.W_OK); } catch (e) { @@ -54,7 +46,8 @@ export function dirIsWriteable(path) { * @param {string} path - The path to the file. * @returns {boolean} - Returns true if the file exists, otherwise false. */ -function fileExists(path) { +export function fileExists(path) { + const fs = container.resolve("fs"); try { fs.readFileSync(fixPath(path)); return true; @@ -69,6 +62,7 @@ function fileExists(path) { * @returns {Object.} - The parsed JSON content of the file. */ function getJSONFileContents(path) { + const fs = container.resolve("fs"); // Open file for reading and writing without truncating const fileContent = fs.readFileSync(path, { flag: "r+" }).toString(); if (!fileContent) { @@ -103,11 +97,13 @@ export class Credentials { * @param {string} [filename=""] - The name of the credentials file. */ constructor(filename = "") { + const fs = container.resolve("fs"); + this.logger = container.resolve("logger"); this.filename = filename; const homedir = container.resolve("homedir"); - this.credsDir = path.join(homedir.toString(),".fauna/credentials"); + this.credsDir = path.join(homedir.toString(), ".fauna/credentials"); if (!dirExists(this.credsDir)) { fs.mkdirSync(this.credsDir, { recursive: true }); @@ -146,6 +142,8 @@ export class Credentials { * @param {string} params.key - The key to index the creds under */ save({ creds, overwrite = false, key }) { + const fs = container.resolve("fs"); + try { const existingContent = overwrite ? {} : this.get(); const newContent = { @@ -159,6 +157,8 @@ export class Credentials { } delete(key) { + const fs = container.resolve("fs"); + try { const existingContent = this.get(); delete existingContent[key]; @@ -193,6 +193,8 @@ export class SecretKey extends Credentials { * @param {string} opts.creds.role - The role to save the secret */ this.save = ({ creds, overwrite = false, key }) => { + const fs = container.resolve("fs"); + try { const existingContent = overwrite ? {} : this.get(); const existingAccountSecrets = existingContent[key] || {}; diff --git a/test/login.mjs b/test/login.mjs index 1eb16094..72b3c609 100644 --- a/test/login.mjs +++ b/test/login.mjs @@ -1,4 +1,5 @@ //@ts-check +import node_fs from "node:fs"; import path from "node:path"; import * as awilix from "awilix"; @@ -72,6 +73,7 @@ describe("login", function () { accountClient: awilix.asFunction(mockAccountClient).scoped(), accountCreds: awilix.asClass(AccountKey).scoped(), homedir: awilix.asFunction(() => homedir).scoped(), + fs: awilix.asValue(stub(node_fs)), }); fs = container.resolve("fs"); }); diff --git a/test/schema/pull.mjs b/test/schema/pull.mjs index d356d309..cacb6938 100644 --- a/test/schema/pull.mjs +++ b/test/schema/pull.mjs @@ -1,4 +1,5 @@ //@ts-check +import node_fs from "node:fs"; import * as awilix from "awilix"; import { expect } from "chai"; @@ -30,6 +31,7 @@ describe("schema pull", function () { deleteUnusedSchemaFiles: awilix.asValue( sinon.spy(deleteUnusedSchemaFiles), ), + fs: awilix.asValue(sinon.stub(node_fs)), }); logger = container.resolve("logger"); fetch = container.resolve("fetch"); diff --git a/test/shell.mjs b/test/shell.mjs index 9e9c15ab..4dd54ad6 100644 --- a/test/shell.mjs +++ b/test/shell.mjs @@ -1,7 +1,8 @@ //@ts-check - import { EOL } from "node:os"; +import path from "node:path"; +import * as awilix from "awilix"; import { expect } from "chai"; import sinon from "sinon"; @@ -45,10 +46,19 @@ const v4Object2 = `{ describe("shell", function () { let container, stdin, stdout, logger; - let prompt = `${EOL}\x1B[1G\x1B[0J> \x1B[3G`; + + const promptReset = "\x1B[1G\x1B[0J> "; + const prompt = `${EOL}${promptReset}\x1B[3G`; + const getHistoryPrompt = (text) => `${promptReset}${text}\u001b[${3 + text.length}G` beforeEach(() => { + const __dirname = import.meta.dirname; + const homedir = path.join(__dirname, "../test/test-homedir"); + container = setupContainer(); + container.register({ + homedir: awilix.asFunction(() => homedir).scoped(), + }); stdin = container.resolve("stdinStream"); stdout = container.resolve("stdoutStream"); logger = container.resolve("logger"); @@ -89,6 +99,50 @@ describe("shell", function () { it.skip("can read input from a file", async function () {}); it.skip("can set a connection timeout", async function () {}); + + const upArrow = "\x1b[A"; + const downArrow = "\x1b[B"; + + it("can keep track of history", async function () { + // start the shell + const runPromise = run(`shell --secret "secret" --typecheck`, container); + + // send our first command + stdin.push(`1\n2\n3\n`); + await stdout.waitForWritten(); + + // navigate up through history + stdout.clear(); + stdin.push(upArrow); + await stdout.waitForWritten(); + expect(stdout.getWritten()).to.equal(getHistoryPrompt("3")); + stdout.clear(); + stdin.push(upArrow); + await stdout.waitForWritten(); + expect(stdout.getWritten()).to.equal(getHistoryPrompt("2")); + stdout.clear(); + stdin.push(upArrow); + await stdout.waitForWritten(); + expect(stdout.getWritten()).to.equal(getHistoryPrompt("1")); + stdout.clear(); + stdin.push(downArrow); + await stdout.waitForWritten(); + expect(stdout.getWritten()).to.equal(getHistoryPrompt("2")); + stdout.clear(); + stdin.push(downArrow); + await stdout.waitForWritten(); + expect(stdout.getWritten()).to.equal(getHistoryPrompt("3")); + + expect(container.resolve("stderrStream").getWritten()).to.equal(""); + + stdin.push(null); + + return runPromise; + }); + + it.skip("can clear history", async function () {}); + + it.skip("can save history between sessions", async function () {}); }); describe("v10", function () {