diff --git a/README.md b/README.md index a18971cd..b0d9f29d 100644 --- a/README.md +++ b/README.md @@ -240,6 +240,29 @@ app.listen({ port: process.env.PORT || 3000 }, (err) => { }) ``` +### eject + +Run `fastify eject` inside your application directory to generate a standalone +server. ESM is detected from `"type": "module"` in `package.json`, and TypeScript +is detected from `src/app.ts`. Use `--esm`, `--esm=false`, or `--lang=js|ts` to +override detection. + +For generated JavaScript projects, ejection creates `server.js` and changes the +standard scripts to `node server.js` and `node --watch server.js`. TypeScript +projects receive `src/server.ts`, run the compiled `dist/server.js`, and retain +the TypeScript compiler watch process. Unmodified generated TypeScript test +configurations and scripts are updated to run compiled tests. + +Ejection replaces unmodified generated test helpers with standalone helpers, +adds `close-with-grace` as a runtime dependency, and removes `fastify-cli` from +both dependency sections when no scripts or source files still reference it. +Run your package manager's install command afterward to update the installed +dependencies and lockfile. + +Custom scripts, helpers, and TypeScript configurations are preserved. If they +still use the CLI, its dependency is retained and a message explains why. +Ejection refuses to overwrite a customized server file. + ### generate `fastify-cli` can also help with generating some project scaffolding to diff --git a/eject.js b/eject.js index f46fe15d..720362cc 100644 --- a/eject.js +++ b/eject.js @@ -1,48 +1,178 @@ 'use strict' const path = require('node:path') +const fs = require('node:fs/promises') const generify = require('generify') const parseArgs = require('./lib/parse-args') const log = require('./log') +const cliPkg = require('./package.json') -function eject (dir, template) { +function normalize (source) { + return source.replace(/\r\n/g, '\n').trimEnd() +} + +async function readPackage (dir) { + try { + return JSON.parse(await fs.readFile(path.join(dir, 'package.json'), 'utf8')) + } catch (err) { + if (err.code === 'ENOENT') return null + throw err + } +} + +function copyTemplate (dir, template) { return new Promise((resolve, reject) => { generify(path.join(__dirname, 'templates', template), dir, {}, function (file) { log('debug', `generated ${file}`) }, function (err) { - if (err) { - return reject(err) - } + if (err) return reject(err) resolve() }) }) } -function cli (args) { - const opts = parseArgs(args, { - options: { - lang: { type: 'string' }, - esm: { type: 'boolean' } +async function usesCli (dir) { + for (const entry of await fs.readdir(dir, { withFileTypes: true })) { + if (entry.name.startsWith('.') || entry.name === 'node_modules' || entry.name === 'dist') continue + const file = path.join(dir, entry.name) + if (entry.isDirectory()) { + if (await usesCli(file)) return true + } else if (entry.isFile() && /\.[cm]?[jt]sx?$/.test(entry.name)) { + if ((await fs.readFile(file, 'utf8')).includes('fastify-cli')) return true } - }) + } + return false +} - let template - if (opts.lang === 'ts' || opts.lang === 'typescript') { - template = 'eject-ts' - } else { - if (opts.esm) { - template = 'eject-esm' - } else { - template = 'eject' +async function eject (dir, template) { + const pkg = await readPackage(dir) + const typescript = template === 'eject-ts' || template === 'eject-ts-esm' + const esm = template === 'eject-esm' || template === 'eject-ts-esm' + const appTemplate = `app${typescript ? '-ts' : ''}${esm ? '-esm' : ''}` + const helperName = `helper.${typescript ? 'ts' : 'js'}` + const helperPath = path.join(dir, 'test', helperName) + let replaceHelper = false + + if (pkg) { + try { + const existing = await fs.readFile(helperPath, 'utf8') + const generated = await fs.readFile(path.join(__dirname, 'templates', appTemplate, 'test', helperName), 'utf8') + replaceHelper = normalize(existing) === normalize(generated) + } catch (err) { + if (err.code !== 'ENOENT') throw err } } - return eject(process.cwd(), template).catch(function (err) { - if (err) { - log('error', err.message) - process.exit(1) + const serverName = typescript ? path.join('src', 'server.ts') : 'server.js' + try { + const existing = await fs.readFile(path.join(dir, serverName), 'utf8') + const generated = await fs.readFile(path.join(__dirname, 'templates', template, serverName), 'utf8') + if (normalize(existing) !== normalize(generated)) { + throw new Error(`${serverName} already exists and has been customized; move it before ejecting`) } - }) + } catch (err) { + if (err.code !== 'ENOENT') throw err + } + + // Validate the manifest and existing server before generating any files. + await copyTemplate(dir, template) + if (!pkg) return + + if (replaceHelper) { + await fs.copyFile(path.join(__dirname, 'templates', `${template}-helper`, helperName), helperPath) + } + + pkg.scripts ||= {} + const replacements = typescript + ? { + start: ['npm run build:ts && fastify start -l info dist/app.js', 'npm run build:ts && node dist/server.js'], + 'dev:start': ['fastify start --ignore-watch=.ts$ -w -l info -P dist/app.js', 'node --watch dist/server.js'], + dev: ['fastify start -w -l info src/app.ts', 'npm run build:ts && concurrently -k -p "[{name}]" -n "TypeScript,App" -c "yellow.bold,cyan.bold" "npm:watch:ts" "npm:dev:start"'] + } + : { + start: ['fastify start -l info app.js', 'node server.js'], + dev: ['fastify start -w -l info -P app.js', 'node --watch server.js'] + } + + for (const [name, [before, after]] of Object.entries(replacements)) { + if (pkg.scripts[name] === before || (name === 'start' && pkg.scripts[name] === undefined) || (!typescript && name === 'dev' && pkg.scripts[name] === undefined)) { + pkg.scripts[name] = after + if (typescript && name === 'dev') pkg.scripts['dev:start'] ||= 'node --watch dist/server.js' + } + } + + if (typescript) { + const appConfig = path.join(dir, 'tsconfig.json') + try { + const existing = await fs.readFile(appConfig, 'utf8') + const generated = await fs.readFile(path.join(__dirname, 'templates', appTemplate, 'tsconfig.json'), 'utf8') + if (normalize(existing) === normalize(generated)) { + const config = JSON.parse(existing) + config.compilerOptions.rootDir = 'src' + config.compilerOptions.skipLibCheck ??= true + await fs.writeFile(appConfig, JSON.stringify(config, null, 2) + '\n') + } + } catch (err) { + if (err.code !== 'ENOENT') throw err + } + const testConfig = path.join(dir, 'test', 'tsconfig.json') + try { + const existing = await fs.readFile(testConfig, 'utf8') + const generated = await fs.readFile(path.join(__dirname, 'templates', appTemplate, 'test', 'tsconfig.json'), 'utf8') + const testScript = esm + ? 'npm run build:ts && tsc -p test/tsconfig.json && FASTIFY_AUTOLOAD_TYPESCRIPT=1 node --test --experimental-test-coverage --loader ts-node/esm test/**/*.ts' + : 'npm run build:ts && tsc -p test/tsconfig.json && c8 node --test -r ts-node/register "test/**/*.ts"' + if (normalize(existing) === normalize(generated) && pkg.scripts.test === testScript) { + await fs.copyFile(path.join(__dirname, 'templates', `${template}-helper`, 'tsconfig.json'), testConfig) + pkg.scripts.test = `npm run build:ts && tsc -p test/tsconfig.json && ${esm ? 'node --test --experimental-test-coverage' : 'c8 node --test'} "dist/test/**/*.test.js"` + } + } catch (err) { + if (err.code !== 'ENOENT') throw err + } + } + + pkg.dependencies ||= {} + if (!pkg.dependencies['close-with-grace']) { + pkg.dependencies['close-with-grace'] = pkg.devDependencies?.['close-with-grace'] || cliPkg.dependencies['close-with-grace'] + if (pkg.devDependencies) delete pkg.devDependencies['close-with-grace'] + } + + const cliScripts = Object.values(pkg.scripts).some(script => /\bfastify\b/.test(script)) + if (!cliScripts && !await usesCli(dir)) { + delete pkg.dependencies['fastify-cli'] + if (pkg.devDependencies) delete pkg.devDependencies['fastify-cli'] + } else { + log('info', 'keeping fastify-cli: custom scripts or source files still use it') + } + await fs.writeFile(path.join(dir, 'package.json'), JSON.stringify(pkg, null, 2) + '\n') + log('info', 'ejected application; run your package manager install command to update dependencies and the lockfile') +} + +async function cli (args) { + try { + const opts = parseArgs(args, { + options: { + lang: { type: 'string' }, + esm: { type: 'boolean' } + } + }) + const dir = process.cwd() + const pkg = await readPackage(dir) + const esm = opts.esm === undefined ? pkg?.type === 'module' : opts.esm + let typescript = opts.lang === 'ts' || opts.lang === 'typescript' + if (opts.lang === undefined) { + try { + await fs.access(path.join(dir, 'src', 'app.ts')) + typescript = true + } catch (err) { + if (err.code !== 'ENOENT') throw err + } + } + await eject(dir, `eject${typescript ? '-ts' : ''}${esm ? '-esm' : ''}`) + } catch (err) { + log('error', err.message) + process.exit(1) + } } module.exports = { diff --git a/help/eject.txt b/help/eject.txt index f610be48..deba7d1f 100644 --- a/help/eject.txt +++ b/help/eject.txt @@ -1,13 +1,25 @@ Usage: fastify eject -Turns your application into a standalone executable with a server.js file being added. +Turns your application into a standalone executable with server.js (JavaScript) +or src/server.ts (TypeScript) being added. -You can set the port to listen on with the PORT environment variable (default to 3000). +The module format is detected from package.json's type field. TypeScript is +detected from src/app.ts. Explicit --esm and --lang options override detection. + +Generated start and dev scripts are updated to use Node.js. Unmodified generated +test helpers are replaced with standalone helpers, and fastify-cli is removed +from dependencies if no scripts or source files still reference it. + +Custom scripts, helpers, and TypeScript configurations are preserved. Existing +custom server files must be moved before ejecting. Run your package manager's +install command afterward to update dependencies and the lockfile. + +You can set the port with PORT (default: 3000). OPTS - --esm - use the ESM template + --esm, --esm=false + override the detected module format - --lang=ts, --lang=typescript - use the TypeScript template + --lang=ts, --lang=typescript, --lang=js + override the detected language diff --git a/templates/eject-esm-helper/helper.js b/templates/eject-esm-helper/helper.js new file mode 100644 index 00000000..22692ae2 --- /dev/null +++ b/templates/eject-esm-helper/helper.js @@ -0,0 +1,20 @@ +import Fastify from 'fastify' +import fp from 'fastify-plugin' +import App from '../app.js' + +// Fill in this config with the options needed for testing the application. +function config () { + return { skipOverride: true } +} + +// Build an application without starting a server, and close it after the test. +async function build (t) { + const { skipOverride, ...options } = config() + const app = Fastify() + t.after(() => app.close()) + app.register(skipOverride ? fp(App) : App, options) + await app.ready() + return app +} + +export { config, build } diff --git a/templates/eject-esm/server.js b/templates/eject-esm/server.js index d9527b13..f750bacb 100644 --- a/templates/eject-esm/server.js +++ b/templates/eject-esm/server.js @@ -29,7 +29,7 @@ closeWithGrace({ delay: process.env.FASTIFY_CLOSE_GRACE_DELAY || 500 }, async fu }) // Start listening -app.listen({ port: process.env.PORT || 3000 }, (err) => { +app.listen({ port: Number(process.env.PORT ?? 3000) }, (err) => { if (err) { app.log.error(err) process.exit(1) diff --git a/templates/eject-helper/helper.js b/templates/eject-helper/helper.js new file mode 100644 index 00000000..6abb1e35 --- /dev/null +++ b/templates/eject-helper/helper.js @@ -0,0 +1,22 @@ +'use strict' + +const Fastify = require('fastify') +const fp = require('fastify-plugin') +const App = require('../app.js') + +// Fill in this config with the options needed for testing the application. +function config () { + return { skipOverride: true } +} + +// Build an application without starting a server, and close it after the test. +async function build (t) { + const { skipOverride, ...options } = config() + const app = Fastify() + t.after(() => app.close()) + app.register(skipOverride ? fp(App) : App, options) + await app.ready() + return app +} + +module.exports = { config, build } diff --git a/templates/eject-ts-esm-helper/helper.ts b/templates/eject-ts-esm-helper/helper.ts new file mode 100644 index 00000000..d4caee18 --- /dev/null +++ b/templates/eject-ts-esm-helper/helper.ts @@ -0,0 +1,23 @@ +import Fastify from 'fastify' +import fp from 'fastify-plugin' +import type { TestContext as NodeTestContext } from 'node:test' +import App from '../src/app.js' + +export type TestContext = Pick + +// Fill in this config with the options needed for testing the application. +function config () { + return { skipOverride: true } +} + +// Build an application without starting a server, and close it after the test. +async function build (t: TestContext) { + const { skipOverride, ...options } = config() + const app = Fastify() + t.after(async () => { await app.close() }) + app.register(skipOverride ? fp(App) : App, options) + await app.ready() + return app +} + +export { config, build } diff --git a/templates/eject-ts-esm-helper/tsconfig.json b/templates/eject-ts-esm-helper/tsconfig.json new file mode 100644 index 00000000..dd7e0012 --- /dev/null +++ b/templates/eject-ts-esm-helper/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "rootDir": "..", + "outDir": "../dist", + "noEmit": false + }, + "include": ["../src/**/*.ts", "**/*.ts"] +} diff --git a/templates/eject-ts-esm/src/server.ts b/templates/eject-ts-esm/src/server.ts new file mode 100644 index 00000000..b4f1f0d4 --- /dev/null +++ b/templates/eject-ts-esm/src/server.ts @@ -0,0 +1,22 @@ +import Fastify from 'fastify' +import closeWithGrace from 'close-with-grace' +import App from './app.js' + +try { + process.loadEnvFile() +} catch {} + +const app = Fastify({ logger: true }) +app.register(App) + +closeWithGrace({ delay: Number(process.env.FASTIFY_CLOSE_GRACE_DELAY) || 500 }, async ({ err }) => { + if (err) app.log.error(err) + await app.close() +}) + +app.listen({ port: Number(process.env.PORT ?? 3000) }, (err) => { + if (err) { + app.log.error(err) + process.exit(1) + } +}) diff --git a/templates/eject-ts-helper/helper.ts b/templates/eject-ts-helper/helper.ts new file mode 100644 index 00000000..b37edd7b --- /dev/null +++ b/templates/eject-ts-helper/helper.ts @@ -0,0 +1,23 @@ +import Fastify from 'fastify' +import fp from 'fastify-plugin' +import type { TestContext as NodeTestContext } from 'node:test' +import App from '../src/app' + +export type TestContext = Pick + +// Fill in this config with the options needed for testing the application. +function config () { + return { skipOverride: true } +} + +// Build an application without starting a server, and close it after the test. +async function build (t: TestContext) { + const { skipOverride, ...options } = config() + const app = Fastify() + t.after(async () => { await app.close() }) + app.register(skipOverride ? fp(App) : App, options) + await app.ready() + return app +} + +export { config, build } diff --git a/templates/eject-ts-helper/tsconfig.json b/templates/eject-ts-helper/tsconfig.json new file mode 100644 index 00000000..dd7e0012 --- /dev/null +++ b/templates/eject-ts-helper/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "rootDir": "..", + "outDir": "../dist", + "noEmit": false + }, + "include": ["../src/**/*.ts", "**/*.ts"] +} diff --git a/templates/eject-ts/server.ts b/templates/eject-ts/server.ts deleted file mode 100644 index c4e44e1b..00000000 --- a/templates/eject-ts/server.ts +++ /dev/null @@ -1,34 +0,0 @@ -// Require the framework -import Fastify from 'fastify' - -// Require library to exit fastify process, gracefully (if possible) -import closeWithGrace from 'close-with-grace' - -// Read and load environment variables from .env files (ignore if not present) -try { - process.loadEnvFile() -} catch {} - -// Instantiate Fastify with some config -const app = Fastify({ - logger: true, -}) - -// Register your application as a normal plugin -app.register(import('./app')) - -// delay is the number of milliseconds for the graceful close to finish -closeWithGrace({ delay: parseInt(process.env.FASTIFY_CLOSE_GRACE_DELAY) || 500 }, async function ({ signal, err, manual }) { - if (err) { - app.log.error(err) - } - await app.close() -} as closeWithGrace.CloseWithGraceAsyncCallback) - -// Start listening -app.listen({ port: parseInt(process.env.PORT) || 3000 }, (err: any) => { - if (err) { - app.log.error(err) - process.exit(1) - } -}) diff --git a/templates/eject-ts/src/server.ts b/templates/eject-ts/src/server.ts new file mode 100644 index 00000000..0b7834a2 --- /dev/null +++ b/templates/eject-ts/src/server.ts @@ -0,0 +1,22 @@ +import Fastify from 'fastify' +import closeWithGrace from 'close-with-grace' +import App from './app' + +try { + process.loadEnvFile() +} catch {} + +const app = Fastify({ logger: true }) +app.register(App) + +closeWithGrace({ delay: Number(process.env.FASTIFY_CLOSE_GRACE_DELAY) || 500 }, async ({ err }) => { + if (err) app.log.error(err) + await app.close() +}) + +app.listen({ port: Number(process.env.PORT ?? 3000) }, (err) => { + if (err) { + app.log.error(err) + process.exit(1) + } +}) diff --git a/templates/eject/server.js b/templates/eject/server.js index 7593eb9d..aa64ffcb 100644 --- a/templates/eject/server.js +++ b/templates/eject/server.js @@ -29,7 +29,7 @@ closeWithGrace({ delay: process.env.FASTIFY_CLOSE_GRACE_DELAY || 500 }, async fu }) // Start listening -app.listen({ port: process.env.PORT || 3000 }, (err) => { +app.listen({ port: Number(process.env.PORT ?? 3000) }, (err) => { if (err) { app.log.error(err) process.exit(1) diff --git a/test/eject-project.test.js b/test/eject-project.test.js new file mode 100644 index 00000000..e9fdf488 --- /dev/null +++ b/test/eject-project.test.js @@ -0,0 +1,203 @@ +'use strict' + +const { test } = require('node:test') +const fs = require('node:fs/promises') +const os = require('node:os') +const path = require('node:path') +const { execFile, spawn } = require('node:child_process') +const { promisify } = require('node:util') +const proxyquire = require('proxyquire') +const { eject } = require('../eject') +const cliPkg = require('../package.json') +const { javascriptTemplate } = require('../generate') + +const exec = promisify(execFile) +const root = path.join(__dirname, '..') +const cliPath = path.join(root, 'cli.js') + +async function makeDir (t) { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'fastify-eject-project-')) + t.after(() => fs.rm(dir, { recursive: true, force: true })) + return dir +} + +async function writePackage (dir, pkg) { + await fs.writeFile(path.join(dir, 'package.json'), JSON.stringify(pkg, null, 2) + '\n') +} + +// Expose only the declared dependencies, so importing fastify-cli fails. +async function linkDependencies (dir, pkg) { + const modules = path.join(dir, 'node_modules') + await fs.mkdir(path.join(modules, '.bin'), { recursive: true }) + for (const name of new Set([...Object.keys(pkg.dependencies), ...Object.keys(pkg.devDependencies || {})])) { + if (name === 'fastify-cli') throw new Error('ejected project still depends on fastify-cli') + const target = path.join(modules, name) + await fs.mkdir(path.dirname(target), { recursive: true }) + await fs.symlink(path.join(root, 'node_modules', name), target, 'junction') + } + for (const bin of ['tsc', 'c8', 'concurrently']) { + for (const suffix of ['', '.cmd', '.ps1']) { + const source = path.join(root, 'node_modules', '.bin', bin + suffix) + try { + await fs.cp(source, path.join(modules, '.bin', bin + suffix)) + } catch (err) { + if (err.code !== 'ENOENT') throw err + } + } + } +} + +async function requestServer (t, dir, args) { + const child = spawn(process.execPath, args, { cwd: dir, env: { ...process.env, PORT: '0' } }) + t.after(() => child.kill()) + const closed = new Promise(resolve => child.once('close', resolve)) + let output = '' + const address = await new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error(`server did not listen: ${output}`)), 15000) + const finish = (err, url) => { + clearTimeout(timeout) + if (err) reject(err) + else resolve(url) + } + child.once('error', err => finish(err)) + child.once('exit', code => finish(new Error(`server exited ${code}: ${output}`))) + child.stderr.on('data', data => { output += data }) + child.stdout.on('data', data => { + output += data + const match = output.match(/http:\/\/(?:127\.0\.0\.1|\[::1\]):\d+/) + if (match) finish(null, match[0]) + }) + }) + const response = await fetch(`${address}/example`) + t.assert.strictEqual(response.status, 200) + t.assert.strictEqual(await response.text(), 'this is an example') + child.kill('SIGTERM') + await closed + if (process.platform !== 'win32') t.assert.strictEqual(child.exitCode, 0) +} + +for (const typescript of [false, true]) { + for (const esm of [false, true]) { + test(`generated ${typescript ? 'TypeScript' : 'JavaScript'} ${esm ? 'ESM' : 'CJS'} app runs without fastify-cli after eject`, async t => { + const parent = await makeDir(t) + const dir = path.join(parent, 'app') + const flags = [...(typescript ? ['--lang=ts'] : []), ...(esm ? ['--esm'] : [])] + await exec(process.execPath, [cliPath, 'generate', 'app', ...flags], { cwd: parent }) + await exec(process.execPath, [cliPath, 'eject'], { cwd: dir }) + const manifest = await fs.readFile(path.join(dir, 'package.json'), 'utf8') + const pkg = JSON.parse(manifest) + t.assert.strictEqual(pkg.dependencies['fastify-cli'], undefined) + t.assert.strictEqual(pkg.dependencies['close-with-grace'], cliPkg.dependencies['close-with-grace']) + t.assert.ok(!Object.values(pkg.scripts).some(script => /\bfastify\b/.test(script))) + t.assert.strictEqual(pkg.scripts.start, typescript ? 'npm run build:ts && node dist/server.js' : 'node server.js') + await linkDependencies(dir, pkg) + await t.assert.rejects(exec(process.execPath, ['-e', "require.resolve('fastify-cli/helper')"], { cwd: dir }), /MODULE_NOT_FOUND/) + // The app's c8 command must not clean the parent suite's coverage directory. + await exec('npm', ['test'], { cwd: dir, env: { ...process.env, NODE_V8_COVERAGE: path.join(dir, 'coverage', 'tmp') }, timeout: 60000, maxBuffer: 1024 * 1024, shell: process.platform === 'win32' }) + await requestServer(t, dir, [typescript ? 'dist/server.js' : 'server.js']) + await exec(process.execPath, [cliPath, 'eject'], { cwd: dir }) + t.assert.strictEqual(await fs.readFile(path.join(dir, 'package.json'), 'utf8'), manifest) + }) + } +} + +test('preserves custom scripts and dependencies while updating generated commands', async t => { + const dir = await makeDir(t) + const pkg = { + name: 'custom-app', + scripts: { ...javascriptTemplate.scripts, dev: 'fastify start -w -p 4000 app.js', lint: 'eslint', custom: 'echo hello' }, + dependencies: { 'fastify-cli': '^8.0.0', 'close-with-grace': '^2.0.0', custom: '1.0.0' }, + devDependencies: { 'fastify-cli': '^8.0.0' } + } + await writePackage(dir, pkg) + await eject(dir, 'eject') + const result = JSON.parse(await fs.readFile(path.join(dir, 'package.json'), 'utf8')) + t.assert.strictEqual(result.scripts.start, 'node server.js') + t.assert.strictEqual(result.scripts.dev, pkg.scripts.dev) + t.assert.strictEqual(result.scripts.lint, 'eslint') + t.assert.strictEqual(result.scripts.custom, 'echo hello') + t.assert.deepStrictEqual(result.dependencies, pkg.dependencies) + t.assert.deepStrictEqual(result.devDependencies, pkg.devDependencies) +}) + +test('preserves customized test helpers and keeps the CLI they import', async t => { + const dir = await makeDir(t) + await writePackage(dir, { scripts: javascriptTemplate.scripts, dependencies: { 'fastify-cli': '^8.0.0' } }) + await fs.mkdir(path.join(dir, 'test')) + const helper = "const helper = require('fastify-cli/helper')\n// custom configuration\n" + await fs.writeFile(path.join(dir, 'test', 'helper.js'), helper) + await eject(dir, 'eject') + t.assert.strictEqual(await fs.readFile(path.join(dir, 'test', 'helper.js'), 'utf8'), helper) + const pkg = JSON.parse(await fs.readFile(path.join(dir, 'package.json'), 'utf8')) + t.assert.strictEqual(pkg.dependencies['fastify-cli'], '^8.0.0') +}) + +test('does not overwrite custom servers or modify their manifest', async t => { + const dir = await makeDir(t) + await writePackage(dir, { dependencies: { 'fastify-cli': '^8.0.0' } }) + const manifest = await fs.readFile(path.join(dir, 'package.json'), 'utf8') + await fs.writeFile(path.join(dir, 'server.js'), '// custom server\n') + await t.assert.rejects(eject(dir, 'eject'), /server.js already exists/) + t.assert.strictEqual(await fs.readFile(path.join(dir, 'server.js'), 'utf8'), '// custom server\n') + t.assert.strictEqual(await fs.readFile(path.join(dir, 'package.json'), 'utf8'), manifest) +}) + +test('rejects invalid manifests before writing a server', async t => { + const dir = await makeDir(t) + await fs.writeFile(path.join(dir, 'package.json'), '{') + await t.assert.rejects(eject(dir, 'eject'), SyntaxError) + await t.assert.rejects(fs.access(path.join(dir, 'server.js')), { code: 'ENOENT' }) +}) + +test('explicit module and language flags override detection', async t => { + const dir = await makeDir(t) + await writePackage(dir, { type: 'module' }) + await fs.mkdir(path.join(dir, 'src')) + await fs.writeFile(path.join(dir, 'src', 'app.ts'), '') + await exec(process.execPath, [cliPath, 'eject', '--esm=false', '--lang=js'], { cwd: dir }) + const server = await fs.readFile(path.join(dir, 'server.js'), 'utf8') + t.assert.match(server, /require\('fastify'\)/) + await t.assert.rejects(fs.access(path.join(dir, 'src', 'server.ts')), { code: 'ENOENT' }) +}) + +test('keeps custom TypeScript configs and reports unreadable configs without rewriting the manifest', async t => { + for (const configPath of ['tsconfig.json', path.join('test', 'tsconfig.json')]) { + const dir = await makeDir(t) + await writePackage(dir, { scripts: { start: 'custom-start' } }) + const manifest = await fs.readFile(path.join(dir, 'package.json'), 'utf8') + await fs.mkdir(path.join(dir, configPath), { recursive: true }) + await t.assert.rejects(eject(dir, 'eject-ts'), { code: 'EISDIR' }) + t.assert.strictEqual(await fs.readFile(path.join(dir, 'package.json'), 'utf8'), manifest) + } + const dir = await makeDir(t) + await writePackage(dir, { scripts: { start: 'custom-start' } }) + const config = '{"compilerOptions":{"rootDir":"src","strict":false}}\n' + await fs.writeFile(path.join(dir, 'tsconfig.json'), config) + await eject(dir, 'eject-ts') + t.assert.strictEqual(await fs.readFile(path.join(dir, 'tsconfig.json'), 'utf8'), config) + await t.assert.rejects(fs.access(path.join(dir, 'test', 'tsconfig.json')), { code: 'ENOENT' }) +}) + +test('rejects unreadable helpers before generating a server', async t => { + const dir = await makeDir(t) + await writePackage(dir, {}) + await fs.mkdir(path.join(dir, 'test', 'helper.js'), { recursive: true }) + await t.assert.rejects(eject(dir, 'eject'), { code: 'EISDIR' }) + await t.assert.rejects(fs.access(path.join(dir, 'server.js')), { code: 'ENOENT' }) +}) + +test('reports language detection errors without generating a server', async t => { + const dir = await makeDir(t) + const { cli } = proxyquire('../eject', { + 'node:fs/promises': { + access: async () => { throw new Error('cannot inspect app.ts') } + } + }) + t.mock.method(process, 'cwd', () => dir) + t.mock.method(process, 'exit', () => {}) + const log = t.mock.method(console, 'log', () => {}) + await cli([]) + t.assert.strictEqual(process.exit.mock.calls[0].arguments[0], 1) + t.assert.match(log.mock.calls[0].arguments[0], /cannot inspect app.ts/) + await t.assert.rejects(fs.access(path.join(dir, 'server.js')), { code: 'ENOENT' }) +})