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..e7ecd0eb0cd 100644 --- a/packages/instantsearch-cli/__tests__/human-output.test.ts +++ b/packages/instantsearch-cli/__tests__/human-output.test.ts @@ -1,6 +1,6 @@ import { runCapturing } from './__utils__/helpers'; -describe.each(['init', 'add', 'introspect'])( +describe.each(['add', 'introspect'])( '%s (no --json)', (command) => { it('emits human-readable output, not a JSON envelope', async () => { 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__/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..dccb3b75ee8 100644 --- a/packages/instantsearch-cli/__tests__/json-envelope.test.ts +++ b/packages/instantsearch-cli/__tests__/json-envelope.test.ts @@ -1,6 +1,6 @@ import { runCapturing } from './__utils__/helpers'; -describe.each(['init', 'add', 'introspect'])( +describe.each(['add', 'introspect'])( '%s --json', (command) => { it('emits a well-formed success envelope on stdout', async () => { diff --git a/packages/instantsearch-cli/package.json b/packages/instantsearch-cli/package.json index 9017f50cbb4..a42061b12d4 100644 --- a/packages/instantsearch-cli/package.json +++ b/packages/instantsearch-cli/package.json @@ -18,7 +18,8 @@ }, "dependencies": { "commander": "11.1.0", - "jsonc-parser": "3.3.1" + "jsonc-parser": "3.3.1", + "prompts": "2.4.2" }, "devDependencies": { "@types/jest": "27.4.0", 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/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/program.ts b/packages/instantsearch-cli/src/program.ts index 8cf88095528..996333eb445 100644 --- a/packages/instantsearch-cli/src/program.ts +++ b/packages/instantsearch-cli/src/program.ts @@ -1,6 +1,8 @@ import { Command, Option } from 'commander'; import { formatEnvelope, successEnvelope } from './envelope'; +import { HandledFailure } from './handled-failure'; +import { runInit } from './init'; import { defaultIO, type IO } from './io'; import version from './version'; @@ -12,16 +14,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: @@ -45,7 +37,18 @@ const STUBS = [ ] 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', + ...STUBS.map((s) => s.name), +]; + +type InitFlagOptions = { + componentsPath?: string; + libPath?: string; + appId?: string; + searchApiKey?: string; + framework?: 'next-app'; +}; export function createProgram(io: IO = defaultIO()): Command { const program = new Command(); @@ -72,6 +75,42 @@ 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); + }); + 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/yarn.lock b/yarn.lock index 2a82061cf1a..a08a0ae2aa3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -25971,7 +25971,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==