diff --git a/README.md b/README.md index c80b8abf..26769278 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,7 @@ For complete details on each command, refer to the following documents: - [`lang`](./docs/cli/lang.md) - [`config create`](./docs/cli/config.md) - [`config`](./docs/cli/config.md) +- [`cache`](./docs/cli/cache.md) Each link provides access to the full documentation for the command, including additional details, options, and usage examples. diff --git a/bin/index.js b/bin/index.js index 17ffceab..99911d5c 100755 --- a/bin/index.js +++ b/bin/index.js @@ -120,6 +120,14 @@ prog .describe(i18n.getTokenSync("cli.commands.config.desc")) .action(commands.config.editConfigFile); +prog + .command("cache") + .option("-l, --list", i18n.getTokenSync("cli.commands.cache.option_list"), false) + .option("-c, --clear", i18n.getTokenSync("cli.commands.cache.option_clear"), false) + .option("-f, --full", i18n.getTokenSync("cli.commands.cache.option_full"), false) + .describe(i18n.getTokenSync("cli.commands.cache.desc")) + .action(commands.cache.main); + prog.parse(process.argv); function defaultScannerCommand(name, options = {}) { diff --git a/docs/cli/cache.md b/docs/cli/cache.md new file mode 100644 index 00000000..b78906be --- /dev/null +++ b/docs/cli/cache.md @@ -0,0 +1,17 @@ +## 📝 Command `cache` + +The `cache` command allows you to manage NodeSecure cache, which is used for the packages navigation and the search page. + +## 📜 Syntax + +```bash +$ nsecure cache [options] +``` + +## ⚙️ Available Options + +| Name | Shortcut | Default Value | Description | +|---|---|---|---| +| `--list` | `-l` | `false` | Display the cache contents in JSON format. Use with `--full` to include scanned payloads stored on disk. | +| `--clear` | `-c` | `false` | Remove cached entries. Use with `--full` to also delete scanned payloads stored on disk. | +| `--full` | `-f` | `false` | Extend the scope of `--list` or `--clear` to include scanned payloads stored on disk.| diff --git a/i18n/english.js b/i18n/english.js index c1bb8acd..f2819ada 100644 --- a/i18n/english.js +++ b/i18n/english.js @@ -65,6 +65,16 @@ const cli = { configCreate: { desc: "Init your Nodesecure config file", option_cwd: "Create config file at the cwd" + }, + cache: { + desc: "Manage NodeSecure cache", + missingAction: "No valid action specified. Use --help to see options.", + option_list: "List cache files", + option_clear: "Clear the cache", + option_full: "Clear or list the full cache, including payloads", + cacheTitle: "NodeSecure Cache:", + scannedPayloadsTitle: "Scanned payloads available on disk:", + cleared: "Cache cleared successfully!" } }, startHttp: { diff --git a/i18n/french.js b/i18n/french.js index 39e90126..b41348fa 100644 --- a/i18n/french.js +++ b/i18n/french.js @@ -65,6 +65,16 @@ const cli = { configCreate: { desc: "Initialiser le fichier de configuration Nodesecure", option_cwd: "Créer le fichier dans le dossier courant" + }, + cache: { + desc: "Gérer le cache de NodeSecure", + missingAction: "Aucune action valide spécifiée. Utilisez --help pour voir les options.", + option_list: "Lister les fichiers du cache", + option_clear: "Nettoyer le cache", + option_full: "Nettoyer ou lister le cache complet, y compris les payloads", + cacheTitle: "Cache NodeSecure:", + scannedPayloadsTitle: "Payloads scannés disponibles sur le disque:", + cleared: "Cache nettoyé avec succès !" } }, startHttp: { diff --git a/package.json b/package.json index 485f2943..c701ffb7 100644 --- a/package.json +++ b/package.json @@ -18,8 +18,7 @@ "build": "node ./esbuild.config.js", "test": "npm run test-only && npm run lint", "test-only": "glob -c \"node --no-warnings --test-concurrency 1 --test\" \"test/**/*.test.js\"", - "coverage": "c8 --reporter=lcov npm run test", - "clear:cache": "node ./scripts/clear-cache.js" + "coverage": "c8 --reporter=lcov npm run test" }, "files": [ "bin", @@ -94,6 +93,7 @@ "@openally/result": "^1.3.0", "@polka/send-type": "^0.5.2", "@topcli/cliui": "^1.1.0", + "@topcli/pretty-json": "^1.0.0", "@topcli/prompts": "^2.0.0", "@topcli/spinner": "^3.0.0", "cacache": "^19.0.1", diff --git a/scripts/clear-cache.js b/scripts/clear-cache.js deleted file mode 100644 index 9473883d..00000000 --- a/scripts/clear-cache.js +++ /dev/null @@ -1,9 +0,0 @@ -// Import Third-party Dependencies -import cacache from "cacache"; - -// Import Internal Dependencies -import { CACHE_PATH } from "../src/cache.js"; - -await cacache.rm.all(CACHE_PATH); - -console.log("Cache cleared successfully!"); diff --git a/src/cache.js b/src/cache.js index 2469fd1f..adb5c019 100644 --- a/src/cache.js +++ b/src/cache.js @@ -88,7 +88,9 @@ class _AppCache { } } - async #initDefaultPayloadsList() { + async #initDefaultPayloadsList(options = {}) { + const { logging = true } = options; + if (this.startFromZero) { const payloadsList = { mru: [], @@ -99,7 +101,9 @@ class _AppCache { root: null }; - logger.info("[cache|init](startFromZero)"); + if (logging) { + logger.info("[cache|init](startFromZero)"); + } await cacache.put(CACHE_PATH, `${this.prefix}${kPayloadsCache}`, JSON.stringify(payloadsList)); return; @@ -119,13 +123,22 @@ class _AppCache { root: formatted }; - logger.info(`[cache|init](dep: ${formatted}|version: ${version}|rootDependencyName: ${payload.rootDependencyName})`); + if (logging) { + logger.info(`[cache|init](dep: ${formatted}|version: ${version}|rootDependencyName: ${payload.rootDependencyName})`); + } await cacache.put(CACHE_PATH, `${this.prefix}${kPayloadsCache}`, JSON.stringify(payloadsList)); this.updatePayload(formatted, payload); } async initPayloadsList(options = {}) { - const { logging = true } = options; + const { + logging = true, + reset = false + } = options; + + if (reset) { + await cacache.rm.all(CACHE_PATH); + } try { // prevent re-initialization of the cache @@ -138,7 +151,7 @@ class _AppCache { } const packagesInFolder = this.availablePayloads(); if (packagesInFolder.length === 0) { - await this.#initDefaultPayloadsList(); + await this.#initDefaultPayloadsList({ logging }); return; } diff --git a/src/commands/cache.js b/src/commands/cache.js new file mode 100644 index 00000000..481ee8cd --- /dev/null +++ b/src/commands/cache.js @@ -0,0 +1,62 @@ +// Import Node.js Dependencies +import { styleText } from "node:util"; +import { setImmediate } from "node:timers/promises"; + +// Import Third-party Dependencies +import prettyJson from "@topcli/pretty-json"; +import * as i18n from "@nodesecure/i18n"; + +// Import Internal Dependencies +import { appCache } from "../cache.js"; + +export async function main(options) { + const { + list, + clear, + full + } = options; + + await i18n.getLocalLang(); + + if (!(list || clear)) { + console.log(styleText("red", i18n.getTokenSync("cli.commands.cache.missingAction"))); + process.exit(1); + } + + if (list) { + listCache(full); + } + if (clear) { + await setImmediate(); + await clearCache(full); + } +} + +async function listCache(full) { + const paylodsList = await appCache.payloadsList(); + console.log(styleText(["underline"], i18n.getTokenSync("cli.commands.cache.cacheTitle"))); + prettyJson(paylodsList); + + if (full) { + console.log(styleText(["underline"], i18n.getTokenSync("cli.commands.cache.scannedPayloadsTitle"))); + try { + const payloads = appCache.availablePayloads(); + prettyJson(payloads); + } + catch { + prettyJson([]); + } + } +} + +async function clearCache(full) { + if (full) { + appCache.availablePayloads().forEach((pkg) => { + appCache.removePayload(pkg); + }); + } + + await appCache.initPayloadsList({ logging: false, reset: true }); + + console.log(styleText("green", i18n.getTokenSync("cli.commands.cache.cleared"))); +} diff --git a/src/commands/index.js b/src/commands/index.js index 1c034cf9..4abcd7c0 100644 --- a/src/commands/index.js +++ b/src/commands/index.js @@ -6,3 +6,4 @@ export * as scanner from "./scanner.js"; export * as config from "./config.js"; export * as scorecard from "./scorecard.js"; export * as report from "./report.js"; +export * as cache from "./cache.js"; diff --git a/test/commands/cache.test.js b/test/commands/cache.test.js new file mode 100644 index 00000000..21dc83c0 --- /dev/null +++ b/test/commands/cache.test.js @@ -0,0 +1,118 @@ +import dotenv from "dotenv"; +dotenv.config(); + +// Import Node.js Dependencies +import fs from "node:fs"; +import path from "node:path"; +import url from "node:url"; +import assert from "node:assert"; +import childProcess from "node:child_process"; +import { after, before, describe, it } from "node:test"; + +// Import Third-party Dependencies +import * as i18n from "@nodesecure/i18n"; + +// Import Internal Dependencies +import { arrayFromAsync } from "../helpers/utils.js"; +import { appCache, DEFAULT_PAYLOAD_PATH } from "../../src/cache.js"; +import { main } from "../../src/commands/cache.js"; + +// CONSTANTS +const __dirname = path.dirname(url.fileURLToPath(import.meta.url)); + +describe("Cache command", { concurrency: 1 }, () => { + let lang; + let actualCache; + let dummyPayload = null; + + before(async() => { + if (fs.existsSync(DEFAULT_PAYLOAD_PATH) === false) { + dummyPayload = { + rootDependencyName: "test_runner", + dependencies: { + test_runner: { + versions: { + "1.0.0": {} + } + } + } + }; + fs.writeFileSync(DEFAULT_PAYLOAD_PATH, JSON.stringify(dummyPayload)); + } + await i18n.setLocalLang("english"); + await i18n.extendFromSystemPath( + path.join(__dirname, "..", "..", "i18n") + ); + lang = await i18n.getLocalLang(); + + try { + actualCache = await appCache.payloadsList(); + } + catch { + await appCache.initPayloadsList({ logging: false }); + actualCache = await appCache.payloadsList(); + } + + appCache.updatePayload("test-package", { foo: "bar" }); + }); + + after(async() => { + await i18n.setLocalLang(lang); + await i18n.getLocalLang(); + + await appCache.updatePayloadsList(actualCache, { logging: false }); + appCache.removePayload("test-package"); + + if (dummyPayload !== null) { + fs.rmSync(DEFAULT_PAYLOAD_PATH); + } + }); + + it("should list the cache", async() => { + const cp = childProcess.spawn("node", [ + ".", + "cache", + "-l" + ]); + const stdout = await arrayFromAsync(cp.stdout); + const inlinedStdout = stdout.join(""); + assert.ok(inlinedStdout.includes(i18n.getTokenSync("cli.commands.cache.cacheTitle"))); + assert.strictEqual(inlinedStdout.includes(i18n.getTokenSync("cli.commands.cache.scannedPayloadsTitle")), false); + }); + + it("should list the cache and scanned payloads on disk", async() => { + const cp = childProcess.spawn("node", [ + ".", + "cache", + "-lf" + ]); + const stdout = await arrayFromAsync(cp.stdout); + const inlinedStdout = stdout.join(""); + assert.ok(inlinedStdout.includes(i18n.getTokenSync("cli.commands.cache.cacheTitle"))); + assert.ok(inlinedStdout.includes(i18n.getTokenSync("cli.commands.cache.scannedPayloadsTitle"))); + }); + + it("should clear the cache", async(ctx) => { + let rmSyncCalled = false; + ctx.mock.method(fs, "rmSync", () => { + rmSyncCalled = true; + }); + await main({ + clear: true, + full: false + }); + assert.strictEqual(rmSyncCalled, false, "should not have removed payloads on disk without --full option"); + }); + + it("should clear the cache and payloads on disk", async(ctx) => { + let rmSyncCalled = false; + ctx.mock.method(fs, "rmSync", () => { + rmSyncCalled = true; + }); + await main({ + clear: true, + full: true + }); + assert.ok(rmSyncCalled, "should have removed payloads on disk with --full option"); + }); +}); diff --git a/test/helpers/utils.js b/test/helpers/utils.js index 372b4f45..b5368503 100644 --- a/test/helpers/utils.js +++ b/test/helpers/utils.js @@ -35,7 +35,12 @@ export async function arrayFromAsync(stream) { const chunks = []; for await (const chunk of stream) { - chunks.push(chunk); + if (Buffer.isBuffer(chunk)) { + chunks.push(chunk.toString("utf8")); + } + else { + chunks.push(chunk); + } } return chunks;