diff --git a/jest.config.js b/jest.config.js index 63deae4af20..fb2e9756f9d 100644 --- a/jest.config.js +++ b/jest.config.js @@ -24,6 +24,7 @@ const config = { '/packages/react-instantsearch-nextjs/__tests__', '/__utils__/', algoliaSearchMajor !== '5' && '/packages/algolia-experiences', + algoliaSearchMajor !== '5' && '/packages/instantsearch-cli', ].filter((x) => x !== false), watchPathIgnorePatterns: [ '/packages/*/cjs', diff --git a/packages/instantsearch-cli/__tests__/__utils__/helpers.ts b/packages/instantsearch-cli/__tests__/__utils__/helpers.ts index 60a38495528..f257e809b15 100644 --- a/packages/instantsearch-cli/__tests__/__utils__/helpers.ts +++ b/packages/instantsearch-cli/__tests__/__utils__/helpers.ts @@ -13,3 +13,26 @@ export async function runCapturing( return { exitCode, stdout: stdout.join(''), stderr: stderr.join('') }; } + +type CapturedIO = { + stdout: string[]; + stderr: string[]; + io: { stdout: (chunk: string) => void; stderr: (chunk: string) => void }; +}; + +export function captureIO(): CapturedIO { + const stdout: string[] = []; + const stderr: string[] = []; + return { + stdout, + stderr, + io: { + stdout: (chunk: string) => stdout.push(chunk), + stderr: (chunk: string) => stderr.push(chunk), + }, + }; +} + +export function readEnvelope(stdout: string[]): Record { + return JSON.parse(stdout.join('')); +} diff --git a/packages/instantsearch-cli/__tests__/global-options.test.ts b/packages/instantsearch-cli/__tests__/global-options.test.ts index 7359137baa5..c0e8898bc4a 100644 --- a/packages/instantsearch-cli/__tests__/global-options.test.ts +++ b/packages/instantsearch-cli/__tests__/global-options.test.ts @@ -9,7 +9,7 @@ describe('global options', () => { describe('--json implies --yes', () => { it('sets yes=true when --json is passed', async () => { const program = createProgram(silentIO); - await program.parseAsync(['init', '--json'], { from: 'user' }); + await program.parseAsync(['add', '--json'], { from: 'user' }); expect(program.opts()).toMatchObject({ json: true, yes: true }); }); @@ -18,14 +18,14 @@ describe('global options', () => { describe('--yes', () => { it('is a standalone global flag (yes=true, json=false)', async () => { const program = createProgram(silentIO); - await program.parseAsync(['init', '--yes'], { from: 'user' }); + await program.parseAsync(['add', '--yes'], { from: 'user' }); expect(program.opts()).toMatchObject({ json: false, yes: true }); }); it('defaults to false when neither --yes nor --json is passed', async () => { const program = createProgram(silentIO); - await program.parseAsync(['init'], { from: 'user' }); + await program.parseAsync(['add'], { from: 'user' }); expect(program.opts()).toMatchObject({ json: false, yes: false }); }); diff --git a/packages/instantsearch-cli/__tests__/human-output.test.ts b/packages/instantsearch-cli/__tests__/human-output.test.ts index 2c8bc96945a..02af05b9e35 100644 --- a/packages/instantsearch-cli/__tests__/human-output.test.ts +++ b/packages/instantsearch-cli/__tests__/human-output.test.ts @@ -1,15 +1,12 @@ import { runCapturing } from './__utils__/helpers'; -describe.each(['init', 'add', 'introspect'])( - '%s (no --json)', - (command) => { - it('emits human-readable output, not a JSON envelope', async () => { - const { exitCode, stdout, stderr } = await runCapturing([command]); +describe('add (no --json)', () => { + it('emits human-readable output, not a JSON envelope', async () => { + const { exitCode, stdout, stderr } = await runCapturing(['add']); - expect(exitCode).toBe(0); - expect(stderr).toBe(''); - expect(stdout).toContain(command); - expect(stdout.trimStart().startsWith('{')).toBe(false); - }); - } -); + expect(exitCode).toBe(0); + expect(stderr).toBe(''); + expect(stdout).toContain('add'); + expect(stdout.trimStart().startsWith('{')).toBe(false); + }); +}); diff --git a/packages/instantsearch-cli/__tests__/init-exit-code.test.ts b/packages/instantsearch-cli/__tests__/init-exit-code.test.ts new file mode 100644 index 00000000000..ced89a88f19 --- /dev/null +++ b/packages/instantsearch-cli/__tests__/init-exit-code.test.ts @@ -0,0 +1,47 @@ +import path from 'path'; + +import { runCapturing } from './__utils__/helpers'; + +const FIXTURES_ROOT = path.join(__dirname, 'fixtures', 'detector'); + +describe('init exit code', () => { + it('returns a non-zero process exit code when runInit fails', async () => { + const originalCwd = process.cwd(); + process.chdir(path.join(FIXTURES_ROOT, 'vanilla')); + try { + const { exitCode } = await runCapturing([ + 'init', + '--json', + '--app-id', + 'X', + '--search-api-key', + 'Y', + ]); + + expect(exitCode).not.toBe(0); + } finally { + process.chdir(originalCwd); + } + }); + + it('surfaces the human-mode failure message on stderr', async () => { + const originalCwd = process.cwd(); + process.chdir(path.join(FIXTURES_ROOT, 'vanilla')); + try { + const { exitCode, stdout, stderr } = await runCapturing([ + 'init', + '--yes', + '--app-id', + 'X', + '--search-api-key', + 'Y', + ]); + + expect(exitCode).not.toBe(0); + expect(stdout).toBe(''); + expect(stderr).toMatch(/react/i); + } finally { + process.chdir(originalCwd); + } + }); +}); diff --git a/packages/instantsearch-cli/__tests__/init.test.ts b/packages/instantsearch-cli/__tests__/init.test.ts new file mode 100644 index 00000000000..91086c6b324 --- /dev/null +++ b/packages/instantsearch-cli/__tests__/init.test.ts @@ -0,0 +1,776 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +import { + runInit, + deriveLibPath, + type InitOptions, + type PromptFn, + type Installer, +} from '../src/init'; +import { readManifest, type Manifest } from '../src/manifest'; + +const FIXTURES_ROOT = path.join(__dirname, 'fixtures', 'detector'); + +type CapturedIO = { + stdout: string[]; + stderr: string[]; + io: { stdout: (chunk: string) => void; stderr: (chunk: string) => void }; +}; + +function captureIO(): CapturedIO { + const stdout: string[] = []; + const stderr: string[] = []; + return { + stdout, + stderr, + io: { + stdout: (chunk: string) => stdout.push(chunk), + stderr: (chunk: string) => stderr.push(chunk), + }, + }; +} + +function copyFixture(name: string): string { + const src = path.join(FIXTURES_ROOT, name); + const dest = fs.mkdtempSync(path.join(os.tmpdir(), `instantsearch-init-${name}-`)); + copyRecursive(src, dest); + return dest; +} + +function copyRecursive(src: string, dest: string): void { + fs.mkdirSync(dest, { recursive: true }); + for (const entry of fs.readdirSync(src, { withFileTypes: true })) { + const srcEntry = path.join(src, entry.name); + const destEntry = path.join(dest, entry.name); + if (entry.isDirectory()) { + copyRecursive(srcEntry, destEntry); + } else { + fs.copyFileSync(srcEntry, destEntry); + } + } +} + +function noopInstaller(): Installer { + return async () => undefined; +} + +function trackingInstaller(): Installer & { calls: Array<{ packages: string[]; cwd: string }> } { + const calls: Array<{ packages: string[]; cwd: string }> = []; + const installer = (async (packages, { cwd }) => { + calls.push({ packages, cwd }); + }) as Installer & { calls: typeof calls }; + installer.calls = calls; + return installer; +} + +function readEnvelope(stdout: string[]): Record { + return JSON.parse(stdout.join('')); +} + +function baseOptions(overrides: Partial & { cwd: string }): InitOptions { + return { + json: true, + yes: true, + appId: 'APP_ID', + searchApiKey: 'SEARCH_KEY', + installer: noopInstaller(), + ...overrides, + }; +} + +describe('init', () => { + let tempDirs: string[]; + + beforeEach(() => { + tempDirs = []; + }); + + afterEach(() => { + for (const dir of tempDirs) { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + function fixture(name: string): string { + const dir = copyFixture(name); + tempDirs.push(dir); + return dir; + } + + it('creates manifest, client, and provider in a React + Vite + TS project', async () => { + const cwd = fixture('react-vite-ts'); + const capture = captureIO(); + + const exitCode = await runInit(baseOptions({ cwd }), capture.io); + + expect(exitCode).toBe(0); + expect(capture.stderr.join('')).toBe(''); + + const envelope = readEnvelope(capture.stdout); + expect(envelope).toMatchObject({ + ok: true, + command: 'init', + filesCreated: expect.any(Array), + }); + + const filesCreated = envelope.filesCreated as string[]; + expect(filesCreated).toEqual([ + path.join(cwd, 'instantsearch.json'), + path.join(cwd, 'src/lib/algolia-client.ts'), + path.join(cwd, 'src/lib/algolia-provider.tsx'), + ]); + + for (const file of filesCreated) { + expect(fs.existsSync(file)).toBe(true); + } + + const manifestResult = readManifest(path.join(cwd, 'instantsearch.json'), { + command: 'init', + }); + expect(manifestResult).toMatchObject({ + ok: true, + manifest: { + flavor: 'react', + typescript: true, + componentsPath: 'src/components', + libPath: 'src/lib', + algolia: { appId: 'APP_ID', searchApiKey: 'SEARCH_KEY' }, + features: [], + }, + }); + expect((manifestResult as { manifest: Manifest }).manifest.framework).toBeUndefined(); + + const clientSource = fs.readFileSync( + path.join(cwd, 'src/lib/algolia-client.ts'), + 'utf8' + ); + expect(clientSource).toContain('searchClient'); + expect(clientSource).toContain('"APP_ID"'); + expect(clientSource).toContain('"SEARCH_KEY"'); + expect(clientSource).toMatch(/cache/i); + + const providerSource = fs.readFileSync( + path.join(cwd, 'src/lib/algolia-provider.tsx'), + 'utf8' + ); + expect(providerSource).toContain("from 'react-instantsearch'"); + expect(providerSource).not.toContain("'use client'"); + expect(providerSource).not.toMatch(/indexName=/); + + const importHint = (envelope as { nextSteps: string[] }).nextSteps.join('\n'); + expect(importHint).toContain('./src/lib/algolia-provider'); + expect(importHint).not.toContain(cwd); + expect(importHint).not.toMatch(/\.(tsx|jsx|ts|js)['"`]/); + }); + + it('renders a Next App Router provider with InstantSearchNext and the "use client" directive', async () => { + const cwd = fixture('next-app'); + const capture = captureIO(); + + const exitCode = await runInit(baseOptions({ cwd }), capture.io); + + expect(exitCode).toBe(0); + + const envelope = readEnvelope(capture.stdout); + const filesCreated = envelope.filesCreated as string[]; + + // next-app fixture has no tsconfig.json, so typescript is false; use .jsx + .js + expect(filesCreated).toEqual([ + path.join(cwd, 'instantsearch.json'), + path.join(cwd, 'src/lib/algolia-client.js'), + path.join(cwd, 'src/lib/algolia-provider.jsx'), + ]); + + const providerSource = fs.readFileSync( + path.join(cwd, 'src/lib/algolia-provider.jsx'), + 'utf8' + ); + expect(providerSource.startsWith("'use client';")).toBe(true); + expect(providerSource).toContain( + "import { InstantSearchNext } from 'react-instantsearch-nextjs';" + ); + expect(providerSource).toContain(', + }); + }); + + it('installs react-instantsearch-nextjs only for Next App Router', async () => { + const reactCwd = fixture('react-vite-ts'); + const nextCwd = fixture('next-app'); + + const reactInstaller = trackingInstaller(); + const nextInstaller = trackingInstaller(); + + await runInit( + baseOptions({ cwd: reactCwd, installer: reactInstaller }), + captureIO().io + ); + await runInit( + baseOptions({ cwd: nextCwd, installer: nextInstaller }), + captureIO().io + ); + + expect(reactInstaller.calls).toEqual([ + expect.objectContaining({ + packages: ['algoliasearch', 'react-instantsearch'], + }), + ]); + expect(nextInstaller.calls).toEqual([ + expect.objectContaining({ + packages: [ + 'algoliasearch', + 'react-instantsearch', + 'react-instantsearch-nextjs', + ], + }), + ]); + }); + + it('respects --components-path and --lib-path overrides', async () => { + const cwd = fixture('react-vite-ts'); + const capture = captureIO(); + + const exitCode = await runInit( + baseOptions({ + cwd, + componentsPath: 'app/widgets', + libPath: 'app/algolia', + }), + capture.io + ); + + expect(exitCode).toBe(0); + + const envelope = readEnvelope(capture.stdout); + expect(envelope.filesCreated).toEqual([ + path.join(cwd, 'instantsearch.json'), + path.join(cwd, 'app/algolia/algolia-client.ts'), + path.join(cwd, 'app/algolia/algolia-provider.tsx'), + ]); + + const manifestResult = readManifest(path.join(cwd, 'instantsearch.json'), { + command: 'init', + }); + expect(manifestResult).toMatchObject({ + ok: true, + manifest: { + componentsPath: 'app/widgets', + libPath: 'app/algolia', + }, + }); + }); + + describe('json/yes contract', () => { + it('re-derives yes from json so JSON-mode never reaches the install prompt', async () => { + const cwd = fixture('react-vite-ts'); + const capture = captureIO(); + const installer = trackingInstaller(); + + const exitCode = await runInit( + { + cwd, + json: true, + yes: false, + appId: 'APP_ID', + searchApiKey: 'SEARCH_KEY', + componentsPath: 'src/components', + libPath: 'src/lib', + installer, + }, + capture.io + ); + + expect(exitCode).toBe(0); + expect(installer.calls.length).toBeGreaterThan(0); + }); + }); + + describe('install prompt', () => { + it('refuses with install_failed when the package manager exits non-zero', async () => { + const cwd = fixture('react-vite-ts'); + const capture = captureIO(); + + const failingInstaller: Installer = async () => { + throw new Error('npm install exited with code 1'); + }; + + const exitCode = await runInit( + baseOptions({ cwd, installer: failingInstaller }), + capture.io + ); + + expect(exitCode).not.toBe(0); + expect(fs.existsSync(path.join(cwd, 'instantsearch.json'))).toBe(false); + expect(readEnvelope(capture.stdout)).toMatchObject({ + ok: false, + command: 'init', + code: 'install_failed', + message: expect.stringContaining('npm install'), + }); + }); + + it('treats a cancelled install prompt as cancelled, not install_declined', async () => { + const cwd = fixture('react-vite-ts'); + const capture = captureIO(); + const installer = trackingInstaller(); + + // prompts() returns {} on Ctrl-C — the install confirm answer key is absent. + const prompt: PromptFn = async (questions) => { + const answers: Record = {}; + for (const question of questions) { + if (question.name === 'installConfirmed') { + // simulate cancellation: do not return this key + continue; + } + if (question.name === 'appId') answers.appId = 'APP_ID'; + else if (question.name === 'searchApiKey') + answers.searchApiKey = 'SEARCH_KEY'; + else if (question.name === 'componentsPath') + answers.componentsPath = 'src/components'; + else if (question.name === 'libPath') answers.libPath = 'src/lib'; + } + return answers; + }; + + const exitCode = await runInit( + { cwd, json: false, yes: false, prompt, installer }, + capture.io + ); + + expect(exitCode).not.toBe(0); + expect(installer.calls).toEqual([]); + const stderr = capture.stderr.join(''); + expect(stderr).not.toContain('Cannot proceed without installing'); + expect(stderr).toMatch(/cancel/i); + }); + + it('refuses with install_declined when the user declines in interactive mode', async () => { + const cwd = fixture('react-vite-ts'); + const capture = captureIO(); + const installer = trackingInstaller(); + + const prompt: PromptFn = async (questions) => { + const answers: Record = {}; + for (const question of questions) { + if (question.name === 'installConfirmed') { + answers.installConfirmed = false; + } else if (question.name === 'appId') { + answers.appId = 'APP_ID'; + } else if (question.name === 'searchApiKey') { + answers.searchApiKey = 'SEARCH_KEY'; + } else if (question.name === 'componentsPath') { + answers.componentsPath = 'src/components'; + } else if (question.name === 'libPath') { + answers.libPath = 'src/lib'; + } + } + return answers; + }; + + const exitCode = await runInit( + { cwd, json: false, yes: false, prompt, installer }, + capture.io + ); + + expect(exitCode).not.toBe(0); + expect(installer.calls).toEqual([]); + expect(fs.existsSync(path.join(cwd, 'instantsearch.json'))).toBe(false); + expect(capture.stderr.join('')).toContain('Cannot proceed without installing'); + }); + }); + + describe('--framework override', () => { + it('resolves ambiguous_framework when --framework next-app is passed', async () => { + const cwd = fixture('next-ambiguous'); + const capture = captureIO(); + + const exitCode = await runInit( + baseOptions({ cwd, framework: 'next-app' }), + capture.io + ); + + expect(exitCode).toBe(0); + expect(capture.stderr.join('')).toBe(''); + + const manifestResult = readManifest(path.join(cwd, 'instantsearch.json'), { + command: 'init', + }); + expect(manifestResult).toMatchObject({ + ok: true, + manifest: { flavor: 'react', framework: 'next-app' } as Partial, + }); + }); + + it('trusts --framework next-app even when next is not in package.json deps', async () => { + const cwd = fixture('react-vite-ts'); + const capture = captureIO(); + + const exitCode = await runInit( + baseOptions({ cwd, framework: 'next-app' }), + capture.io + ); + + expect(exitCode).toBe(0); + const manifestResult = readManifest(path.join(cwd, 'instantsearch.json'), { + command: 'init', + }); + expect(manifestResult).toMatchObject({ + ok: true, + manifest: { flavor: 'react', framework: 'next-app' } as Partial, + }); + }); + + it('still refuses with ambiguous_framework when --framework is omitted', async () => { + const cwd = fixture('next-ambiguous'); + const capture = captureIO(); + + const exitCode = await runInit(baseOptions({ cwd }), capture.io); + + expect(exitCode).not.toBe(0); + expect(readEnvelope(capture.stdout)).toMatchObject({ + ok: false, + code: 'ambiguous_framework', + }); + }); + }); + + describe('deriveLibPath', () => { + it('maps src/components to src/lib', () => { + expect(deriveLibPath('src/components')).toBe('src/lib'); + }); + + it('maps components to lib', () => { + expect(deriveLibPath('components')).toBe('lib'); + }); + }); + + it('derives libPath from componentsPath when only --components-path is passed', async () => { + const cwd = fixture('react-vite-ts'); + const capture = captureIO(); + + await runInit( + baseOptions({ cwd, componentsPath: 'components' }), + capture.io + ); + + const envelope = readEnvelope(capture.stdout); + expect(envelope.filesCreated).toEqual([ + path.join(cwd, 'instantsearch.json'), + path.join(cwd, 'lib/algolia-client.ts'), + path.join(cwd, 'lib/algolia-provider.tsx'), + ]); + + const manifestResult = readManifest(path.join(cwd, 'instantsearch.json'), { + command: 'init', + }); + expect(manifestResult).toMatchObject({ + ok: true, + manifest: { + componentsPath: 'components', + libPath: 'lib', + }, + }); + }); + + it('refuses with manifest_exists when re-run on a project with an existing manifest', async () => { + const cwd = fixture('react-vite-ts'); + const firstCapture = captureIO(); + await runInit(baseOptions({ cwd }), firstCapture.io); + + const secondCapture = captureIO(); + const exitCode = await runInit(baseOptions({ cwd }), secondCapture.io); + + expect(exitCode).not.toBe(0); + expect(secondCapture.stderr.join('')).toBe(''); + expect(readEnvelope(secondCapture.stdout)).toMatchObject({ + ok: false, + command: 'init', + code: 'manifest_exists', + message: expect.stringContaining('instantsearch.json'), + }); + }); + + it('prefers manifest_exists over a detection failure on re-runs', async () => { + const cwd = fixture('vanilla'); // detector would refuse this with unsupported_flavor + fs.writeFileSync( + path.join(cwd, 'instantsearch.json'), + '{"flavor":"react","typescript":true,"componentsPath":"src/components","libPath":"src/lib","aliases":{},"algolia":{"appId":"X","searchApiKey":"Y"},"features":[]}', + 'utf8' + ); + const capture = captureIO(); + + const exitCode = await runInit(baseOptions({ cwd }), capture.io); + + expect(exitCode).not.toBe(0); + expect(readEnvelope(capture.stdout)).toMatchObject({ + ok: false, + command: 'init', + code: 'manifest_exists', + }); + }); + + it('refuses with missing_required_flag when --yes mode lacks --app-id', async () => { + const cwd = fixture('react-vite-ts'); + const capture = captureIO(); + + const exitCode = await runInit( + { + cwd, + json: true, + yes: true, + searchApiKey: 'SEARCH_KEY', + installer: noopInstaller(), + }, + capture.io + ); + + expect(exitCode).not.toBe(0); + expect(readEnvelope(capture.stdout)).toMatchObject({ + ok: false, + command: 'init', + code: 'missing_required_flag', + message: expect.stringContaining('--app-id'), + }); + }); + + it('refuses with missing_required_flag when --yes mode lacks --search-api-key', async () => { + const cwd = fixture('react-vite-ts'); + const capture = captureIO(); + + const exitCode = await runInit( + { + cwd, + json: true, + yes: true, + appId: 'APP_ID', + installer: noopInstaller(), + }, + capture.io + ); + + expect(exitCode).not.toBe(0); + expect(readEnvelope(capture.stdout)).toMatchObject({ + ok: false, + command: 'init', + code: 'missing_required_flag', + message: expect.stringContaining('--search-api-key'), + }); + }); + + it('prompts for credentials and paths in interactive mode', async () => { + const cwd = fixture('react-vite-ts'); + const capture = captureIO(); + + const askedQuestions: string[] = []; + const prompt: PromptFn = async (questions) => { + for (const question of questions) askedQuestions.push(question.name); + const answers: Record = { + appId: 'INTERACTIVE_APP', + searchApiKey: 'INTERACTIVE_KEY', + componentsPath: 'src/widgets', + libPath: 'src/integrations', + }; + for (const question of questions) { + if (question.name === 'installConfirmed') { + answers.installConfirmed = true; + } + } + return answers; + }; + + const exitCode = await runInit( + { + cwd, + json: false, + yes: false, + prompt, + installer: noopInstaller(), + }, + capture.io + ); + + expect(exitCode).toBe(0); + expect(askedQuestions).toEqual([ + 'appId', + 'searchApiKey', + 'componentsPath', + 'libPath', + 'installConfirmed', + ]); + + const manifestResult = readManifest(path.join(cwd, 'instantsearch.json'), { + command: 'init', + }); + expect(manifestResult).toMatchObject({ + ok: true, + manifest: { + componentsPath: 'src/widgets', + libPath: 'src/integrations', + algolia: { + appId: 'INTERACTIVE_APP', + searchApiKey: 'INTERACTIVE_KEY', + }, + }, + }); + }); + + it('refuses to overwrite an existing algolia-client file', async () => { + const cwd = fixture('react-vite-ts'); + fs.mkdirSync(path.join(cwd, 'src', 'lib'), { recursive: true }); + fs.writeFileSync( + path.join(cwd, 'src', 'lib', 'algolia-client.ts'), + '// pre-existing\n', + 'utf8' + ); + const capture = captureIO(); + + const exitCode = await runInit(baseOptions({ cwd }), capture.io); + + expect(exitCode).not.toBe(0); + expect(readEnvelope(capture.stdout)).toMatchObject({ + ok: false, + command: 'init', + code: 'write_failed', + }); + // Original file content preserved. + expect( + fs.readFileSync(path.join(cwd, 'src', 'lib', 'algolia-client.ts'), 'utf8') + ).toBe('// pre-existing\n'); + // Rollback: manifest and provider that this run might have created are gone. + expect(fs.existsSync(path.join(cwd, 'instantsearch.json'))).toBe(false); + expect( + fs.existsSync(path.join(cwd, 'src', 'lib', 'algolia-provider.tsx')) + ).toBe(false); + }); + + it('rolls back the manifest when the filesystem step fails', async () => { + const cwd = fixture('react-vite-ts'); + // Block mkdirSync by placing a regular file where the libDir should go. + fs.mkdirSync(path.join(cwd, 'src'), { recursive: true }); + fs.writeFileSync(path.join(cwd, 'src', 'lib'), 'blocker', 'utf8'); + const capture = captureIO(); + + const exitCode = await runInit(baseOptions({ cwd }), capture.io); + + expect(exitCode).not.toBe(0); + // Manifest got rolled back so the user can retry after fixing the issue. + expect(fs.existsSync(path.join(cwd, 'instantsearch.json'))).toBe(false); + }); + + it('surfaces write_failed when scaffolding fails on a filesystem error', async () => { + const cwd = fixture('react-vite-ts'); + // Block mkdirSync by placing a regular file where the libDir should go. + fs.mkdirSync(path.join(cwd, 'src'), { recursive: true }); + fs.writeFileSync(path.join(cwd, 'src', 'lib'), 'blocker', 'utf8'); + const capture = captureIO(); + + const exitCode = await runInit(baseOptions({ cwd }), capture.io); + + expect(exitCode).not.toBe(0); + expect(readEnvelope(capture.stdout)).toMatchObject({ + ok: false, + command: 'init', + code: 'write_failed', + }); + }); + + it.each([ + ['/tmp/absolute/components', 'absolute'], + ['../escape/components', 'traversal'], + ])( + 'refuses an %s componentsPath (%s) with invalid_components_path', + async (componentsPath) => { + const cwd = fixture('react-vite-ts'); + const capture = captureIO(); + + const exitCode = await runInit( + baseOptions({ cwd, componentsPath }), + capture.io + ); + + expect(exitCode).not.toBe(0); + expect(readEnvelope(capture.stdout)).toMatchObject({ + ok: false, + command: 'init', + code: 'invalid_components_path', + }); + expect(fs.existsSync(path.join(cwd, 'instantsearch.json'))).toBe(false); + } + ); + + it.each([ + ['/tmp/absolute/lib', 'absolute'], + ['../escape/lib', 'traversal'], + ])('refuses an %s libPath (%s) with invalid_lib_path', async (libPath) => { + const cwd = fixture('react-vite-ts'); + const capture = captureIO(); + + const exitCode = await runInit( + baseOptions({ cwd, libPath }), + capture.io + ); + + expect(exitCode).not.toBe(0); + expect(readEnvelope(capture.stdout)).toMatchObject({ + ok: false, + command: 'init', + code: 'invalid_lib_path', + }); + expect(fs.existsSync(path.join(cwd, 'instantsearch.json'))).toBe(false); + }); + + it('treats empty-string prompt submissions as cancelled, not missing_required_flag', async () => { + const cwd = fixture('react-vite-ts'); + const capture = captureIO(); + + // User presses Enter at every prompt without typing. + const prompt: PromptFn = async (questions) => { + const answers: Record = {}; + for (const q of questions) answers[q.name] = ''; + return answers; + }; + + const exitCode = await runInit( + { cwd, json: false, yes: false, prompt, installer: noopInstaller() }, + capture.io + ); + + expect(exitCode).not.toBe(0); + expect(fs.existsSync(path.join(cwd, 'instantsearch.json'))).toBe(false); + expect(capture.stderr.join('')).toMatch(/cancel/i); + expect(capture.stderr.join('')).not.toMatch(/required/i); + }); + + it('treats a cancelled credentials prompt as cancelled, not missing_required_flag', async () => { + const cwd = fixture('react-vite-ts'); + const capture = captureIO(); + + // prompts() returns {} on Ctrl-C + const prompt: PromptFn = async () => ({}); + + const exitCode = await runInit( + { + cwd, + json: false, + yes: false, + prompt, + installer: noopInstaller(), + }, + capture.io + ); + + expect(exitCode).not.toBe(0); + expect(fs.existsSync(path.join(cwd, 'instantsearch.json'))).toBe(false); + const stderr = capture.stderr.join(''); + expect(stderr).not.toMatch(/--app-id/); + expect(stderr).toMatch(/cancel/i); + }); +}); diff --git a/packages/instantsearch-cli/__tests__/introspect.test.ts b/packages/instantsearch-cli/__tests__/introspect.test.ts new file mode 100644 index 00000000000..d509bf16fcc --- /dev/null +++ b/packages/instantsearch-cli/__tests__/introspect.test.ts @@ -0,0 +1,568 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +import nock from 'nock'; + +import { runIntrospect } from '../src/introspect'; +import { serializeManifest, type Manifest } from '../src/manifest'; + +import { captureIO, readEnvelope } from './__utils__/helpers'; + +function manifestFixture(overrides: { + appId?: string; + searchApiKey?: string; +} = {}): Manifest { + return { + flavor: 'react', + framework: 'vite', + typescript: true, + componentsPath: 'src/components', + libPath: 'src/lib', + aliases: {}, + algolia: { + appId: overrides.appId ?? 'MANIFEST_APP', + searchApiKey: overrides.searchApiKey ?? 'MANIFEST_KEY', + }, + features: [], + }; +} + +function writeManifestFile(cwd: string, manifest: Manifest): void { + fs.writeFileSync( + path.join(cwd, 'instantsearch.json'), + serializeManifest(manifest), + 'utf8' + ); +} + +type Captured = { + appId?: string; + apiKey?: string; +}; + +function mockSearch( + indexName: string, + reply: + | { kind: 'ok'; body: unknown } + | { kind: 'status'; status: number; body?: unknown } + | { kind: 'error' }, + captured: Captured = {} +): Captured { + const scope = nock(/algolia(net)?\.(net|com)/) + .post(`/1/indexes/${encodeURIComponent(indexName)}/query`) + .query(true); + if (reply.kind === 'error') { + scope.replyWithError({ code: 'ECONNREFUSED', message: 'connection refused' }); + } else { + scope.reply(function () { + const req = this.req as { headers: Record }; + captured.appId = req.headers['x-algolia-application-id']; + captured.apiKey = req.headers['x-algolia-api-key']; + return reply.kind === 'ok' + ? [200, reply.body] + : [reply.status, reply.body ?? { message: 'error' }]; + }); + } + return captured; +} + +function searchResponseFixture( + overrides: { facets?: Record; hits?: unknown[] } = {} +) { + return { + hits: overrides.hits ?? [ + { + _highlightResult: { + name: { value: 'name', matchLevel: 'none', matchedWords: [] }, + description: { + value: 'desc', + matchLevel: 'none', + matchedWords: [], + }, + }, + }, + ], + facets: overrides.facets ?? { brand: { Apple: 1 }, categories: { Box: 1 } }, + nbHits: 1, + page: 0, + nbPages: 1, + hitsPerPage: 20, + processingTimeMS: 1, + query: '', + params: '', + }; +} + +function makeTempDir(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), 'instantsearch-introspect-')); +} + +beforeAll(() => { + nock.disableNetConnect(); +}); + +afterEach(() => { + nock.cleanAll(); +}); + +afterAll(() => { + nock.enableNetConnect(); +}); + +describe('introspect', () => { + let tempDirs: string[]; + + beforeEach(() => { + tempDirs = []; + }); + + afterEach(() => { + for (const dir of tempDirs) { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + function tempDir(): string { + const dir = makeTempDir(); + tempDirs.push(dir); + return dir; + } + + it('returns facets and searchable attributes with --app-id + --search-api-key', async () => { + const captured = mockSearch('instant_search', { + kind: 'ok', + body: searchResponseFixture(), + }); + const capture = captureIO(); + + const exitCode = await runIntrospect( + { + cwd: tempDir(), + json: true, + index: 'instant_search', + appId: 'FLAG_APP', + searchApiKey: 'FLAG_KEY', + }, + capture.io + ); + + expect(exitCode).toBe(0); + expect(capture.stderr.join('')).toBe(''); + expect(readEnvelope(capture.stdout)).toEqual({ + ok: true, + command: 'introspect', + filesCreated: [], + nextSteps: [], + data: { + facets: ['brand', 'categories'], + searchableAttributes: ['name', 'description'], + }, + }); + expect(captured).toEqual({ appId: 'FLAG_APP', apiKey: 'FLAG_KEY' }); + }); + + it('reads credentials from instantsearch.json when no flags are passed', async () => { + const cwd = tempDir(); + writeManifestFile( + cwd, + manifestFixture({ appId: 'MANIFEST_APP', searchApiKey: 'MANIFEST_KEY' }) + ); + + const captured = mockSearch('instant_search', { + kind: 'ok', + body: searchResponseFixture(), + }); + const capture = captureIO(); + + const exitCode = await runIntrospect( + { cwd, json: true, index: 'instant_search' }, + capture.io + ); + + expect(exitCode).toBe(0); + expect(captured).toEqual({ + appId: 'MANIFEST_APP', + apiKey: 'MANIFEST_KEY', + }); + }); + + it('prefers flags over manifest when both are present', async () => { + const cwd = tempDir(); + writeManifestFile( + cwd, + manifestFixture({ appId: 'MANIFEST_APP', searchApiKey: 'MANIFEST_KEY' }) + ); + + const captured = mockSearch('instant_search', { + kind: 'ok', + body: searchResponseFixture(), + }); + + await runIntrospect( + { + cwd, + json: true, + index: 'instant_search', + appId: 'FLAG_APP', + searchApiKey: 'FLAG_KEY', + }, + captureIO().io + ); + + expect(captured.appId).toBe('FLAG_APP'); + expect(captured.apiKey).toBe('FLAG_KEY'); + }); + + it('refuses with missing_required_flag when no manifest and no credential flags are provided', async () => { + const capture = captureIO(); + const exitCode = await runIntrospect( + { cwd: tempDir(), json: true, index: 'instant_search' }, + capture.io + ); + + expect(exitCode).not.toBe(0); + expect(capture.stderr.join('')).toBe(''); + expect(readEnvelope(capture.stdout)).toMatchObject({ + ok: false, + command: 'introspect', + code: 'missing_required_flag', + message: expect.stringContaining('--app-id'), + }); + }); + + it('refuses with missing_required_flag when only one credential flag is passed even if a manifest exists', async () => { + const cwd = tempDir(); + writeManifestFile(cwd, manifestFixture()); + const capture = captureIO(); + + const exitCode = await runIntrospect( + { + cwd, + json: true, + index: 'instant_search', + appId: 'FLAG_APP', + // searchApiKey deliberately omitted — should not silently fall back to manifest + }, + capture.io + ); + + expect(exitCode).not.toBe(0); + expect(readEnvelope(capture.stdout)).toMatchObject({ + ok: false, + code: 'missing_required_flag', + message: expect.stringContaining('--search-api-key'), + }); + }); + + it('refuses with missing_required_flag when only --app-id is passed (no --search-api-key, no manifest)', async () => { + const capture = captureIO(); + const exitCode = await runIntrospect( + { + cwd: tempDir(), + json: true, + index: 'instant_search', + appId: 'FLAG_APP', + }, + capture.io + ); + + expect(exitCode).not.toBe(0); + expect(readEnvelope(capture.stdout)).toMatchObject({ + ok: false, + code: 'missing_required_flag', + }); + }); + + it('refuses with missing_required_flag when --index is missing', async () => { + const capture = captureIO(); + const exitCode = await runIntrospect( + { + cwd: tempDir(), + json: true, + appId: 'FLAG_APP', + searchApiKey: 'FLAG_KEY', + }, + capture.io + ); + + expect(exitCode).not.toBe(0); + expect(readEnvelope(capture.stdout)).toMatchObject({ + ok: false, + code: 'missing_required_flag', + message: expect.stringContaining('--index'), + }); + }); + + it('surfaces index_not_found when the API returns a 404', async () => { + mockSearch('does_not_exist', { + kind: 'status', + status: 404, + body: { message: 'Index does_not_exist does not exist.' }, + }); + const capture = captureIO(); + + const exitCode = await runIntrospect( + { + cwd: tempDir(), + json: true, + index: 'does_not_exist', + appId: 'FLAG_APP', + searchApiKey: 'FLAG_KEY', + }, + capture.io + ); + + expect(exitCode).not.toBe(0); + expect(readEnvelope(capture.stdout)).toMatchObject({ + ok: false, + command: 'introspect', + code: 'index_not_found', + message: expect.stringContaining('does_not_exist'), + }); + }); + + it.each([401, 403])( + 'surfaces credentials_invalid when Algolia returns %i', + async (status) => { + mockSearch('instant_search', { + kind: 'status', + status, + body: { message: 'Invalid Application-ID or API key' }, + }); + const capture = captureIO(); + + const exitCode = await runIntrospect( + { + cwd: tempDir(), + json: true, + index: 'instant_search', + appId: 'FLAG_APP', + searchApiKey: 'FLAG_KEY', + }, + capture.io + ); + + expect(exitCode).not.toBe(0); + expect(readEnvelope(capture.stdout)).toMatchObject({ + ok: false, + command: 'introspect', + code: 'credentials_invalid', + }); + } + ); + + it('surfaces algolia_error for other non-404 API errors', async () => { + mockSearch('instant_search', { + kind: 'status', + status: 500, + body: { message: 'Internal server error' }, + }); + const capture = captureIO(); + + const exitCode = await runIntrospect( + { + cwd: tempDir(), + json: true, + index: 'instant_search', + appId: 'FLAG_APP', + searchApiKey: 'FLAG_KEY', + }, + capture.io + ); + + expect(exitCode).not.toBe(0); + expect(readEnvelope(capture.stdout)).toMatchObject({ + ok: false, + command: 'introspect', + code: 'algolia_error', + }); + }); + + it('surfaces algolia_error for transport errors', async () => { + mockSearch('instant_search', { kind: 'error' }); + const capture = captureIO(); + + const exitCode = await runIntrospect( + { + cwd: tempDir(), + json: true, + index: 'instant_search', + appId: 'FLAG_APP', + searchApiKey: 'FLAG_KEY', + }, + capture.io + ); + + expect(exitCode).not.toBe(0); + expect(readEnvelope(capture.stdout)).toMatchObject({ + ok: false, + code: 'algolia_error', + }); + }); + + it('passes through an index with no facets and no hits as empty arrays', async () => { + mockSearch('instant_search', { + kind: 'ok', + body: searchResponseFixture({ facets: {}, hits: [] }), + }); + const capture = captureIO(); + + const exitCode = await runIntrospect( + { + cwd: tempDir(), + json: true, + index: 'instant_search', + appId: 'FLAG_APP', + searchApiKey: 'FLAG_KEY', + }, + capture.io + ); + + expect(exitCode).toBe(0); + expect(readEnvelope(capture.stdout)).toEqual({ + ok: true, + command: 'introspect', + filesCreated: [], + nextSteps: [], + data: { + facets: [], + searchableAttributes: [], + }, + }); + }); + + it('treats an empty _highlightResult as zero searchable attributes', async () => { + mockSearch('instant_search', { + kind: 'ok', + body: searchResponseFixture({ + hits: [{ objectID: 'a', _highlightResult: {} }], + }), + }); + const capture = captureIO(); + + const exitCode = await runIntrospect( + { + cwd: tempDir(), + json: true, + index: 'instant_search', + appId: 'FLAG_APP', + searchApiKey: 'FLAG_KEY', + }, + capture.io + ); + + expect(exitCode).toBe(0); + expect(readEnvelope(capture.stdout)).toMatchObject({ + ok: true, + data: { + facets: ['brand', 'categories'], + searchableAttributes: [], + }, + }); + }); + + it('returns top-level attribute names for nested and array _highlightResult entries', async () => { + mockSearch('instant_search', { + kind: 'ok', + body: searchResponseFixture({ + hits: [ + { + objectID: 'a', + _highlightResult: { + name: { value: 'n', matchLevel: 'none', matchedWords: [] }, + hierarchical_categories: { + lvl0: { value: 'l0', matchLevel: 'none', matchedWords: [] }, + lvl1: { value: 'l1', matchLevel: 'none', matchedWords: [] }, + }, + tags: [ + { value: 't1', matchLevel: 'none', matchedWords: [] }, + { value: 't2', matchLevel: 'none', matchedWords: [] }, + ], + }, + }, + ], + }), + }); + const capture = captureIO(); + + const exitCode = await runIntrospect( + { + cwd: tempDir(), + json: true, + index: 'instant_search', + appId: 'FLAG_APP', + searchApiKey: 'FLAG_KEY', + }, + capture.io + ); + + expect(exitCode).toBe(0); + expect(readEnvelope(capture.stdout)).toMatchObject({ + ok: true, + data: { + searchableAttributes: ['name', 'hierarchical_categories', 'tags'], + }, + }); + }); + + it('refuses with invalid_manifest when the manifest is malformed JSON', async () => { + const cwd = tempDir(); + fs.writeFileSync( + path.join(cwd, 'instantsearch.json'), + '{ not valid json', + 'utf8' + ); + + const capture = captureIO(); + const exitCode = await runIntrospect( + { cwd, json: true, index: 'instant_search' }, + capture.io + ); + + expect(exitCode).not.toBe(0); + expect(readEnvelope(capture.stdout)).toMatchObject({ + ok: false, + code: 'invalid_manifest', + message: expect.stringContaining('instantsearch.json'), + }); + }); + + it('emits human-readable output when --json is not set', async () => { + mockSearch('instant_search', { + kind: 'ok', + body: searchResponseFixture(), + }); + const capture = captureIO(); + + const exitCode = await runIntrospect( + { + cwd: tempDir(), + json: false, + index: 'instant_search', + appId: 'FLAG_APP', + searchApiKey: 'FLAG_KEY', + }, + capture.io + ); + + expect(exitCode).toBe(0); + const stdout = capture.stdout.join(''); + expect(stdout).toContain('instant_search'); + expect(stdout).toContain('brand'); + expect(stdout).toContain('name'); + expect(stdout.trimStart().startsWith('{')).toBe(false); + }); + + it('emits human-readable error on stderr when --json is not set', async () => { + const capture = captureIO(); + const exitCode = await runIntrospect( + { cwd: tempDir(), json: false, index: 'instant_search' }, + capture.io + ); + + expect(exitCode).not.toBe(0); + expect(capture.stdout.join('')).toBe(''); + expect(capture.stderr.join('')).toContain('credentials'); + }); +}); diff --git a/packages/instantsearch-cli/__tests__/invalid-flag.test.ts b/packages/instantsearch-cli/__tests__/invalid-flag.test.ts new file mode 100644 index 00000000000..f4479f587b8 --- /dev/null +++ b/packages/instantsearch-cli/__tests__/invalid-flag.test.ts @@ -0,0 +1,20 @@ +import { runCapturing } from './__utils__/helpers'; + +describe('invalid flag value', () => { + it('emits an invalid_flag envelope when --framework gets an unsupported value', async () => { + const { exitCode, stdout, stderr } = await runCapturing([ + 'init', + '--framework', + 'bogus', + '--json', + ]); + + expect(exitCode).not.toBe(0); + expect(stderr).toBe(''); + expect(JSON.parse(stdout)).toMatchObject({ + ok: false, + code: 'invalid_flag', + message: expect.stringContaining('bogus'), + }); + }); +}); diff --git a/packages/instantsearch-cli/__tests__/json-envelope.test.ts b/packages/instantsearch-cli/__tests__/json-envelope.test.ts index e6f576bfcb5..84fdb1182c2 100644 --- a/packages/instantsearch-cli/__tests__/json-envelope.test.ts +++ b/packages/instantsearch-cli/__tests__/json-envelope.test.ts @@ -1,22 +1,16 @@ import { runCapturing } from './__utils__/helpers'; -describe.each(['init', 'add', 'introspect'])( - '%s --json', - (command) => { - it('emits a well-formed success envelope on stdout', async () => { - const { exitCode, stdout, stderr } = await runCapturing([ - command, - '--json', - ]); +describe('add --json', () => { + it('emits a well-formed success envelope on stdout', async () => { + const { exitCode, stdout, stderr } = await runCapturing(['add', '--json']); - expect(exitCode).toBe(0); - expect(stderr).toBe(''); - expect(JSON.parse(stdout)).toEqual({ - ok: true, - command, - filesCreated: expect.any(Array), - nextSteps: expect.any(Array), - }); + expect(exitCode).toBe(0); + expect(stderr).toBe(''); + expect(JSON.parse(stdout)).toEqual({ + ok: true, + command: 'add', + filesCreated: expect.any(Array), + nextSteps: expect.any(Array), }); - } -); + }); +}); diff --git a/packages/instantsearch-cli/package.json b/packages/instantsearch-cli/package.json index 9017f50cbb4..9d30113c82c 100644 --- a/packages/instantsearch-cli/package.json +++ b/packages/instantsearch-cli/package.json @@ -13,17 +13,21 @@ "instantsearch": "src/cli.ts" }, "scripts": { + "build": "tsc", "test": "jest", "version": "./scripts/version.cjs" }, "dependencies": { + "algoliasearch": "5.1.1", "commander": "11.1.0", - "jsonc-parser": "3.3.1" + "jsonc-parser": "3.3.1", + "prompts": "2.4.2" }, "devDependencies": { "@types/jest": "27.4.0", "@types/node": "18.11.13", "@types/semver": "7.5.8", + "nock": "13.5.6", "semver": "7.7.3" } } diff --git a/packages/instantsearch-cli/src/detector.ts b/packages/instantsearch-cli/src/detector.ts index 6d85badd61a..97ae336853d 100644 --- a/packages/instantsearch-cli/src/detector.ts +++ b/packages/instantsearch-cli/src/detector.ts @@ -47,9 +47,9 @@ type TsConfig = { export function detect( projectRoot: string, - options: { command: string } + options: { command: string; frameworkOverride?: Framework } ): DetectionResult { - const { command } = options; + const { command, frameworkOverride } = options; const pkg = readJsonFile(path.join(projectRoot, 'package.json')); if (!pkg) { @@ -74,7 +74,12 @@ export function detect( ); } - const frameworkResult = detectFramework(projectRoot, deps, command); + const frameworkResult = detectFramework( + projectRoot, + deps, + command, + frameworkOverride + ); if (!frameworkResult.ok) { return frameworkResult; } @@ -104,8 +109,13 @@ type FrameworkOk = { ok: true; framework?: Framework }; function detectFramework( projectRoot: string, deps: Record, - command: string + command: string, + override?: Framework ): FrameworkOk | DetectionFailure { + if (override === 'next-app') { + return { ok: true, framework: 'next-app' }; + } + if ('next' in deps) { const hasAppDir = isDirectory(path.join(projectRoot, 'app')) || diff --git a/packages/instantsearch-cli/src/envelope.ts b/packages/instantsearch-cli/src/envelope.ts index aba10a0bbb2..c16ae13416e 100644 --- a/packages/instantsearch-cli/src/envelope.ts +++ b/packages/instantsearch-cli/src/envelope.ts @@ -3,6 +3,7 @@ type SuccessEnvelope = { command: string; filesCreated: string[]; nextSteps: string[]; + data?: Record; }; type FailureEnvelope = { @@ -16,13 +17,18 @@ type Envelope = SuccessEnvelope | FailureEnvelope; export function successEnvelope( command: string, - details: { filesCreated?: string[]; nextSteps?: string[] } = {} + details: { + filesCreated?: string[]; + nextSteps?: string[]; + data?: Record; + } = {} ): SuccessEnvelope { return { ok: true, command, filesCreated: details.filesCreated ?? [], nextSteps: details.nextSteps ?? [], + ...(details.data !== undefined && { data: details.data }), }; } diff --git a/packages/instantsearch-cli/src/handled-failure.ts b/packages/instantsearch-cli/src/handled-failure.ts new file mode 100644 index 00000000000..2ce0b06c780 --- /dev/null +++ b/packages/instantsearch-cli/src/handled-failure.ts @@ -0,0 +1,5 @@ +export class HandledFailure extends Error { + constructor(public readonly exitCode: number) { + super(`command failed with exit code ${exitCode}`); + } +} diff --git a/packages/instantsearch-cli/src/init.ts b/packages/instantsearch-cli/src/init.ts new file mode 100644 index 00000000000..700ee08fba4 --- /dev/null +++ b/packages/instantsearch-cli/src/init.ts @@ -0,0 +1,534 @@ +import fs from 'fs'; +import path from 'path'; + +import { detect } from './detector'; +import { + failureEnvelope, + formatEnvelope, + successEnvelope, +} from './envelope'; +import { writeManifest, type Manifest } from './manifest'; + +import type { IO } from './io'; + +type PackageManager = 'yarn' | 'npm' | 'pnpm' | 'bun'; + +type PromptQuestion = { + type: 'text' | 'password' | 'confirm'; + name: string; + message: string; + initial?: string | boolean; +}; + +type PromptAnswers = { + appId?: string; + searchApiKey?: string; + componentsPath?: string; + libPath?: string; + installConfirmed?: boolean; +}; + +export type PromptFn = (questions: PromptQuestion[]) => Promise; + +export type Installer = ( + packages: string[], + context: { cwd: string; manager: PackageManager } +) => Promise; + +export type InitOptions = { + cwd: string; + json: boolean; + yes: boolean; + componentsPath?: string; + libPath?: string; + appId?: string; + searchApiKey?: string; + framework?: 'next-app'; + prompt?: PromptFn; + installer?: Installer; +}; + +const COMMAND = 'init'; +const MANIFEST_FILENAME = 'instantsearch.json'; + +export async function runInit( + rawOptions: InitOptions, + io: IO +): Promise { + const options: InitOptions = { + ...rawOptions, + yes: rawOptions.yes || rawOptions.json, + }; + + const manifestPath = path.join(options.cwd, MANIFEST_FILENAME); + if (fs.existsSync(manifestPath)) { + emitFailure( + io, + options.json, + failureEnvelope( + COMMAND, + 'manifest_exists', + `A manifest already exists at ${manifestPath}. Edit it directly to update settings.` + ) + ); + return 1; + } + + const detection = detect(options.cwd, { + command: COMMAND, + frameworkOverride: options.framework, + }); + if (!detection.ok) { + emitFailure(io, options.json, detection); + return 1; + } + + const credentialsResult = await resolveInputs(options, io); + if (!credentialsResult.ok) return 1; + + const { + appId, + searchApiKey, + componentsPath, + libPath, + } = credentialsResult.value; + + if (!isSafeRelativePath(componentsPath)) { + emitFailure( + io, + options.json, + failureEnvelope( + COMMAND, + 'invalid_components_path', + `--components-path must be a relative path inside the project (got "${componentsPath}").` + ) + ); + return 1; + } + if (!isSafeRelativePath(libPath)) { + emitFailure( + io, + options.json, + failureEnvelope( + COMMAND, + 'invalid_lib_path', + `--lib-path must be a relative path inside the project (got "${libPath}").` + ) + ); + return 1; + } + + const packages = ['algoliasearch', 'react-instantsearch']; + if (detection.framework === 'next-app') { + packages.push('react-instantsearch-nextjs'); + } + + const missing = findMissingPackages(options.cwd, packages); + if (missing.length > 0) { + if (!options.yes) { + const promptFn = options.prompt ?? defaultPrompt; + const answers = await promptFn([ + { + type: 'confirm', + name: 'installConfirmed', + message: `Install missing packages: ${missing.join(', ')}?`, + initial: true, + }, + ]); + if (!('installConfirmed' in answers)) { + emitFailure( + io, + options.json, + failureEnvelope(COMMAND, 'cancelled', 'Cancelled by user.') + ); + return 1; + } + if (!answers.installConfirmed) { + emitFailure( + io, + options.json, + failureEnvelope( + COMMAND, + 'install_declined', + `Cannot proceed without installing: ${missing.join(', ')}.` + ) + ); + return 1; + } + } + + const installer = options.installer ?? defaultInstaller; + const manager = detectPackageManager(options.cwd); + try { + await installer(missing, { cwd: options.cwd, manager }); + } catch (error) { + emitFailure( + io, + options.json, + failureEnvelope( + COMMAND, + 'install_failed', + error instanceof Error ? error.message : String(error) + ) + ); + return 1; + } + } + + const manifest: Manifest = { + flavor: detection.flavor, + framework: detection.framework, + typescript: detection.typescript, + componentsPath, + libPath, + aliases: detection.aliases, + algolia: { appId, searchApiKey }, + features: [], + }; + + const writeResult = writeManifest(manifestPath, manifest, { + command: COMMAND, + }); + if (!writeResult.ok) { + emitFailure(io, options.json, writeResult); + return 1; + } + + const libDir = path.join(options.cwd, libPath); + const clientPath = path.join( + libDir, + `algolia-client.${detection.typescript ? 'ts' : 'js'}` + ); + const providerPath = path.join( + libDir, + `algolia-provider.${detection.typescript ? 'tsx' : 'jsx'}` + ); + + const createdByThisRun: string[] = [manifestPath]; + try { + fs.mkdirSync(libDir, { recursive: true }); + fs.writeFileSync(clientPath, renderClient(appId, searchApiKey), { + encoding: 'utf8', + flag: 'wx', + }); + createdByThisRun.push(clientPath); + fs.writeFileSync( + providerPath, + renderProvider({ + framework: detection.framework, + typescript: detection.typescript, + }), + { encoding: 'utf8', flag: 'wx' } + ); + createdByThisRun.push(providerPath); + } catch (error) { + rollback(createdByThisRun); + emitFailure( + io, + options.json, + failureEnvelope( + COMMAND, + 'write_failed', + `Could not write scaffolded files under ${libDir}: ${ + error instanceof Error ? error.message : String(error) + }` + ) + ); + return 1; + } + + const filesCreated = [manifestPath, clientPath, providerPath]; + + const providerImport = `./${path.posix.join( + libPath.replace(/\\/g, '/'), + 'algolia-provider' + )}`; + + emitSuccess(io, options.json, filesCreated, providerImport); + return 0; +} + +type ResolvedInputs = { + appId: string; + searchApiKey: string; + componentsPath: string; + libPath: string; +}; + +async function resolveInputs( + options: InitOptions, + io: IO +): Promise<{ ok: true; value: ResolvedInputs } | { ok: false }> { + if (options.yes) { + if (!options.appId) { + emitFailure( + io, + options.json, + failureEnvelope( + COMMAND, + 'missing_required_flag', + '--app-id is required in non-interactive mode (--yes or --json).' + ) + ); + return { ok: false }; + } + if (!options.searchApiKey) { + emitFailure( + io, + options.json, + failureEnvelope( + COMMAND, + 'missing_required_flag', + '--search-api-key is required in non-interactive mode (--yes or --json).' + ) + ); + return { ok: false }; + } + + const componentsPath = options.componentsPath ?? 'src/components'; + const libPath = options.libPath ?? deriveLibPath(componentsPath); + + return { + ok: true, + value: { + appId: options.appId, + searchApiKey: options.searchApiKey, + componentsPath, + libPath, + }, + }; + } + + const promptFn = options.prompt ?? defaultPrompt; + const componentsDefault = options.componentsPath ?? 'src/components'; + const libDefault = + options.libPath ?? deriveLibPath(componentsDefault); + + const questions: PromptQuestion[] = []; + if (!options.appId) { + questions.push({ + type: 'text', + name: 'appId', + message: 'Algolia Application ID', + }); + } + if (!options.searchApiKey) { + questions.push({ + type: 'password', + name: 'searchApiKey', + message: 'Algolia Search API Key', + }); + } + if (!options.componentsPath) { + questions.push({ + type: 'text', + name: 'componentsPath', + message: 'Components path', + initial: componentsDefault, + }); + } + if (!options.libPath) { + questions.push({ + type: 'text', + name: 'libPath', + message: 'Lib path', + initial: libDefault, + }); + } + + const answers = questions.length > 0 ? await promptFn(questions) : {}; + + if ( + questions.length > 0 && + questions.some((q) => { + const value = (answers as Record)[q.name]; + // prompts returns {} on Ctrl-C (key absent) and '' on empty submit — both mean the user gave up. + return value === undefined || value === null || value === ''; + }) + ) { + emitFailure( + io, + options.json, + failureEnvelope(COMMAND, 'cancelled', 'Cancelled by user.') + ); + return { ok: false }; + } + + const appId = options.appId ?? answers.appId; + const searchApiKey = options.searchApiKey ?? answers.searchApiKey; + if (!appId || !searchApiKey) { + emitFailure( + io, + options.json, + failureEnvelope( + COMMAND, + 'missing_required_flag', + 'Algolia Application ID and Search API Key are required.' + ) + ); + return { ok: false }; + } + + const componentsPath = + options.componentsPath ?? answers.componentsPath ?? componentsDefault; + const libPath = + options.libPath ?? answers.libPath ?? deriveLibPath(componentsPath); + + return { + ok: true, + value: { appId, searchApiKey, componentsPath, libPath }, + }; +} + +export function deriveLibPath(componentsPath: string): string { + const normalized = componentsPath.replace(/\\/g, '/').replace(/\/+$/, ''); + return normalized === 'src/components' ? 'src/lib' : 'lib'; +} + +function findMissingPackages(cwd: string, packages: string[]): string[] { + let pkg: { dependencies?: Record; devDependencies?: Record }; + try { + pkg = JSON.parse(fs.readFileSync(path.join(cwd, 'package.json'), 'utf8')); + } catch { + return packages; + } + const installed = new Set([ + ...Object.keys(pkg.dependencies ?? {}), + ...Object.keys(pkg.devDependencies ?? {}), + ]); + return packages.filter((name) => !installed.has(name)); +} + +function detectPackageManager(cwd: string): PackageManager { + if (fs.existsSync(path.join(cwd, 'pnpm-lock.yaml'))) return 'pnpm'; + if (fs.existsSync(path.join(cwd, 'bun.lockb'))) return 'bun'; + if (fs.existsSync(path.join(cwd, 'yarn.lock'))) return 'yarn'; + return 'npm'; +} + +const defaultInstaller: Installer = async (packages, { cwd, manager }) => { + const { spawn } = await import('child_process'); + const args = + manager === 'npm' + ? ['install', ...packages] + : ['add', ...packages]; + + await new Promise((resolve, reject) => { + // npm/yarn/pnpm install on Windows as .cmd shims; spawn auto-resolves .exe only. + // bun ships as bun.exe, so it doesn't need the suffix. + const command = + process.platform === 'win32' && manager !== 'bun' + ? `${manager}.cmd` + : manager; + // Pipe child stdout to our stderr so install logs don't contaminate the JSON envelope on stdout. + const child = spawn(command, args, { + cwd, + stdio: ['ignore', process.stderr, process.stderr], + }); + child.on('error', reject); + child.on('exit', (code) => { + if (code === 0) resolve(); + else reject(new Error(`${manager} ${args.join(' ')} exited with code ${code}.`)); + }); + }); +}; + +const defaultPrompt: PromptFn = async (questions) => { + const { default: prompts } = await import('prompts'); + return prompts(questions) as Promise; +}; + +function isSafeRelativePath(input: string): boolean { + if (path.isAbsolute(input)) return false; + const segments = input.split(/[/\\]/); + return !segments.includes('..'); +} + +function rollback(paths: string[]): void { + // Best-effort: delete in reverse order, swallow any IO error so the original failure stays the headline. + for (let i = paths.length - 1; i >= 0; i -= 1) { + try { + fs.unlinkSync(paths[i]); + } catch { + // ignore + } + } +} + +function emitFailure( + io: IO, + json: boolean, + envelope: ReturnType +): void { + if (json) { + io.stdout(formatEnvelope(envelope)); + } else { + io.stderr(`${envelope.message}\n`); + } +} + +function emitSuccess( + io: IO, + json: boolean, + filesCreated: string[], + providerImport: string +): void { + const envelope = successEnvelope(COMMAND, { + filesCreated, + nextSteps: [ + `Import { AlgoliaProvider } from '${providerImport}' to wrap your app's search UI.`, + `Add an wrapper around each feature that targets a specific Algolia index.`, + ], + }); + if (json) { + io.stdout(formatEnvelope(envelope)); + } else { + io.stdout(`init: created ${filesCreated.length} files\n`); + for (const file of filesCreated) { + io.stdout(` - ${file}\n`); + } + } +} + +function renderClient(appId: string, searchApiKey: string): string { + return `import { liteClient as algoliasearch } from 'algoliasearch/lite'; + +// Module-scoped so the reference stays stable across renders and InstantSearch's request cache survives. +export const searchClient = algoliasearch( + ${JSON.stringify(appId)}, + ${JSON.stringify(searchApiKey)} +); +`; +} + +function renderProvider(detection: { + framework?: string; + typescript: boolean; +}): string { + const isNext = detection.framework === 'next-app'; + const useClient = isNext ? "'use client';\n\n" : ''; + const importLine = isNext + ? "import { InstantSearchNext } from 'react-instantsearch-nextjs';" + : "import { InstantSearch } from 'react-instantsearch';"; + const componentName = isNext ? 'InstantSearchNext' : 'InstantSearch'; + + const reactImport = detection.typescript + ? "import type { ReactNode } from 'react';\n" + : ''; + const typedChildren = detection.typescript + ? ': { children: ReactNode }' + : ''; + + return `${useClient}${reactImport}${importLine} + +import { searchClient } from './algolia-client'; + +export function AlgoliaProvider({ children }${typedChildren}) { + return ( + <${componentName} searchClient={searchClient}> + {children} + + ); +} +`; +} diff --git a/packages/instantsearch-cli/src/introspect.ts b/packages/instantsearch-cli/src/introspect.ts new file mode 100644 index 00000000000..40eb7438d2e --- /dev/null +++ b/packages/instantsearch-cli/src/introspect.ts @@ -0,0 +1,204 @@ +import path from 'path'; + +import { + failureEnvelope, + formatEnvelope, + successEnvelope, +} from './envelope'; +import { readManifest } from './manifest'; + +import type { IO } from './io'; + +const COMMAND = 'introspect'; +const MANIFEST_FILENAME = 'instantsearch.json'; + +type IntrospectOptions = { + cwd: string; + json: boolean; + index?: string; + appId?: string; + searchApiKey?: string; +}; + +type IntrospectData = { + facets: string[]; + searchableAttributes: string[]; +}; + +type SearchHit = { _highlightResult?: Record }; +type SearchResponse = { + facets?: Record; + hits?: SearchHit[]; +}; + +export async function runIntrospect( + options: IntrospectOptions, + io: IO +): Promise { + if (!options.index) { + emitFailure( + io, + options.json, + failureEnvelope(COMMAND, 'missing_required_flag', '--index is required.') + ); + return 1; + } + + const credentials = resolveCredentials(options); + if (!credentials.ok) { + emitFailure(io, options.json, credentials.envelope); + return 1; + } + + try { + const { algoliasearch } = await import('algoliasearch'); + const client = algoliasearch(credentials.appId, credentials.searchApiKey); + const response = (await client.searchSingleIndex({ + indexName: options.index, + searchParams: { + query: '', + facets: ['*'], + attributesToHighlight: ['*'], + hitsPerPage: 5, + }, + })) as SearchResponse; + + emitSuccess(io, options.json, options.index, toIntrospectData(response)); + return 0; + } catch (error) { + const code = classifyAlgoliaError(error); + const message = + code === 'index_not_found' + ? `Index "${options.index}" was not found.` + : code === 'credentials_invalid' + ? 'Algolia rejected the credentials. Check --app-id and --search-api-key.' + : describeError(error); + emitFailure(io, options.json, failureEnvelope(COMMAND, code, message)); + return 1; + } +} + +function toIntrospectData(response: SearchResponse): IntrospectData { + const searchable = new Set(); + for (const hit of response.hits ?? []) { + const highlights = hit._highlightResult; + if (highlights && typeof highlights === 'object') { + for (const key of Object.keys(highlights)) searchable.add(key); + } + } + return { + facets: Object.keys(response.facets ?? {}), + searchableAttributes: Array.from(searchable), + }; +} + +function classifyAlgoliaError( + error: unknown +): 'index_not_found' | 'credentials_invalid' | 'algolia_error' { + const status = getStatus(error); + if (status === 404) return 'index_not_found'; + if (status === 401 || status === 403) return 'credentials_invalid'; + return 'algolia_error'; +} + +function getStatus(error: unknown): number | undefined { + if (typeof error !== 'object' || error === null) return undefined; + const status = (error as { status?: unknown }).status; + return typeof status === 'number' ? status : undefined; +} + +type ResolvedCredentials = + | { ok: true; appId: string; searchApiKey: string } + | { ok: false; envelope: ReturnType }; + +function resolveCredentials(options: IntrospectOptions): ResolvedCredentials { + // If either flag is set, require both. Don't silently fall back to the manifest + // when the user has expressed partial intent. + if (options.appId || options.searchApiKey) { + if (!options.appId) { + return { + ok: false, + envelope: failureEnvelope( + COMMAND, + 'missing_required_flag', + '--app-id is required when --search-api-key is passed.' + ), + }; + } + if (!options.searchApiKey) { + return { + ok: false, + envelope: failureEnvelope( + COMMAND, + 'missing_required_flag', + '--search-api-key is required when --app-id is passed.' + ), + }; + } + return { + ok: true, + appId: options.appId, + searchApiKey: options.searchApiKey, + }; + } + + const manifestPath = path.join(options.cwd, MANIFEST_FILENAME); + const result = readManifest(manifestPath, { command: COMMAND }); + if (!result.ok) { + if (result.code === 'not_found') { + return { + ok: false, + envelope: failureEnvelope( + COMMAND, + 'missing_required_flag', + 'Algolia credentials are required. Pass --app-id and --search-api-key, or run from a project with an instantsearch.json manifest.' + ), + }; + } + return { ok: false, envelope: result }; + } + + return { + ok: true, + appId: result.manifest.algolia.appId, + searchApiKey: result.manifest.algolia.searchApiKey, + }; +} + +function describeError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function emitFailure( + io: IO, + json: boolean, + envelope: ReturnType +): void { + if (json) { + io.stdout(formatEnvelope(envelope)); + } else { + io.stderr(`${envelope.message}\n`); + } +} + +function emitSuccess( + io: IO, + json: boolean, + index: string, + data: IntrospectData +): void { + const envelope = successEnvelope(COMMAND, { data }); + if (json) { + io.stdout(formatEnvelope(envelope)); + return; + } + io.stdout(`introspect: index ${index}\n`); + io.stdout( + ` searchable attributes (${data.searchableAttributes.length}): ${ + data.searchableAttributes.join(', ') || '(none)' + }\n` + ); + io.stdout( + ` facets (${data.facets.length}): ${data.facets.join(', ') || '(none)'}\n` + ); +} diff --git a/packages/instantsearch-cli/src/program.ts b/packages/instantsearch-cli/src/program.ts index 8cf88095528..251710aeb8a 100644 --- a/packages/instantsearch-cli/src/program.ts +++ b/packages/instantsearch-cli/src/program.ts @@ -1,6 +1,9 @@ import { Command, Option } from 'commander'; import { formatEnvelope, successEnvelope } from './envelope'; +import { HandledFailure } from './handled-failure'; +import { runInit } from './init'; +import { runIntrospect } from './introspect'; import { defaultIO, type IO } from './io'; import version from './version'; @@ -12,16 +15,6 @@ type StubDescriptor = { }; const STUBS = [ - { - name: 'init', - description: - 'Scaffold a new InstantSearch project in the current directory.', - humanSummary: - 'init: stub command — no files were created in this slice.', - nextSteps: [ - "Run 'instantsearch init --help' once project scaffolding lands.", - ], - }, { name: 'add', description: @@ -32,20 +25,28 @@ const STUBS = [ "Run 'instantsearch add --help' once the add command is implemented.", ], }, - { - name: 'introspect', - description: - 'Inspect an Algolia index and report its searchable structure.', - humanSummary: - 'introspect: stub command — no Algolia calls are made in this slice.', - nextSteps: [ - "Run 'instantsearch introspect --help' once introspection lands.", - ], - }, ] as const satisfies readonly StubDescriptor[]; export const PROGRAM_NAME = 'instantsearch'; -export const KNOWN_COMMANDS: ReadonlyArray = STUBS.map((s) => s.name); +export const KNOWN_COMMANDS: ReadonlyArray = [ + 'init', + 'introspect', + ...STUBS.map((s) => s.name), +]; + +type InitFlagOptions = { + componentsPath?: string; + libPath?: string; + appId?: string; + searchApiKey?: string; + framework?: 'next-app'; +}; + +type IntrospectFlagOptions = { + index?: string; + appId?: string; + searchApiKey?: string; +}; export function createProgram(io: IO = defaultIO()): Command { const program = new Command(); @@ -72,6 +73,65 @@ export function createProgram(io: IO = defaultIO()): Command { writeErr: (str) => io.stderr(str), }); + program + .command('init') + .description( + 'Scaffold a new InstantSearch project in the current directory.' + ) + .option('--components-path ', 'directory for generated components') + .option('--lib-path ', 'directory for generated library files') + .option('--app-id ', 'Algolia Application ID') + .option('--search-api-key ', 'Algolia Search-Only API Key') + .addOption( + new Option( + '--framework ', + 'override the auto-detected host framework' + ).choices(['next-app']) + ) + .action(async (flags: InitFlagOptions, cmd: Command) => { + const { json, yes } = cmd.optsWithGlobals<{ + json: boolean; + yes: boolean; + }>(); + const exitCode = await runInit( + { + cwd: process.cwd(), + json: Boolean(json), + yes: Boolean(yes), + componentsPath: flags.componentsPath, + libPath: flags.libPath, + appId: flags.appId, + searchApiKey: flags.searchApiKey, + framework: flags.framework, + }, + io + ); + if (exitCode !== 0) throw new HandledFailure(exitCode); + }); + + program + .command('introspect') + .description( + 'Inspect an Algolia index and report its searchable structure.' + ) + .option('--index ', 'Algolia index name to inspect') + .option('--app-id ', 'Algolia Application ID') + .option('--search-api-key ', 'Algolia Search-Only API Key') + .action(async (flags: IntrospectFlagOptions, cmd: Command) => { + const { json } = cmd.optsWithGlobals<{ json: boolean }>(); + const exitCode = await runIntrospect( + { + cwd: process.cwd(), + json: Boolean(json), + index: flags.index, + appId: flags.appId, + searchApiKey: flags.searchApiKey, + }, + io + ); + if (exitCode !== 0) throw new HandledFailure(exitCode); + }); + for (const stub of STUBS) { program .command(stub.name) diff --git a/packages/instantsearch-cli/src/run.ts b/packages/instantsearch-cli/src/run.ts index 8d1559a9b6e..af9f92ad94f 100644 --- a/packages/instantsearch-cli/src/run.ts +++ b/packages/instantsearch-cli/src/run.ts @@ -1,6 +1,7 @@ import { CommanderError } from 'commander'; import { failureEnvelope, formatEnvelope } from './envelope'; +import { HandledFailure } from './handled-failure'; import { defaultIO, type IO } from './io'; import { createProgram, KNOWN_COMMANDS, PROGRAM_NAME } from './program'; @@ -12,6 +13,7 @@ type ParserFailureCode = | 'unknown_command' | 'internal_error'; + export async function run( argv: string[], options: Partial = {} @@ -36,6 +38,11 @@ export async function run( return 0; } + if (error instanceof HandledFailure) { + io.stderr(errBuffer.join('')); + return error.exitCode; + } + const { code, message } = classifyError(error); const envelope = failureEnvelope(detectCommand(argv), code, message); if (argv.includes('--json')) { diff --git a/packages/instantsearch-cli/src/types/prompts.d.ts b/packages/instantsearch-cli/src/types/prompts.d.ts new file mode 100644 index 00000000000..c3d3f085311 --- /dev/null +++ b/packages/instantsearch-cli/src/types/prompts.d.ts @@ -0,0 +1,6 @@ +declare module 'prompts' { + type Answers = Record; + type Question = unknown; + function prompts(questions: Question | Question[]): Promise; + export default prompts; +} diff --git a/tsconfig.v4.json b/tsconfig.v4.json index 9dd5bcb2522..49de06e40bf 100644 --- a/tsconfig.v4.json +++ b/tsconfig.v4.json @@ -13,6 +13,7 @@ "examples/js/e-commerce-umd/public/packages", "examples/js/showcase", "packages/create-instantsearch-app/src/templates/**/*", - "packages/algolia-experiences" + "packages/algolia-experiences", + "packages/instantsearch-cli" ] } diff --git a/yarn.lock b/yarn.lock index 2a82061cf1a..6ca9a9be904 100644 --- a/yarn.lock +++ b/yarn.lock @@ -22669,6 +22669,15 @@ nocache@^2.1.0: resolved "https://registry.yarnpkg.com/nocache/-/nocache-2.1.0.tgz#120c9ffec43b5729b1d5de88cd71aa75a0ba491f" integrity sha512-0L9FvHG3nfnnmaEQPjT9xhfN4ISk0A8/2j4M37Np4mcDesJjHgEUfgPhdCyZuFI954tjokaIj/A3NdpFNdEh4Q== +nock@13.5.6: + version "13.5.6" + resolved "https://registry.yarnpkg.com/nock/-/nock-13.5.6.tgz#5e693ec2300bbf603b61dae6df0225673e6c4997" + integrity sha512-o2zOYiCpzRqSzPj0Zt/dQ/DqZeYoaQ7TUonc/xUPjCGl9WeHpNbxgVvOquXYAaJzI0M9BXV3HTzG0p8IUAbBTQ== + dependencies: + debug "^4.1.0" + json-stringify-safe "^5.0.1" + propagate "^2.0.0" + node-abi@^3.3.0: version "3.52.0" resolved "https://registry.yarnpkg.com/node-abi/-/node-abi-3.52.0.tgz#ffba0a85f54e552547e5849015f40f9514d5ba7c" @@ -25971,7 +25980,7 @@ prompts@2.4.0: kleur "^3.0.3" sisteransi "^1.0.5" -prompts@^2.0.1, prompts@^2.3.2, prompts@^2.4.0, prompts@^2.4.2: +prompts@2.4.2, prompts@^2.0.1, prompts@^2.3.2, prompts@^2.4.0, prompts@^2.4.2: version "2.4.2" resolved "https://registry.yarnpkg.com/prompts/-/prompts-2.4.2.tgz#7b57e73b3a48029ad10ebd44f74b01722a4cb069" integrity sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q== @@ -26012,6 +26021,11 @@ prop-types@^15.5.10, prop-types@^15.5.8, prop-types@^15.6.0, prop-types@^15.6.1, object-assign "^4.1.1" react-is "^16.13.1" +propagate@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/propagate/-/propagate-2.0.1.tgz#40cdedab18085c792334e64f0ac17256d38f9a45" + integrity sha512-vGrhOavPSTz4QVNuBNdcNXePNdNMaO1xj9yBeH1ScQPjk/rhg9sSlCXPhMkFuaNNW/syTvYqsnbIJxMBfRbbag== + proper-lockfile@^4.1.2: version "4.1.2" resolved "https://registry.yarnpkg.com/proper-lockfile/-/proper-lockfile-4.1.2.tgz#c8b9de2af6b2f1601067f98e01ac66baa223141f"