diff --git a/.size-limit.cjs b/.size-limit.cjs index 6c44674..d995e7b 100644 --- a/.size-limit.cjs +++ b/.size-limit.cjs @@ -12,12 +12,24 @@ function getObjectValues(input, acc = []) { } module.exports = [ - ...new Set([pkg.main, pkg.module, ...getObjectValues(pkg.exports)]), + ...new Set([ + pkg.main, + pkg.module, + ...getObjectValues(pkg.bin), + ...getObjectValues(pkg.exports), + ]), ] .sort() .filter((path) => { return path && path !== "./package.json"; }) .map((path) => { - return { path }; + return { + path, + modifyEsbuildConfig(config) { + config.platform = "node"; + + return config; + }, + }; }); diff --git a/bin/prismic-next.js b/bin/prismic-next.js new file mode 100644 index 0000000..14bc04c --- /dev/null +++ b/bin/prismic-next.js @@ -0,0 +1,3 @@ +#!/usr/bin/env node + +import("../dist/cli.cjs").then((mod) => mod.run(process.argv)); diff --git a/package-lock.json b/package-lock.json index e4f11dc..ebdf7d9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,11 @@ "version": "1.1.0", "license": "Apache-2.0", "dependencies": { - "imgix-url-builder": "^0.0.3" + "imgix-url-builder": "^0.0.3", + "mri": "^1.2.0" + }, + "bin": { + "prismic-next": "bin/prismic-next.mjs" }, "devDependencies": { "@prismicio/client": "^7.0.0-alpha.3", @@ -27,6 +31,7 @@ "eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-tsdoc": "^0.2.17", "happy-dom": "^9.9.2", + "memfs": "^3.5.1", "next": "^13.4.0", "node-fetch": "^3.3.1", "prettier": "^2.8.8", @@ -3725,6 +3730,12 @@ "node": ">=14.14" } }, + "node_modules/fs-monkey": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/fs-monkey/-/fs-monkey-1.0.3.tgz", + "integrity": "sha512-cybjIfiiE+pTWicSCLFHSrXZ6EilF30oh91FDP9S2B051prEa7QWfrVTQm10/dDpswBDXZugPa1Ogu8Yh+HV0Q==", + "dev": true + }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -5023,6 +5034,18 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/memfs": { + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/memfs/-/memfs-3.5.1.tgz", + "integrity": "sha512-UWbFJKvj5k+nETdteFndTpYxdeTMox/ULeqX5k/dpaQJCCFmj5EeKv3dBcyO2xmkRAx2vppRu5dVG7SOtsGOzA==", + "dev": true, + "dependencies": { + "fs-monkey": "^1.0.3" + }, + "engines": { + "node": ">= 4.0.0" + } + }, "node_modules/meow": { "version": "8.1.2", "resolved": "https://registry.npmjs.org/meow/-/meow-8.1.2.tgz", @@ -5746,7 +5769,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", - "dev": true, "engines": { "node": ">=4" } @@ -10727,6 +10749,12 @@ "universalify": "^2.0.0" } }, + "fs-monkey": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/fs-monkey/-/fs-monkey-1.0.3.tgz", + "integrity": "sha512-cybjIfiiE+pTWicSCLFHSrXZ6EilF30oh91FDP9S2B051prEa7QWfrVTQm10/dDpswBDXZugPa1Ogu8Yh+HV0Q==", + "dev": true + }, "fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -11691,6 +11719,15 @@ "integrity": "sha512-n4Vypz/DZgwo0iMHLQL49dJzlp7YtAJP+N07MZHpjPf/5XJuHUWstviF4Mn2jEiR/GNmtnRRqnwsXExk3igfFA==", "dev": true }, + "memfs": { + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/memfs/-/memfs-3.5.1.tgz", + "integrity": "sha512-UWbFJKvj5k+nETdteFndTpYxdeTMox/ULeqX5k/dpaQJCCFmj5EeKv3dBcyO2xmkRAx2vppRu5dVG7SOtsGOzA==", + "dev": true, + "requires": { + "fs-monkey": "^1.0.3" + } + }, "meow": { "version": "8.1.2", "resolved": "https://registry.npmjs.org/meow/-/meow-8.1.2.tgz", @@ -12137,8 +12174,7 @@ "mri": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", - "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", - "dev": true + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==" }, "ms": { "version": "2.1.2", diff --git a/package.json b/package.json index 8fde7d0..b748e1e 100644 --- a/package.json +++ b/package.json @@ -45,8 +45,12 @@ "size": "size-limit", "test": "npm run lint && npm run types && npm run unit && npm run build && npm run size" }, + "bin": { + "prismic-next": "./bin/prismic-next.js" + }, "dependencies": { - "imgix-url-builder": "^0.0.3" + "imgix-url-builder": "^0.0.3", + "mri": "^1.2.0" }, "devDependencies": { "@prismicio/client": "^7.0.0-alpha.3", @@ -64,6 +68,7 @@ "eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-tsdoc": "^0.2.17", "happy-dom": "^9.9.2", + "memfs": "^3.5.1", "next": "^13.4.0", "node-fetch": "^3.3.1", "prettier": "^2.8.8", diff --git a/src/cli/index.ts b/src/cli/index.ts new file mode 100644 index 0000000..8471b42 --- /dev/null +++ b/src/cli/index.ts @@ -0,0 +1,168 @@ +import mri from "mri"; +import * as path from "node:path"; +import * as fs from "node:fs/promises"; +import * as tty from "node:tty"; +import { Buffer } from "node:buffer"; + +import * as pkg from "../../package.json"; + +async function pathExists(filePath: string) { + try { + await fs.access(filePath); + + return true; + } catch { + return false; + } +} + +function color(colorCode: number, string: string) { + return tty.WriteStream.prototype.hasColors() + ? "\u001B[" + colorCode + "m" + string + "\u001B[39m" + : string; +} + +function warn(string: string) { + // Yellow + return console.warn(`${color(33, "warn")} - ${string}`); +} + +function info(string: string) { + // Magenta + return console.info(`${color(35, "info")} - ${string}`); +} + +type Args = { + help: boolean; + version: boolean; +}; + +export async function run(argv: string[]) { + const args = mri(argv.slice(2), { + boolean: ["help", "version"], + alias: { + help: "h", + version: "v", + }, + default: { + help: false, + version: false, + }, + }); + + const command = args._[0]; + + switch (command) { + case "clear-cache": { + warn( + "`prismic-next clear-cache` is an experimental utility. It may be replaced with a different solution in the future.", + ); + + async function getAppRootDir() { + let currentDir = process.cwd(); + + while ( + !(await pathExists(path.join(currentDir, ".next"))) && + !(await pathExists(path.join(currentDir, "package.json"))) + ) { + if (currentDir === path.resolve("/")) { + break; + } + + currentDir = path.join(currentDir, ".."); + } + + if ( + (await pathExists(path.join(currentDir, ".next"))) || + (await pathExists(path.join(currentDir, "package.json"))) + ) { + return currentDir; + } + } + + const appRootDir = await getAppRootDir(); + + if (!appRootDir) { + warn( + "Could not find the Next.js app root. Run `prismic-next clear-cache` in a Next.js project with a `.next` directory or `package.json` file.", + ); + + return; + } + + const fetchCacheDir = path.join( + appRootDir, + ".next", + "cache", + "fetch-cache", + ); + + if (!(await pathExists(fetchCacheDir))) { + info("No Next.js fetch cache directory found. You are good to go!"); + + return; + } + + const cacheEntries = await fs.readdir(fetchCacheDir); + + await Promise.all( + cacheEntries.map(async (entry) => { + try { + const contents = await fs.readFile( + path.join(fetchCacheDir, entry), + "utf8", + ); + const payload = JSON.parse(contents); + + if (payload.kind !== "FETCH") { + return; + } + + const bodyPayload = JSON.parse( + Buffer.from(payload.data.body, "base64").toString(), + ); + + // Delete `/api/v2` requests. + if (/\.prismic\.io\/auth$/.test(bodyPayload.oauth_initiate)) { + await fs.unlink(path.join(fetchCacheDir, entry)); + + info(`Prismic /api/v2 request cache cleared: ${entry}`); + } + } catch (e) { + // noop + } + }), + ); + + info( + "The Prismic request cache has been cleared. Uncached requests will begin on the next Next.js server start-up.", + ); + + return; + } + + default: { + if (command && (!args.version || !args.help)) { + warn("Invalid command.\n"); + } + + if (args.version) { + console.info(pkg.version); + + return; + } + + console.info( + ` +Usage: + prismic-next [options...] +Available commands: + clear-cache +Options: + --help, -h Show help text + --version, -v Show version +`.trim(), + ); + } + } +} diff --git a/test/__setup__.ts b/test/__setup__.ts index 8b4d0e4..328cb1e 100644 --- a/test/__setup__.ts +++ b/test/__setup__.ts @@ -1,15 +1,43 @@ -import { beforeEach, vi } from "vitest"; +import { beforeAll, beforeEach, vi } from "vitest"; import { createMockFactory, MockFactory } from "@prismicio/mock"; import { Headers } from "node-fetch"; +import * as fs from "node:fs/promises"; +import * as path from "node:path"; +import * as os from "node:os"; declare module "vitest" { export interface TestContext { mock: MockFactory; + appRoot: string; } } vi.stubGlobal("Headers", Headers); -beforeEach((ctx) => { +beforeAll(async () => { + await fs.mkdir(os.tmpdir(), { recursive: true }); + + vi.spyOn(process, "cwd"); +}); + +beforeEach(async (ctx) => { ctx.mock = createMockFactory({ seed: ctx.meta.name }); + ctx.appRoot = await fs.mkdtemp( + path.join(os.tmpdir(), "@prismicio__next___cli"), + ); + + vi.clearAllMocks(); + + await fs.mkdir(path.join(ctx.appRoot, "foo", "bar"), { recursive: true }); + vi.mocked(process.cwd).mockReturnValue(path.join(ctx.appRoot, "foo", "bar")); + + return async () => { + await fs.rm(ctx.appRoot, { recursive: true }); + }; +}); + +vi.mock("node:fs/promises", async () => { + const memfs: typeof import("memfs") = await vi.importActual("memfs"); + + return memfs.fs.promises; }); diff --git a/test/__testutils__/argv.ts b/test/__testutils__/argv.ts new file mode 100644 index 0000000..c0bdb9b --- /dev/null +++ b/test/__testutils__/argv.ts @@ -0,0 +1,3 @@ +export function argv(...args: string[]): string[] { + return ["/node", "./prismic-next", ...args]; +} diff --git a/test/cli-clear-cache.test.ts b/test/cli-clear-cache.test.ts new file mode 100644 index 0000000..7f49605 --- /dev/null +++ b/test/cli-clear-cache.test.ts @@ -0,0 +1,178 @@ +import { it, expect, vi, beforeAll, TestContext, afterEach } from "vitest"; +import * as fs from "node:fs/promises"; +import * as path from "node:path"; + +import { argv } from "./__testutils__/argv"; + +import { run } from "../src/cli"; + +beforeAll(() => { + vi.spyOn(console, "info").mockImplementation(() => void 0); + vi.spyOn(console, "warn").mockImplementation(() => void 0); +}); + +afterEach(() => { + setUpApp.fileCount = 0; +}); + +type SetupAppArgs = { + withPackageJSON?: boolean; + withCachedFetches?: { + kind: string; + + data: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + body: any; + }; + }[]; +}; + +async function setUpApp(ctx: TestContext, args: SetupAppArgs = {}) { + if (args.withPackageJSON ?? true) { + await fs.writeFile( + path.join(ctx.appRoot, "package.json"), + JSON.stringify({}), + ); + } + + if (args.withCachedFetches) { + await fs.mkdir(path.join(ctx.appRoot, ".next/cache/fetch-cache"), { + recursive: true, + }); + + for (const cachedFetch of args.withCachedFetches) { + await fs.writeFile( + path.join( + ctx.appRoot, + ".next/cache/fetch-cache", + (setUpApp.fileCount++).toString(), + ), + JSON.stringify({ + ...cachedFetch, + data: { + body: Buffer.from(JSON.stringify(cachedFetch.data.body)).toString( + "base64", + ), + }, + }), + ); + } + } +} +setUpApp.fileCount = 0; + +it("warns that the cli is experimental", async () => { + await run(argv("clear-cache")); + + expect(console.warn).toHaveBeenCalledWith( + expect.stringMatching(/experimental/i), + ); +}); + +it("warns if an app root cannot be found", async () => { + await run(argv("clear-cache")); + + expect(console.warn).toHaveBeenCalledWith( + expect.stringMatching(/could not find the next.js app root/i), + ); +}); + +it("exits early if the fetch cache doesn't exist", async (ctx) => { + await setUpApp(ctx); + + await run(argv("clear-cache")); + + expect(console.info).toHaveBeenCalledWith( + expect.stringMatching(/no next.js fetch cache directory found/i), + ); +}); + +it("clears cached Prismic Rest API /api/v2 requests", async (ctx) => { + await setUpApp(ctx, { + withCachedFetches: [ + { + kind: "FETCH", + data: { + body: { + oauth_initiate: "https://example-prismic-repo.prismic.io/auth", + }, + }, + }, + ], + }); + + expect( + await fs.readdir(path.join(ctx.appRoot, ".next/cache/fetch-cache")), + ).includes("0"); + + await run(argv("clear-cache")); + + expect( + await fs.readdir(path.join(ctx.appRoot, ".next/cache/fetch-cache")), + ).not.includes("0"); + + expect(console.info).toHaveBeenCalledWith( + expect.stringMatching(/\/api\/v2 request cache cleared/i), + ); +}); + +it("leaves non-Prismic Rest API V2 requests in the cache", async (ctx) => { + await setUpApp(ctx, { + withCachedFetches: [ + { + kind: "FETCH", + data: { + body: { + foo: "bar", + }, + }, + }, + { + kind: "FETCH", + data: { + body: { + oauth_initiate: "https://example-prismic-repo.prismic.io/auth", + }, + }, + }, + { + kind: "FOO", + data: { + body: { + baz: "qux", + }, + }, + }, + ], + }); + + const cacheDir = path.join(ctx.appRoot, ".next/cache/fetch-cache"); + + const cacheEntriesPre = await fs.readdir(cacheDir); + + expect(cacheEntriesPre).includes("0"); + expect(cacheEntriesPre).includes("1"); + expect(cacheEntriesPre).includes("2"); + + await run(argv("clear-cache")); + + const cacheEntriesPost = await fs.readdir(cacheDir); + + expect(cacheEntriesPost).includes("0"); + expect(cacheEntriesPost).not.includes("1"); + expect(cacheEntriesPost).includes("2"); + + expect(console.info).toHaveBeenCalledWith( + expect.stringMatching(/\/api\/v2 request cache cleared/i), + ); +}); + +it("prints success message after successful clearing", async (ctx) => { + await setUpApp(ctx, { withCachedFetches: [] }); + + await run(argv("clear-cache")); + + expect(console.info).toHaveBeenCalledWith( + expect.stringMatching(/cache has been cleared/i), + ); +}); diff --git a/test/cli.test.ts b/test/cli.test.ts new file mode 100644 index 0000000..5d3afed --- /dev/null +++ b/test/cli.test.ts @@ -0,0 +1,44 @@ +import { it, expect, vi, beforeAll } from "vitest"; + +import { argv } from "./__testutils__/argv"; + +import { run } from "../src/cli"; +import { version } from "../package.json"; + +beforeAll(() => { + vi.spyOn(console, "info").mockImplementation(() => void 0); + vi.spyOn(console, "warn").mockImplementation(() => void 0); +}); + +it("prints help text when no arguments are given", async () => { + await run(argv()); + + expect(console.info).toHaveBeenCalledTimes(1); + expect(console.info).toHaveBeenCalledWith(expect.stringMatching(/^Usage:\n/)); +}); + +it("prints help text when --help is given", async () => { + await run(argv("--help")); + + expect(console.info).toHaveBeenCalledTimes(1); + expect(console.info).toHaveBeenCalledWith(expect.stringMatching(/^Usage:\n/)); +}); + +it("prints the current version when --version is given", async () => { + await run(argv("--version")); + + expect(console.info).toHaveBeenCalledTimes(1); + expect(console.info).toHaveBeenCalledWith(version); +}); + +it("prints warning when an unknown command is given", async () => { + await run(argv("__invalid_command__")); + + expect(console.warn).toHaveBeenCalledTimes(1); + expect(console.warn).toHaveBeenCalledWith( + expect.stringMatching(/invalid command/i), + ); + + expect(console.info).toHaveBeenCalledTimes(1); + expect(console.info).toHaveBeenCalledWith(expect.stringMatching(/^Usage:\n/)); +}); diff --git a/vite.config.ts b/vite.config.ts index bde2796..84f6b55 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -11,6 +11,12 @@ export default defineConfig({ react(), ], build: { + lib: { + entry: { + index: "./src/index.ts", + cli: "./src/cli/index.ts", + }, + }, rollupOptions: { plugins: [preserveDirectives() as Plugin], },