diff --git a/.changeset/fix-bundler-transitive-deps.md b/.changeset/fix-bundler-transitive-deps.md new file mode 100644 index 00000000..a04d0c93 --- /dev/null +++ b/.changeset/fix-bundler-transitive-deps.md @@ -0,0 +1,12 @@ +--- +'@kidd-cli/bundler': minor +--- + +Revert Bun.build migration and restore tsdown as the bundler. + +- Restore `map-config.ts` with tsdown InlineConfig mapping (build + watch) +- Restore simpler autoload plugin (no `coreDistDir` needed) +- Restore tsdown native watch mode +- Remove `@kidd-cli/core` dependency (no longer needed) +- Remove `bun-runner.ts` subprocess architecture +- Restore `NODE_BUILTINS` and `neverBundle` for proper externalization diff --git a/.changeset/upgrade-deps.md b/.changeset/upgrade-deps.md index 51f63076..f95769d8 100644 --- a/.changeset/upgrade-deps.md +++ b/.changeset/upgrade-deps.md @@ -1,5 +1,5 @@ --- -"@kidd-cli/core": minor +'@kidd-cli/core': minor --- Upgrade `@clack/prompts` from 1.1.0 to 1.2.0 diff --git a/.github/workflows/ci-integration.yml b/.github/workflows/ci-integration.yml index 747034f1..ffd02830 100644 --- a/.github/workflows/ci-integration.yml +++ b/.github/workflows/ci-integration.yml @@ -152,8 +152,8 @@ jobs: - name: 'Restore binary permissions' if: runner.os != 'Windows' run: | - find examples -name 'cli-*' -type f -exec chmod +x {} + - find examples -name '*.js' -path '*/dist/*' -type f -exec chmod +x {} + + find examples -path '*/dist/*' -type f ! -name '*.mjs' ! -name '*.map' ! -name '*.exe' -exec chmod +x {} + + find examples -name '*.mjs' -path '*/dist/*' -type f -exec chmod +x {} + - name: 'Run integration tests' run: pnpm test:integration diff --git a/codspeed.yml b/codspeed.yml index 29de9e65..599ab4d7 100644 --- a/codspeed.yml +++ b/codspeed.yml @@ -1,6 +1,6 @@ benchmarks: - name: 'icons --help' - exec: node examples/icons/dist/index.js --help + exec: node examples/icons/dist/index.mjs --help - name: 'icons status' - exec: node examples/icons/dist/index.js status + exec: node examples/icons/dist/index.mjs status diff --git a/examples/advanced/kidd.config.ts b/examples/advanced/kidd.config.ts index ec07187f..40d3308b 100644 --- a/examples/advanced/kidd.config.ts +++ b/examples/advanced/kidd.config.ts @@ -1,7 +1,12 @@ import { defineConfig } from '@kidd-cli/core' export default defineConfig({ - build: { out: './dist' }, + build: { + out: './dist', + define: { + __BUILD_DATE__: JSON.stringify(new Date().toISOString()), + }, + }, commands: './src/commands', compile: true, entry: './src/index.ts', diff --git a/examples/advanced/package.json b/examples/advanced/package.json index 66c9d3da..285f598e 100644 --- a/examples/advanced/package.json +++ b/examples/advanced/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "dev": "kidd dev", - "build": "kidd build", + "build": "KIDD_PUBLIC_APP_ENV=development KIDD_PUBLIC_ANALYTICS_KEY=client-abc123 kidd build", "routes": "kidd routes", "typecheck": "tsgo --noEmit" }, diff --git a/packages/bundler/package.json b/packages/bundler/package.json index 8266f05c..ac491659 100644 --- a/packages/bundler/package.json +++ b/packages/bundler/package.json @@ -1,12 +1,12 @@ { "name": "@kidd-cli/bundler", "version": "0.6.0", - "description": "Programmatic bundler for kidd CLI tools powered by Bun", + "description": "Programmatic bundler for kidd CLI tools powered by tsdown", "keywords": [ - "bun", "bundler", "cli", "kidd", + "tsdown", "typescript" ], "homepage": "https://github.com/joggrdocs/kidd/tree/main/packages/bundler", @@ -44,17 +44,15 @@ "@kidd-cli/utils": "workspace:*", "es-toolkit": "catalog:", "ts-pattern": "catalog:", + "tsdown": "catalog:", "zod": "catalog:" }, "devDependencies": { - "@types/bun": "catalog:", "@types/node": "catalog:", - "tsdown": "catalog:", "typescript": "catalog:", "vitest": "catalog:" }, "engines": { - "bun": ">=1.3", "node": ">=24" } } diff --git a/packages/bundler/src/autoloader/autoload-plugin.test.ts b/packages/bundler/src/autoloader/autoload-plugin.test.ts index 34458557..5e765fd1 100644 --- a/packages/bundler/src/autoloader/autoload-plugin.test.ts +++ b/packages/bundler/src/autoloader/autoload-plugin.test.ts @@ -2,102 +2,60 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' vi.mock(import('./generate-autoloader.js')) vi.mock(import('./scan-commands.js')) -vi.mock(import('node:fs')) const { generateStaticAutoloader } = await import('./generate-autoloader.js') const { scanCommandsDir } = await import('./scan-commands.js') -const { readFileSync } = await import('node:fs') const { createAutoloadPlugin } = await import('./autoload-plugin.js') const mockGenerateStaticAutoloader = vi.mocked(generateStaticAutoloader) const mockScanCommandsDir = vi.mocked(scanCommandsDir) -const mockReadFileSync = vi.mocked(readFileSync) beforeEach(() => { vi.clearAllMocks() }) describe('createAutoloadPlugin', () => { - const mockBuild = { - onResolve: vi.fn(), - onLoad: vi.fn(), - } - - beforeEach(() => { - const plugin = createAutoloadPlugin({ - commandsDir: '/project/commands', - tagModulePath: '/project/tag.js', - coreDistDir: '/project/node_modules/@kidd-cli/core/dist', - }) - - plugin.setup(mockBuild as never) - }) - - describe('onResolve hook', () => { - it('should resolve virtual module ID', () => { - const [, resolveFn] = mockBuild.onResolve.mock.calls[0] - const result = resolveFn({ path: 'virtual:kidd-static-commands' }) - - expect(result).toEqual({ - namespace: 'kidd-autoload', - path: 'virtual:kidd-static-commands', + describe('transform hook', () => { + it('should return null for non-kidd dist files', () => { + const plugin = createAutoloadPlugin({ + commandsDir: '/project/commands', + tagModulePath: '/project/tag.js', }) - }) - }) - - describe('onLoad autoload hook', () => { - it('should call scanCommandsDir and generateStaticAutoloader for virtual module', async () => { - const scanResult = { dirs: [], files: [] } - mockScanCommandsDir.mockResolvedValueOnce(scanResult) - mockGenerateStaticAutoloader.mockReturnValueOnce('generated code') - const autoloadCall = mockBuild.onLoad.mock.calls.find( - ([opts]) => opts.namespace === 'kidd-autoload' - ) - const result = await autoloadCall[1]({}) + const result = plugin.transform('some code', '/other/package/dist/index.js') - expect(result).toEqual({ contents: 'generated code', loader: 'js' }) - expect(mockScanCommandsDir).toHaveBeenCalledOnce() - expect(mockGenerateStaticAutoloader).toHaveBeenCalledOnce() + expect(result).toBeNull() }) - it('should pass commandsDir and tagModulePath to generators', async () => { - const scanResult = { dirs: [], files: [] } - mockScanCommandsDir.mockResolvedValueOnce(scanResult) - mockGenerateStaticAutoloader.mockReturnValueOnce('generated code') - - const autoloadCall = mockBuild.onLoad.mock.calls.find( - ([opts]) => opts.namespace === 'kidd-autoload' - ) - await autoloadCall[1]({}) - - expect(mockScanCommandsDir).toHaveBeenCalledWith('/project/commands') - expect(mockGenerateStaticAutoloader).toHaveBeenCalledWith({ - scan: scanResult, + it('should return null when no region start marker found', () => { + const plugin = createAutoloadPlugin({ + commandsDir: '/project/commands', tagModulePath: '/project/tag.js', }) - }) - }) - describe('onLoad transform hook', () => { - it('should return undefined when no region start marker found', () => { - const transformCall = mockBuild.onLoad.mock.calls.find( - ([opts]) => opts.namespace !== 'kidd-autoload' - ) + const code = 'const x = 1\n//#endregion\n' + const result = plugin.transform(code, '/node_modules/kidd/dist/index.js') - mockReadFileSync.mockReturnValueOnce('const x = 1\nconst y = 2\n') + expect(result).toBeNull() + }) - const result = transformCall[1]({ - path: '/project/node_modules/@kidd-cli/core/dist/index.js', + it('should return null when no region end marker found', () => { + const plugin = createAutoloadPlugin({ + commandsDir: '/project/commands', + tagModulePath: '/project/tag.js', }) - expect(result).toBeUndefined() + const code = 'const x = 1\n//#region src/autoload.ts\nsome content' + const result = plugin.transform(code, '/node_modules/kidd/dist/index.js') + + expect(result).toBeNull() }) - it('should replace region with static import when markers found', () => { - const transformCall = mockBuild.onLoad.mock.calls.find( - ([opts]) => opts.namespace !== 'kidd-autoload' - ) + it('should replace region with static import when markers found in kidd dist', () => { + const plugin = createAutoloadPlugin({ + commandsDir: '/project/commands', + tagModulePath: '/project/tag.js', + }) const code = [ 'const before = 1', @@ -107,33 +65,87 @@ describe('createAutoloadPlugin', () => { 'const after = 2', ].join('\n') - mockReadFileSync.mockReturnValueOnce(code) + const result = plugin.transform(code, '/node_modules/kidd/dist/index.js') + + expect(result).toContain('const before = 1') + expect(result).toContain('const after = 2') + expect(result).toContain('//#region src/autoload.ts (static)') + expect(result).toContain("await import('virtual:kidd-static-commands')") + expect(result).toContain('return mod.autoload()') + expect(result).not.toContain('async function autoload() { return {} }') + }) + }) + + describe('resolveId hook', () => { + it('should resolve virtual module ID to prefixed ID', () => { + const plugin = createAutoloadPlugin({ + commandsDir: '/project/commands', + tagModulePath: '/project/tag.js', + }) + + const result = plugin.resolveId('virtual:kidd-static-commands') + + expect(result).toBe('\0virtual:kidd-static-commands') + }) + + it('should return null for non-virtual module IDs', () => { + const plugin = createAutoloadPlugin({ + commandsDir: '/project/commands', + tagModulePath: '/project/tag.js', + }) + + const result = plugin.resolveId('./some-module.js') + + expect(result).toBeNull() + }) + }) - const result = transformCall[1]({ - path: '/project/node_modules/@kidd-cli/core/dist/index.js', + describe('load hook', () => { + it('should return null for non-virtual module IDs', async () => { + const plugin = createAutoloadPlugin({ + commandsDir: '/project/commands', + tagModulePath: '/project/tag.js', }) - expect(result).toEqual({ contents: expect.any(String), loader: 'js' }) - expect(result.contents).toContain('const before = 1') - expect(result.contents).toContain('const after = 2') - expect(result.contents).toContain('//#region src/autoload.ts (static)') - expect(result.contents).toContain("await import('virtual:kidd-static-commands')") - expect(result.contents).toContain('return mod.autoload()') - expect(result.contents).not.toContain('async function autoload() { return {} }') + const result = await plugin.load('./some-module.js') + + expect(result).toBeNull() }) - it('should return undefined when code has end marker but no start marker', () => { - const transformCall = mockBuild.onLoad.mock.calls.find( - ([opts]) => opts.namespace !== 'kidd-autoload' - ) + it('should call scanCommandsDir and generateStaticAutoloader for virtual module', async () => { + const scanResult = { dirs: [], files: [] } + mockScanCommandsDir.mockResolvedValueOnce(scanResult) + mockGenerateStaticAutoloader.mockReturnValueOnce('generated code') + + const plugin = createAutoloadPlugin({ + commandsDir: '/project/commands', + tagModulePath: '/project/tag.js', + }) + + const result = await plugin.load('\0virtual:kidd-static-commands') - mockReadFileSync.mockReturnValueOnce('const x = 1\n//#endregion\n') + expect(result).toBe('generated code') + expect(mockScanCommandsDir).toHaveBeenCalledOnce() + expect(mockGenerateStaticAutoloader).toHaveBeenCalledOnce() + }) + + it('should pass commandsDir and tagModulePath to generators', async () => { + const scanResult = { dirs: [], files: [] } + mockScanCommandsDir.mockResolvedValueOnce(scanResult) + mockGenerateStaticAutoloader.mockReturnValueOnce('generated code') - const result = transformCall[1]({ - path: '/project/node_modules/@kidd-cli/core/dist/index.js', + const plugin = createAutoloadPlugin({ + commandsDir: '/project/commands', + tagModulePath: '/project/tag.js', }) - expect(result).toBeUndefined() + await plugin.load('\0virtual:kidd-static-commands') + + expect(mockScanCommandsDir).toHaveBeenCalledWith('/project/commands') + expect(mockGenerateStaticAutoloader).toHaveBeenCalledWith({ + scan: scanResult, + tagModulePath: '/project/tag.js', + }) }) }) }) diff --git a/packages/bundler/src/autoloader/autoload-plugin.ts b/packages/bundler/src/autoloader/autoload-plugin.ts index 744faa84..c4548a7d 100644 --- a/packages/bundler/src/autoloader/autoload-plugin.ts +++ b/packages/bundler/src/autoloader/autoload-plugin.ts @@ -1,11 +1,10 @@ -import { readFileSync } from 'node:fs' - -import type { BunPlugin } from 'bun' +import type { Rolldown } from 'tsdown' import { generateStaticAutoloader } from './generate-autoloader.js' import { scanCommandsDir } from './scan-commands.js' const VIRTUAL_MODULE_ID = 'virtual:kidd-static-commands' +const RESOLVED_VIRTUAL_ID = `\0${VIRTUAL_MODULE_ID}` const AUTOLOADER_REGION_START = '//#region src/autoload.ts' const AUTOLOADER_REGION_END = '//#endregion' @@ -16,86 +15,67 @@ const AUTOLOADER_REGION_END = '//#endregion' interface CreateAutoloadPluginParams { readonly commandsDir: string readonly tagModulePath: string - readonly coreDistDir: string } /** - * Create a Bun plugin that replaces the runtime autoloader with a static version. + * Create a rolldown plugin that replaces the runtime autoloader with a static version. + * + * Uses a three-hook approach to break the circular dependency between kidd's + * dist and user command files (which `import { command } from '@kidd-cli/core'`): * - * Uses three hooks to break the circular dependency between kidd's dist and - * user command files (which `import { command } from '@kidd-cli/core'`): + * 1. `transform` — detects kidd's pre-bundled dist and replaces the autoloader + * region with a dynamic `import()` to a virtual module + * 2. `resolveId` — resolves the virtual module identifier + * 3. `load` — scans the commands directory and generates a static autoloader + * module with all command imports pre-resolved * - * 1. `onLoad` (core dist filter) — detects kidd's pre-bundled dist and replaces - * the autoloader region with a dynamic `import()` to a virtual module - * 2. `onResolve` — resolves the virtual module identifier - * 3. `onLoad` (kidd-autoload namespace) — scans the commands directory and - * generates a static autoloader module with all command imports pre-resolved + * The dynamic import ensures command files execute after kidd's code is fully + * initialized, avoiding `ReferenceError` from accessing `TAG` before its + * declaration. * - * @param params - The commands directory, tag module path, and core dist directory. - * @returns A BunPlugin for static autoloading. + * @param params - The commands directory and tag module path. + * @returns A rolldown plugin for static autoloading. */ -export function createAutoloadPlugin(params: CreateAutoloadPluginParams): BunPlugin { - const dirEscaped = params.coreDistDir.replaceAll('.', '\\.').replaceAll('/', '\\/') - // oxlint-disable-next-line security/detect-non-literal-regexp - const coreDistFilter = new RegExp(`${dirEscaped}\\/[^/]+\\.js$`) - +export function createAutoloadPlugin(params: CreateAutoloadPluginParams): Rolldown.Plugin { return { - name: 'kidd-static-autoloader', - setup(build) { - build.onResolve({ filter: /^virtual:kidd-static-commands$/ }, () => ({ - namespace: 'kidd-autoload', - path: VIRTUAL_MODULE_ID, - })) + async load(id) { + if (id !== RESOLVED_VIRTUAL_ID) { + return null + } - build.onLoad({ filter: /.*/, namespace: 'kidd-autoload' }, async () => { - const scan = await scanCommandsDir(params.commandsDir) - const contents = generateStaticAutoloader({ - scan, - tagModulePath: params.tagModulePath, - }) + const scan = await scanCommandsDir(params.commandsDir) - return { contents, loader: 'js' } - }) - - build.onLoad({ filter: coreDistFilter }, (args) => { - const code = readFileSync(args.path, 'utf-8') - const transformed = transformAutoloaderRegion(code) - - if (!transformed) { - return undefined - } - - return { contents: transformed, loader: 'js' } + return generateStaticAutoloader({ + scan, + tagModulePath: params.tagModulePath, }) }, - } -} - -// --------------------------------------------------------------------------- - -/** - * Replace the autoloader region in kidd's dist with a static import delegation. - * - * @private - * @param code - The source code to transform. - * @returns The transformed code, or `undefined` if no region markers were found. - */ -function transformAutoloaderRegion(code: string): string | undefined { - const regionStart = code.indexOf(AUTOLOADER_REGION_START) - if (regionStart === -1) { - return undefined - } + name: 'kidd-static-autoloader', + resolveId(source) { + if (source === VIRTUAL_MODULE_ID) { + return RESOLVED_VIRTUAL_ID + } - const regionEnd = code.indexOf(AUTOLOADER_REGION_END, regionStart) - if (regionEnd === -1) { - return undefined + return null + }, + transform(code, _id) { + const regionStart = code.indexOf(AUTOLOADER_REGION_START) + if (regionStart === -1) { + return null + } + + const regionEnd = code.indexOf(AUTOLOADER_REGION_END, regionStart) + if (regionEnd === -1) { + return null + } + + const before = code.slice(0, regionStart) + const after = code.slice(regionEnd + AUTOLOADER_REGION_END.length) + const staticRegion = buildStaticRegion() + + return `${before}${staticRegion}${after}` + }, } - - const before = code.slice(0, regionStart) - const after = code.slice(regionEnd + AUTOLOADER_REGION_END.length) - const staticRegion = buildStaticRegion() - - return `${before}${staticRegion}${after}` } /** diff --git a/packages/bundler/src/autoloader/scan-commands.ts b/packages/bundler/src/autoloader/scan-commands.ts index b9d439cf..c4212670 100644 --- a/packages/bundler/src/autoloader/scan-commands.ts +++ b/packages/bundler/src/autoloader/scan-commands.ts @@ -29,8 +29,6 @@ export async function scanCommandsDir(dir: string): Promise { return { dirs, files } } -// --------------------------------------------------------------------------- - /** * Recursively scan a subdirectory into a ScannedDir. * diff --git a/packages/bundler/src/build/build.test.ts b/packages/bundler/src/build/build.test.ts index 68f3f073..a1bfb7ab 100644 --- a/packages/bundler/src/build/build.test.ts +++ b/packages/bundler/src/build/build.test.ts @@ -1,34 +1,19 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -vi.mock(import('node:child_process')) -vi.mock(import('node:fs/promises')) -vi.mock(import('../config/read-version.js')) -vi.mock(import('../config/resolve-config.js')) -vi.mock(import('./bun-config.js')) -vi.mock(import('./clean.js')) - -const { execFile } = await import('node:child_process') -const { readFile, writeFile, unlink } = await import('node:fs/promises') -const { readVersion } = await import('../config/read-version.js') -const { resolveConfig } = await import('../config/resolve-config.js') -const { buildRunnerConfig } = await import('./bun-config.js') +const mockFsExists = vi.fn() + +vi.mock(import('@kidd-cli/utils/node'), () => ({ + fs: { exists: mockFsExists }, +})) + +vi.mock(import('tsdown')) + +const { build: tsdownBuild } = await import('tsdown') const { build } = await import('./build.js') -const mockExecFile = vi.mocked(execFile) -const mockReadFile = vi.mocked(readFile) -const mockWriteFile = vi.mocked(writeFile) -const mockUnlink = vi.mocked(unlink) -const mockReadVersion = vi.mocked(readVersion) -const mockResolveConfig = vi.mocked(resolveConfig) -const mockBuildRunnerConfig = vi.mocked(buildRunnerConfig) - -const SUCCESS_STDOUT = JSON.stringify({ - success: true, - entryFile: '/project/dist/index.js', - errors: [], -}) +const mockTsdownBuild = vi.mocked(tsdownBuild) -const RESOLVED_CONFIG = { +const resolved = { entry: '/project/src/index.ts', commands: '/project/commands', buildOutDir: '/project/dist', @@ -40,136 +25,91 @@ const RESOLVED_CONFIG = { external: [], clean: false, }, - compile: { - targets: [], - name: 'cli', - }, + compile: { targets: [], name: 'cli' }, include: [], cwd: '/project', + version: '1.0.0', } as const -function mockExecFileSuccess(stdout: string = SUCCESS_STDOUT) { - mockExecFile.mockImplementation( - // @ts-expect-error -- callback signature mismatch with overloaded execFile - (_cmd: string, _args: string[], cb: (err: Error | null, stdout: string, stderr: string) => void) => { - cb(null, stdout, '') - }, - ) -} - -function mockExecFileFailure(error: Error) { - mockExecFile.mockImplementation( - // @ts-expect-error -- callback signature mismatch with overloaded execFile - (_cmd: string, _args: string[], cb: (err: Error | null, stdout: string, stderr: string) => void) => { - cb(error, '', '') - }, - ) -} - beforeEach(() => { vi.clearAllMocks() - mockReadVersion.mockResolvedValue([null, '1.0.0']) - mockResolveConfig.mockReturnValue(RESOLVED_CONFIG) - mockBuildRunnerConfig.mockReturnValue({ - entry: '/project/src/index.ts', - outDir: '/project/dist', - commandsDir: '/project/commands', - tagModulePath: '/fake/tag.js', - coreDistDir: '/fake/core', - minify: false, - sourcemap: true, - target: 'node18', - define: {}, - external: [], - compile: false, - stubPackages: [], - alwaysBundlePatterns: [], - nodeBuiltins: [], - }) - // @ts-expect-error -- writeFile overload signature mismatch - mockWriteFile.mockResolvedValue(undefined) - mockUnlink.mockResolvedValue(undefined) }) describe('build operation', () => { it('should return ok with build output on success', async () => { - mockExecFileSuccess() - // @ts-expect-error -- readFile overload signature mismatch - mockReadFile.mockResolvedValueOnce('console.log("hello")') + mockTsdownBuild.mockResolvedValueOnce([]) + mockFsExists.mockImplementation((p: string) => Promise.resolve(p.endsWith('index.mjs'))) - const [error, output] = await build({ config: {}, cwd: '/project' }) + const [error, output] = await build({ resolved, compile: false }) expect(error).toBeNull() expect(output).toMatchObject({ - entryFile: '/project/dist/index.js', - outDir: '/project/dist', + entryFile: expect.stringMatching(/index\.mjs$/), + outDir: expect.stringContaining('dist'), version: '1.0.0', }) }) - it('should return err with Error on bun build failure', async () => { - mockExecFileFailure(new Error('bun crashed')) - - const [error, output] = await build({ config: {}, cwd: '/project' }) + it('should return err with Error on tsdown failure', async () => { + mockTsdownBuild.mockRejectedValueOnce(new Error('tsdown crashed')) + const [error, output] = await build({ resolved, compile: false }) expect(output).toBeNull() expect(error).toBeInstanceOf(Error) - expect(error).toMatchObject({ - message: expect.stringContaining('failed to parse bun build result'), - cause: expect.objectContaining({ message: 'bun crashed' }), - }) + expect(error).toMatchObject({ message: expect.stringContaining('tsdown build failed') }) }) it('should return err when no entry file is produced', async () => { - const stdout = JSON.stringify({ - success: true, - entryFile: undefined, - errors: [], - }) - mockExecFileSuccess(stdout) + mockTsdownBuild.mockResolvedValueOnce([]) + mockFsExists.mockResolvedValue(false) - const [error, output] = await build({ config: {}, cwd: '/project' }) + const [error, output] = await build({ resolved, compile: false }) expect(output).toBeNull() expect(error).toBeInstanceOf(Error) expect(error).toMatchObject({ message: expect.stringContaining('no entry file') }) }) - it('should include version in build output', async () => { - mockReadVersion.mockResolvedValueOnce([null, '2.5.0']) - mockExecFileSuccess() - // @ts-expect-error -- readFile overload signature mismatch - mockReadFile.mockResolvedValueOnce('console.log("hello")') + it('should pass inline config to tsdown build', async () => { + mockTsdownBuild.mockResolvedValueOnce([]) + mockFsExists.mockImplementation((p: string) => Promise.resolve(p.endsWith('index.mjs'))) + + const minifyResolved = { ...resolved, build: { ...resolved.build, minify: true } } + await build({ resolved: minifyResolved, compile: false }) + + expect(mockTsdownBuild).toHaveBeenCalledWith(expect.objectContaining({ minify: true })) + }) + + it('should include version from resolved config in build output', async () => { + mockTsdownBuild.mockResolvedValueOnce([]) + mockFsExists.mockImplementation((p: string) => Promise.resolve(p.endsWith('index.mjs'))) - const [, output] = await build({ config: {}, cwd: '/project' }) + const versionResolved = { ...resolved, version: '2.5.0' } + const [, output] = await build({ resolved: versionResolved, compile: false }) expect(output).toMatchObject({ version: '2.5.0' }) }) - it('should continue with undefined version when readVersion fails', async () => { - mockReadVersion.mockResolvedValueOnce([new Error('ENOENT'), null]) - mockExecFileSuccess() - // @ts-expect-error -- readFile overload signature mismatch - mockReadFile.mockResolvedValueOnce('console.log("hello")') + it('should handle undefined version in resolved config', async () => { + mockTsdownBuild.mockResolvedValueOnce([]) + mockFsExists.mockImplementation((p: string) => Promise.resolve(p.endsWith('index.mjs'))) - const [error, output] = await build({ config: {}, cwd: '/project' }) + const noVersionResolved = { ...resolved, version: undefined } + const [error, output] = await build({ resolved: noVersionResolved, compile: false }) expect(error).toBeNull() expect(output).toHaveProperty('version', undefined) }) - it('should prepend shebang to entry file on success', async () => { - const originalContent = 'console.log("hello")' - mockExecFileSuccess() - // @ts-expect-error -- readFile overload signature mismatch - mockReadFile.mockResolvedValueOnce(originalContent) + it('should inject __KIDD_VERSION__ define when version is available', async () => { + mockTsdownBuild.mockResolvedValueOnce([]) + mockFsExists.mockImplementation((p: string) => Promise.resolve(p.endsWith('index.mjs'))) - await build({ config: {}, cwd: '/project' }) + const versionResolved = { ...resolved, version: '4.0.0' } + await build({ resolved: versionResolved, compile: false }) - expect(mockWriteFile).toHaveBeenCalledWith( - '/project/dist/index.js', - `#!/usr/bin/env node\n${originalContent}`, - 'utf-8', + expect(mockTsdownBuild).toHaveBeenCalledWith( + expect.objectContaining({ define: { __KIDD_VERSION__: '"4.0.0"' } }) ) }) }) diff --git a/packages/bundler/src/build/build.ts b/packages/bundler/src/build/build.ts index 1fee37e3..61bfdddb 100644 --- a/packages/bundler/src/build/build.ts +++ b/packages/bundler/src/build/build.ts @@ -1,203 +1,49 @@ -import { execFile as execFileCb } from 'node:child_process' -import { randomUUID } from 'node:crypto' -import { readFile, unlink, writeFile } from 'node:fs/promises' -import { dirname, join } from 'node:path' -import { tmpdir } from 'node:os' -import { fileURLToPath } from 'node:url' - import { err, ok } from '@kidd-cli/utils/fp' -import type { AsyncResult, Result } from '@kidd-cli/utils/fp' -import { attempt, attemptAsync } from 'es-toolkit' -import { z } from 'zod' - -import { buildRunnerConfig } from './bun-config.js' -import type { BunRunnerConfig } from './bun-config.js' -import { cleanBuildArtifacts } from './clean.js' -import { readVersion } from '../config/read-version.js' -import { resolveConfig } from '../config/resolve-config.js' -import { SHEBANG } from '../constants.js' -import type { AsyncBundlerResult, BuildOutput, BuildParams } from '../types.js' +import { attemptAsync } from 'es-toolkit' +import { build as tsdownBuild } from 'tsdown' -/** - * Schema for validating the JSON result from the Bun runner subprocess. - */ -const RunnerResultSchema = z.object({ - success: z.boolean(), - entryFile: z.string().optional(), - errors: z.array(z.string()), -}) - -/** - * Result returned from the Bun runner subprocess. - */ -type RunnerResult = z.infer +import { resolveBuildVars, toTsdownBuildConfig } from './config.js' +import { clean } from '../utils/clean.js' +import { resolveBuildEntry } from '../utils/resolve-build-entry.js' +import type { AsyncBundlerResult, BuildOutput, ResolvedBundlerConfig } from '../types.js' /** - * Build a kidd CLI tool using Bun.build via a subprocess. + * Run the tsdown build with a resolved config. * - * Resolves defaults, reads the project version from package.json, builds a - * serializable config, writes it to a temp file, and spawns `bun` to execute - * the runner script. When `clean` is enabled (the default), build artifacts - * are removed before bundling. A shebang is prepended to the output entry. + * Cleans artifacts when enabled, maps to a tsdown InlineConfig, and invokes + * tsdown's build API. * - * @param params - The build parameters including config and working directory. + * @param params - The resolved config and whether compile mode is active. * @returns A result tuple with build output on success or an Error on failure. */ -export async function build(params: BuildParams): AsyncBundlerResult { - const resolved = resolveConfig(params) - const compile = !!params.config.compile - - if (resolved.build.clean) { - const [cleanError] = attempt(() => - cleanBuildArtifacts({ compile, outDir: resolved.buildOutDir }) - ) - if (cleanError) { - return err( - new Error(`failed to clean build artifacts in ${resolved.buildOutDir}`, { cause: cleanError }) - ) - } +export async function build(params: { + readonly resolved: ResolvedBundlerConfig + readonly compile: boolean +}): AsyncBundlerResult { + if (params.resolved.build.clean) { + await clean({ resolved: params.resolved, compile: params.compile }) } - const [, versionResult] = await readVersion(params.cwd) - const version = versionResult ?? undefined - - const runnerConfig = buildRunnerConfig({ compile, config: resolved, version }) + const inlineConfig = toTsdownBuildConfig({ + compile: params.compile, + config: params.resolved, + }) - const [buildError, buildResult] = await spawnBunBuild(runnerConfig) + const [buildError] = await attemptAsync(() => tsdownBuild(inlineConfig)) if (buildError) { - return err(buildError) + return err(new Error('tsdown build failed', { cause: buildError })) } - if (!buildResult.entryFile) { - return err(new Error(`build produced no entry file in ${resolved.buildOutDir}`)) - } + const entryFile = await resolveBuildEntry(params.resolved.buildOutDir) - const [shebangError] = await prependShebang(buildResult.entryFile) - if (shebangError) { - return err(new Error('failed to prepend shebang to entry file', { cause: shebangError })) + if (!entryFile) { + return err(new Error(`build produced no entry file in ${params.resolved.buildOutDir}`)) } return ok({ - entryFile: buildResult.entryFile, - outDir: resolved.buildOutDir, - version, - }) -} - -// --------------------------------------------------------------------------- - -/** - * Spawn the Bun runner subprocess to perform the actual build. - * - * Writes the config to a temporary JSON file, resolves the runner script path, - * and executes `bun `. Parses the JSON result from stdout. - * - * @private - * @param config - The serializable runner config. - * @returns A result tuple with the parsed runner result or an Error. - */ -async function spawnBunBuild( - config: BunRunnerConfig -): AsyncBundlerResult { - const configPath = join(tmpdir(), `kidd-build-${randomUUID()}.json`) - const [writeConfigError] = await attemptAsync(() => - writeFile(configPath, JSON.stringify(config), 'utf-8') - ) - if (writeConfigError) { - return err(new Error(`failed to write bun config ${configPath}`, { cause: writeConfigError })) - } - - const runnerPath = join(dirname(fileURLToPath(import.meta.url)), 'bun-runner.js') - - const [execError, stdout] = await execBun([runnerPath, configPath]) - - await unlink(configPath).catch(() => undefined) - - const [parseError, parsed] = parseRunnerResult(stdout ?? '') - if (parseError) { - const cause = execError ?? parseError - return err(new Error('failed to parse bun build result', { cause })) - } - - if (!parsed.success) { - const message = parsed.errors.length > 0 - ? `bun build failed: ${parsed.errors.join(', ')}` - : 'bun build failed' - return err(new Error(message)) - } - - if (execError) { - return err(new Error('bun build failed', { cause: execError })) - } - - return ok(parsed) -} - -/** - * Prepend the Node.js shebang line to a built entry file. - * - * Bun.build has no `banner` option, so we read the file and prepend manually. - * - * @private - * @param filePath - Absolute path to the entry file. - * @returns A result tuple with void on success or an Error on failure. - */ -async function prependShebang(filePath: string): AsyncResult { - const [readError, contents] = await attemptAsync(() => readFile(filePath, 'utf-8')) - if (readError) { - return err(readError) - } - - const [writeError] = await attemptAsync(() => writeFile(filePath, `${SHEBANG}${contents}`, 'utf-8')) - if (writeError) { - return err(writeError) - } - - return ok() -} - -/** - * Parse and validate the JSON result from the runner subprocess stdout. - * - * @private - * @param stdout - The raw stdout string from the subprocess. - * @returns A result tuple with the validated runner result or an Error. - */ -function parseRunnerResult(stdout: string): Result { - const [jsonError, json] = attempt(() => JSON.parse(stdout) as unknown) - if (jsonError) { - return err(new Error('failed to parse runner JSON', { cause: jsonError })) - } - - const validated = RunnerResultSchema.safeParse(json) - if (!validated.success) { - return err(new Error('invalid runner result shape', { cause: validated.error })) - } - - return ok(validated.data) -} - -/** - * Promisified wrapper around `execFile` to invoke `bun`. - * - * Always returns stdout so the caller can attempt to parse structured output - * even when the subprocess exits with a non-zero code. - * - * @private - * @param args - Arguments to pass to `bun`. - * @returns A tuple of `[error | null, stdout]` where stdout is always present. - */ -function execBun(args: readonly string[]): Promise { - return new Promise((resolve) => { - execFileCb('bun', [...args], (error, stdout, stderr) => { - if (error) { - const enriched = new Error(error.message, { cause: error }) - Object.defineProperty(enriched, 'stderr', { enumerable: true, value: stderr }) - resolve([enriched, stdout ?? '']) - return - } - - resolve([null, stdout]) - }) + define: { ...resolveBuildVars(), ...params.resolved.build.define }, + entryFile, + outDir: params.resolved.buildOutDir, + version: params.resolved.version, }) } diff --git a/packages/bundler/src/build/bun-config.test.ts b/packages/bundler/src/build/bun-config.test.ts deleted file mode 100644 index b990c0c2..00000000 --- a/packages/bundler/src/build/bun-config.test.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' - -vi.mock(import('node:module'), async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - createRequire: vi.fn(() => ({ - resolve: vi.fn((id: string) => { - if (id === '@kidd-cli/utils/tag') { - return '/resolved/tag/index.js' - } - if (id === '@kidd-cli/core') { - return '/resolved/core/dist/index.js' - } - return id - }), - })), - } -}) - -const { buildRunnerConfig } = await import('./bun-config.js') - -function makeResolvedConfig(): Parameters[0]['config'] { - return { - entry: '/project/src/index.ts', - commands: '/project/src/commands', - buildOutDir: '/project/dist', - compileOutDir: '/project/dist/bin', - build: { - target: 'node18', - minify: true, - sourcemap: true, - external: ['pg'], - clean: true, - }, - compile: { - targets: ['darwin-arm64' as const], - name: 'cli', - }, - include: [], - cwd: '/project', - } -} - -describe('buildRunnerConfig', () => { - it('should return a config with all required fields', () => { - const result = buildRunnerConfig({ - config: makeResolvedConfig(), - version: '1.0.0', - compile: false, - }) - - expect(result.entry).toBe('/project/src/index.ts') - expect(result.outDir).toBe('/project/dist') - expect(result.commandsDir).toBe('/project/src/commands') - expect(result.minify).toBe(true) - expect(result.sourcemap).toBe(true) - expect(result.target).toBe('node18') - expect(result.compile).toBe(false) - expect(result.external).toEqual(['pg']) - }) - - it('should define __KIDD_VERSION__ when version is provided', () => { - const result = buildRunnerConfig({ - config: makeResolvedConfig(), - version: '2.3.4', - compile: false, - }) - - expect(result.define).toEqual({ __KIDD_VERSION__: '"2.3.4"' }) - }) - - it('should return empty define when version is undefined', () => { - const result = buildRunnerConfig({ - config: makeResolvedConfig(), - version: undefined, - compile: false, - }) - - expect(result.define).toEqual({}) - }) - - it('should resolve tag module path and core dist dir', () => { - const result = buildRunnerConfig({ - config: makeResolvedConfig(), - version: '1.0.0', - compile: true, - }) - - expect(result.tagModulePath).toBe('/resolved/tag/index.js') - expect(result.coreDistDir).toBe('/resolved/core/dist') - }) - - it('should convert ALWAYS_BUNDLE regexes to source strings', () => { - const result = buildRunnerConfig({ - config: makeResolvedConfig(), - version: '1.0.0', - compile: false, - }) - - expect(result.alwaysBundlePatterns.length).toBeGreaterThan(0) - expect(result.alwaysBundlePatterns.every((pattern) => typeof pattern === 'string')).toBe(true) - }) - - it('should include node builtins', () => { - const result = buildRunnerConfig({ - config: makeResolvedConfig(), - version: '1.0.0', - compile: false, - }) - - expect(result.nodeBuiltins.length).toBeGreaterThan(0) - expect(result.nodeBuiltins).toContain('fs') - expect(result.nodeBuiltins).toContain('node:fs') - }) - - it('should include stub packages', () => { - const result = buildRunnerConfig({ - config: makeResolvedConfig(), - version: '1.0.0', - compile: false, - }) - - expect(result.stubPackages.length).toBeGreaterThan(0) - expect(result.stubPackages).toContain('chokidar') - }) -}) diff --git a/packages/bundler/src/build/bun-config.ts b/packages/bundler/src/build/bun-config.ts deleted file mode 100644 index e87f4423..00000000 --- a/packages/bundler/src/build/bun-config.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { createRequire } from 'node:module' -import { dirname } from 'node:path' - -import { match } from 'ts-pattern' - -import { ALWAYS_BUNDLE, NODE_BUILTINS, STUB_PACKAGES } from '../constants.js' -import type { ResolvedBundlerConfig } from '../types.js' - -/** - * Serializable configuration passed to the Bun runner subprocess via a JSON temp file. - */ -export interface BunRunnerConfig { - readonly entry: string - readonly outDir: string - readonly commandsDir: string - readonly tagModulePath: string - readonly coreDistDir: string - readonly minify: boolean - readonly sourcemap: boolean - readonly target: string - readonly define: Readonly> - readonly external: readonly string[] - readonly compile: boolean - readonly stubPackages: readonly string[] - readonly alwaysBundlePatterns: readonly string[] - readonly nodeBuiltins: readonly string[] -} - -/** - * Build a serializable runner config from a resolved bundler config. - * - * Resolves module paths and converts RegExp patterns to strings so the config - * can be serialized as JSON for the Bun subprocess. - * - * @param params - The resolved config, version, and compile flag. - * @returns A fully serializable config for the Bun runner. - */ -export function buildRunnerConfig(params: { - readonly config: ResolvedBundlerConfig - readonly version?: string - readonly compile: boolean -}): BunRunnerConfig { - return { - alwaysBundlePatterns: ALWAYS_BUNDLE.map((re) => re.source), - compile: params.compile, - coreDistDir: resolveCoreDistDir(), - define: buildDefine(params.version), - entry: params.config.entry, - external: [...params.config.build.external], - minify: params.config.build.minify, - nodeBuiltins: [...NODE_BUILTINS], - outDir: params.config.buildOutDir, - commandsDir: params.config.commands, - sourcemap: params.config.build.sourcemap, - stubPackages: [...STUB_PACKAGES], - tagModulePath: resolveTagModulePath(), - target: params.config.build.target, - } -} - -// --------------------------------------------------------------------------- - -/** - * Build the `define` map for compile-time constants. - * - * @private - * @param version - The version string from package.json, or undefined. - * @returns A define map for Bun.build. - */ -function buildDefine(version: string | undefined): Record { - return match(version) - .with(undefined, () => ({})) - .otherwise((resolvedVersion) => ({ - __KIDD_VERSION__: JSON.stringify(resolvedVersion), - })) -} - -/** - * Resolve the absolute file path to the `@kidd-cli/utils/tag` module. - * - * @private - * @returns The absolute file path to the tag module. - */ -function resolveTagModulePath(): string { - const require = createRequire(import.meta.url) - return require.resolve('@kidd-cli/utils/tag') -} - -/** - * Resolve the absolute path to the `@kidd-cli/core` dist directory. - * - * The core package's tsdown output splits code across multiple chunk files - * (e.g. `cli-CirRkJ6N.js`). The autoload region marker may live in any chunk, - * so we return the dist directory rather than a single entry file. The autoload - * plugin uses this to match all files inside the directory. - * - * @private - * @returns The absolute directory path containing core dist files. - */ -function resolveCoreDistDir(): string { - const require = createRequire(import.meta.url) - const entry = require.resolve('@kidd-cli/core') - return dirname(entry) -} diff --git a/packages/bundler/src/build/bun-runner.ts b/packages/bundler/src/build/bun-runner.ts deleted file mode 100644 index c4c2828e..00000000 --- a/packages/bundler/src/build/bun-runner.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { readFileSync } from 'node:fs' - -import { attempt } from 'es-toolkit' - -import { createAutoloadPlugin } from '../autoloader/autoload-plugin.js' -import { createExternalsPlugin, createStubPlugin } from './plugins.js' -import type { BunRunnerConfig } from './bun-config.js' - -/** - * Result written to stdout by the runner. - */ -interface RunnerResult { - readonly success: boolean - readonly entryFile: string | undefined - readonly errors: readonly string[] -} - -/** - * Entry point for the Bun subprocess runner. - * - * Reads a JSON config file path from argv, constructs Bun plugins, invokes - * `Bun.build()`, and writes a JSON result to stdout. Exits with code 0 on - * success, 1 on failure. - * - * @private - */ -async function main(): Promise { - const configPath = process.argv[2] - if (!configPath) { - writeResult({ entryFile: undefined, errors: ['no config path provided'], success: false }) - process.exit(1) - } - - const [configParseError, config] = attempt( - () => JSON.parse(readFileSync(configPath, 'utf-8')) as BunRunnerConfig - ) - if (configParseError || !config) { - writeResult({ entryFile: undefined, errors: [`failed to parse config: ${String(configParseError)}`], success: false }) - process.exit(1) - return - } - - const plugins = [ - createAutoloadPlugin({ - commandsDir: config.commandsDir, - coreDistDir: config.coreDistDir, - tagModulePath: config.tagModulePath, - }), - createExternalsPlugin({ - alwaysBundlePatterns: config.alwaysBundlePatterns, - compile: config.compile, - external: config.external, - nodeBuiltins: config.nodeBuiltins, - }), - ...(config.compile ? [createStubPlugin(config.stubPackages)] : []), - ] - - const result = await Bun.build({ - define: { ...config.define }, - entrypoints: [config.entry], - external: [...config.nodeBuiltins, ...config.external], - minify: config.minify, - naming: '[dir]/[name].js', - outdir: config.outDir, - plugins, - sourcemap: config.sourcemap ? 'linked' : 'none', - splitting: false, - target: 'node', - }) - - if (!result.success) { - const errors = result.logs - .filter((log) => log.level === 'error') - .map((log) => log.message) - - writeResult({ entryFile: undefined, errors, success: false }) - process.exit(1) - } - - const entryOutput = result.outputs.find((o) => o.kind === 'entry-point') - const entryFile = entryOutput?.path - - writeResult({ entryFile, errors: [], success: true }) -} - -// --------------------------------------------------------------------------- - -/** - * Write a JSON result to stdout for the parent process to parse. - * - * @private - * @param result - The runner result to serialize. - */ -function writeResult(result: RunnerResult): void { - process.stdout.write(JSON.stringify(result)) -} - -await main() diff --git a/packages/bundler/src/build/clean.test.ts b/packages/bundler/src/build/clean.test.ts deleted file mode 100644 index 5ab2e493..00000000 --- a/packages/bundler/src/build/clean.test.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs' -import { join } from 'node:path' -import { tmpdir } from 'node:os' - -import { afterEach, beforeEach, describe, expect, it } from 'vitest' - -import { cleanBuildArtifacts, isBuildArtifact, isCompiledBinary } from './clean.js' - -describe('isBuildArtifact', () => { - it('should match .js files', () => { - expect(isBuildArtifact('index.js')).toBe(true) - }) - - it('should match .mjs files', () => { - expect(isBuildArtifact('index.mjs')).toBe(true) - }) - - it('should match .js.map files', () => { - expect(isBuildArtifact('index.js.map')).toBe(true) - }) - - it('should match .mjs.map files', () => { - expect(isBuildArtifact('index.mjs.map')).toBe(true) - }) - - it('should not match non-artifact files', () => { - expect(isBuildArtifact('README.md')).toBe(false) - }) - - it('should not match compiled binaries', () => { - expect(isBuildArtifact('cli-darwin-arm64')).toBe(false) - }) -}) - -describe('isCompiledBinary', () => { - it('should match extensionless files', () => { - expect(isCompiledBinary('cli-darwin-arm64')).toBe(true) - }) - - it('should match .exe files', () => { - expect(isCompiledBinary('cli-windows-x64.exe')).toBe(true) - }) - - it('should not match .js files', () => { - expect(isCompiledBinary('index.js')).toBe(false) - }) - - it('should not match .md files', () => { - expect(isCompiledBinary('README.md')).toBe(false) - }) -}) - -describe('cleanBuildArtifacts', () => { - const testDir = join(tmpdir(), `kidd-clean-test-${Date.now()}`) - - beforeEach(() => { - mkdirSync(testDir, { recursive: true }) - }) - - afterEach(() => { - rmSync(testDir, { force: true, recursive: true }) - }) - - it('should return empty result for non-existent directory', () => { - const result = cleanBuildArtifacts({ outDir: '/non/existent/path' }) - - expect(result.removed).toStrictEqual([]) - expect(result.foreign).toStrictEqual([]) - }) - - it('should remove build artifacts', () => { - writeFileSync(join(testDir, 'index.js'), '') - writeFileSync(join(testDir, 'index.js.map'), '') - - const result = cleanBuildArtifacts({ outDir: testDir }) - - expect(result.removed).toContain('index.js') - expect(result.removed).toContain('index.js.map') - expect(existsSync(join(testDir, 'index.js'))).toBe(false) - expect(existsSync(join(testDir, 'index.js.map'))).toBe(false) - }) - - it('should preserve foreign files and report them', () => { - writeFileSync(join(testDir, 'index.js'), '') - writeFileSync(join(testDir, 'README.md'), '') - writeFileSync(join(testDir, 'cli-darwin-arm64'), '') - - const result = cleanBuildArtifacts({ outDir: testDir }) - - expect(result.removed).toContain('index.js') - expect(result.foreign).toContain('README.md') - expect(result.foreign).toContain('cli-darwin-arm64') - expect(existsSync(join(testDir, 'README.md'))).toBe(true) - expect(existsSync(join(testDir, 'cli-darwin-arm64'))).toBe(true) - }) - - it('should remove compiled binaries when compile is true', () => { - writeFileSync(join(testDir, 'index.mjs'), '') - writeFileSync(join(testDir, 'cli-darwin-arm64'), '') - writeFileSync(join(testDir, 'cli-linux-x64'), '') - writeFileSync(join(testDir, 'cli-windows-x64.exe'), '') - writeFileSync(join(testDir, 'README.md'), '') - - const result = cleanBuildArtifacts({ compile: true, outDir: testDir }) - - expect(result.removed).toContain('index.mjs') - expect(result.removed).toContain('cli-darwin-arm64') - expect(result.removed).toContain('cli-linux-x64') - expect(result.removed).toContain('cli-windows-x64.exe') - expect(result.foreign).toContain('README.md') - expect(existsSync(join(testDir, 'index.mjs'))).toBe(false) - expect(existsSync(join(testDir, 'cli-darwin-arm64'))).toBe(false) - expect(existsSync(join(testDir, 'cli-linux-x64'))).toBe(false) - expect(existsSync(join(testDir, 'cli-windows-x64.exe'))).toBe(false) - expect(existsSync(join(testDir, 'README.md'))).toBe(true) - }) - - it('should return empty result for empty directory', () => { - const result = cleanBuildArtifacts({ outDir: testDir }) - - expect(result.removed).toStrictEqual([]) - expect(result.foreign).toStrictEqual([]) - }) -}) diff --git a/packages/bundler/src/build/clean.ts b/packages/bundler/src/build/clean.ts deleted file mode 100644 index 9ca2d4b3..00000000 --- a/packages/bundler/src/build/clean.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { existsSync, readdirSync, rmSync } from 'node:fs' -import { extname, join } from 'node:path' - -import { BUILD_ARTIFACT_EXTENSIONS } from '../constants.js' - -/** - * Result of a targeted clean operation. - */ -export interface CleanResult { - /** Files that were removed. */ - readonly removed: readonly string[] - /** Files that were not removed because they are not build artifacts. */ - readonly foreign: readonly string[] -} - -/** - * Check whether a filename matches a known build artifact extension. - * - * @param filename - The filename to check. - * @returns `true` when the file ends with a known build artifact extension. - */ -export function isBuildArtifact(filename: string): boolean { - return BUILD_ARTIFACT_EXTENSIONS.some((ext) => filename.endsWith(ext)) -} - -/** - * Check whether a filename looks like a compiled binary. - * - * Compiled binaries are either extensionless (unix) or `.exe` (windows). - * - * @param filename - The filename to check. - * @returns `true` when the file has no extension or ends with `.exe`. - */ -export function isCompiledBinary(filename: string): boolean { - const ext = extname(filename) - return ext === '' || ext === '.exe' -} - -/** - * Remove kidd build artifacts (and compiled binaries when enabled) from the - * output directory. - * - * Unlike a blanket `clean: true` which deletes the entire output - * directory, this function targets only files with known build artifact - * extensions (`.js`, `.mjs`, `.js.map`, `.mjs.map`). When `compile` is - * true, compiled binaries (extensionless or `.exe`) are also removed. - * - * Only regular files and symbolic links are considered for removal. - * Directories are always treated as foreign entries. - * - * @param params - The output directory and whether compile mode is active. - * @returns A {@link CleanResult} describing what was removed and what was skipped. - */ -export function cleanBuildArtifacts(params: { - readonly outDir: string - readonly compile?: boolean -}): CleanResult { - if (!existsSync(params.outDir)) { - return { foreign: [], removed: [] } - } - - const entries = readdirSync(params.outDir, { withFileTypes: true }) - - return entries.reduce<{ readonly removed: string[]; readonly foreign: string[] }>( - (acc, entry) => { - const name = entry.name - const isRemovable = entry.isFile() || entry.isSymbolicLink() - const isArtifact = isBuildArtifact(name) || (!!params.compile && isCompiledBinary(name)) - if (isRemovable && isArtifact) { - rmSync(join(params.outDir, name), { force: true }) - return { ...acc, removed: [...acc.removed, name] } - } - return { ...acc, foreign: [...acc.foreign, name] } - }, - { foreign: [], removed: [] }, - ) -} diff --git a/packages/bundler/src/build/config.test.ts b/packages/bundler/src/build/config.test.ts new file mode 100644 index 00000000..27352ecc --- /dev/null +++ b/packages/bundler/src/build/config.test.ts @@ -0,0 +1,188 @@ +import { describe, expect, it, vi } from 'vitest' + +import { NODE_BUILTINS, SHEBANG } from '../constants.js' +import { toTsdownBuildConfig, toTsdownWatchConfig } from './config.js' +import type { ResolvedBundlerConfig } from '../types.js' + +const config: ResolvedBundlerConfig = { + build: { + clean: true, + define: {}, + external: ['pg'], + minify: false, + sourcemap: true, + target: 'node18', + }, + buildOutDir: '/project/dist', + commands: '/project/commands', + compile: { + name: 'my-cli', + targets: [], + }, + compileOutDir: '/project/dist', + cwd: '/project', + entry: '/project/src/index.ts', + include: [], + version: undefined, +} + +describe('build config mapping', () => { + const result = toTsdownBuildConfig({ config }) + + it('should set entry as object with index key', () => { + expect(result.entry).toStrictEqual({ index: '/project/src/index.ts' }) + }) + + it('should set format to esm', () => { + expect(result.format).toBe('esm') + }) + + it('should set platform to node', () => { + expect(result.platform).toBe('node') + }) + + it('should set outDir from resolved config', () => { + expect(result.outDir).toBe('/project/dist') + }) + + it('should set target from resolved config', () => { + expect(result.target).toBe('node18') + }) + + it('should set sourcemap from resolved config', () => { + expect(result.sourcemap).toBeTruthy() + }) + + it('should set minify from resolved config', () => { + expect(result.minify).toBeFalsy() + }) + + it('should prepend shebang as banner', () => { + expect(result.banner).toBe(SHEBANG) + }) + + it('should disable config file loading', () => { + expect(result.config).toBeFalsy() + }) + + it('should disable tsdown clean (kidd handles cleaning) and enable treeshake', () => { + expect(result.clean).toBeFalsy() + expect(result.treeshake).toBeTruthy() + }) + + it('should disable dts', () => { + expect(result.dts).toBeFalsy() + }) + + it('should set logLevel to silent', () => { + expect(result.logLevel).toBe('silent') + }) + + it('should disable code splitting for single-file output', () => { + const outputOpts = result.outputOptions as { codeSplitting: boolean } + expect(outputOpts.codeSplitting).toBeFalsy() + }) + + it('should prefer ESM module entry via mainFields', () => { + const inputOpts = result.inputOptions as { resolve: { mainFields: string[] } } + expect(inputOpts.resolve.mainFields).toStrictEqual(['module', 'main']) + }) + + it('should set cwd from resolved config', () => { + expect(result.cwd).toBe('/project') + }) + + it('should combine node builtins and user externals in deps.neverBundle', () => { + const neverBundle = result.deps as { neverBundle: (string | RegExp)[] } + expect(neverBundle.neverBundle).toContain('pg') + expect(neverBundle.neverBundle).toEqual(expect.arrayContaining(NODE_BUILTINS)) + }) + + it('should not include __KIDD_VERSION__ when version is undefined', () => { + const output = toTsdownBuildConfig({ config }) + expect(output.define).not.toHaveProperty('__KIDD_VERSION__') + }) + + it('should set __KIDD_VERSION__ define when version is provided', () => { + const configWithVersion = { ...config, version: '3.2.1' } + const output = toTsdownBuildConfig({ config: configWithVersion }) + expect(output.define).toMatchObject({ __KIDD_VERSION__: '"3.2.1"' }) + }) + + it('should merge user define into the define map', () => { + const configWithDefine = { + ...config, + build: { ...config.build, define: { __MY_KEY__: '"abc"' } }, + } + const output = toTsdownBuildConfig({ config: configWithDefine }) + expect(output.define).toMatchObject({ __MY_KEY__: '"abc"' }) + }) + + it('should resolve KIDD_PUBLIC_* env vars into define map', () => { + process.env.KIDD_PUBLIC_TEST_KEY = 'test-value' + + const output = toTsdownBuildConfig({ config }) + expect(output.define).toMatchObject({ + 'process.env.KIDD_PUBLIC_TEST_KEY': '"test-value"', + }) + + delete process.env.KIDD_PUBLIC_TEST_KEY + }) + + it('should let user define override KIDD_PUBLIC_* env vars', () => { + process.env.KIDD_PUBLIC_TEST_KEY = 'env-value' + + const configWithDefine = { + ...config, + build: { + ...config.build, + define: { 'process.env.KIDD_PUBLIC_TEST_KEY': '"override"' }, + }, + } + const output = toTsdownBuildConfig({ config: configWithDefine }) + expect(output.define).toMatchObject({ + 'process.env.KIDD_PUBLIC_TEST_KEY': '"override"', + }) + + delete process.env.KIDD_PUBLIC_TEST_KEY + }) + + it('should bundle all deps and add stub plugin when compile is true', () => { + const output = toTsdownBuildConfig({ config, compile: true }) + const deps = output.deps as { alwaysBundle: RegExp[] } + expect(deps.alwaysBundle).toStrictEqual([/./]) + const plugins = output.plugins as { name: string }[] + const pluginNames = plugins.map((p) => p.name) + expect(pluginNames).toContain('kidd-stub-packages') + }) +}) + +describe('watch config mapping', () => { + it('should spread build config and enable watch', () => { + const result = toTsdownWatchConfig({ config }) + expect(result.watch).toBeTruthy() + expect(result.format).toBe('esm') + }) + + it('should override logLevel to error for watch mode', () => { + const result = toTsdownWatchConfig({ config }) + expect(result.logLevel).toBe('error') + }) + + it('should pass through onSuccess callback', () => { + const onSuccess = vi.fn() + const result = toTsdownWatchConfig({ config, onSuccess }) + expect(result.onSuccess).toBe(onSuccess) + }) + + it('should leave onSuccess undefined when not provided', () => { + const result = toTsdownWatchConfig({ config }) + expect(result.onSuccess).toBeUndefined() + }) + + it('should pass version define through to build config', () => { + const configWithVersion = { ...config, version: '1.0.0' } + const result = toTsdownWatchConfig({ config: configWithVersion }) + expect(result.define).toMatchObject({ __KIDD_VERSION__: '"1.0.0"' }) + }) +}) diff --git a/packages/bundler/src/build/config.ts b/packages/bundler/src/build/config.ts new file mode 100644 index 00000000..b5d634f4 --- /dev/null +++ b/packages/bundler/src/build/config.ts @@ -0,0 +1,213 @@ +import { createRequire } from 'node:module' + +import { match } from 'ts-pattern' +import type { Rolldown } from 'tsdown' +import type { InlineConfig } from 'tsdown' + +import { createAutoloadPlugin } from '../autoloader/autoload-plugin.js' +import { ALWAYS_BUNDLE, NODE_BUILTINS, SHEBANG, STUB_PACKAGES } from '../constants.js' +import type { ResolvedBundlerConfig } from '../types.js' + +const TAG_MODULE_PATH = createRequire(import.meta.url).resolve('@kidd-cli/utils/tag') + +/** + * Convert a resolved bundler config to a tsdown InlineConfig for production builds. + * + * @param params - The resolved config and optional version for compile-time injection. + * @returns A tsdown InlineConfig ready for `build()`. + */ +export function toTsdownBuildConfig(params: { + readonly config: ResolvedBundlerConfig + readonly compile?: boolean +}): InlineConfig { + return { + banner: SHEBANG, + clean: false, + config: false, + cwd: params.config.cwd, + define: buildDefine({ + define: params.config.build.define, + version: params.config.version, + }), + deps: buildDeps(params.config.build.external, params.compile ?? false), + dts: false, + entry: { index: params.config.entry }, + format: 'esm', + inputOptions: { + resolve: { + mainFields: ['module', 'main'], + }, + }, + logLevel: 'silent', + minify: params.config.build.minify, + outDir: params.config.buildOutDir, + outputOptions: { + codeSplitting: false, + }, + platform: 'node', + plugins: [ + createAutoloadPlugin({ + commandsDir: params.config.commands, + tagModulePath: TAG_MODULE_PATH, + }), + ...buildPlugins(params.compile ?? false), + ], + sourcemap: params.config.build.sourcemap, + target: params.config.build.target, + treeshake: true, + } +} + +/** + * Convert a resolved bundler config to a tsdown InlineConfig for watch mode. + * + * @param params - The resolved config, optional version, and optional success callback. + * @returns A tsdown InlineConfig with `watch: true`. + */ +export function toTsdownWatchConfig(params: { + readonly config: ResolvedBundlerConfig + readonly onSuccess?: () => void | Promise +}): InlineConfig { + const buildConfig = toTsdownBuildConfig({ config: params.config }) + + return { + ...buildConfig, + logLevel: 'error', + onSuccess: params.onSuccess, + watch: true, + } +} + + +/** + * Build the `deps` configuration for tsdown. + * + * When compiling to a standalone binary, all dependencies must be inlined so + * `bun build --compile` never encounters unresolvable bare imports. Only + * Node.js builtins and explicit user externals are kept external. + * + * In normal (non-compile) mode, only `@kidd-cli/*` packages are force-bundled + * and everything else in `node_modules` is left external by tsdown's default. + * + * @private + * @param userExternals - Additional packages the user explicitly marked external. + * @param compile - Whether the build targets a compiled binary. + * @returns A tsdown `deps` configuration object. + */ +function buildDeps( + userExternals: readonly string[], + compile: boolean +): { alwaysBundle: RegExp[]; neverBundle: (string | RegExp)[] } { + const alwaysBundle = match(compile) + .with(true, () => [/./]) + .with(false, () => [...ALWAYS_BUNDLE]) + .exhaustive() + + return { + alwaysBundle, + neverBundle: [...NODE_BUILTINS, ...userExternals], + } +} + +/** + * Build the `define` map for tsdown/rolldown. + * + * Merges three sources (lowest to highest precedence): + * 1. `KIDD_PUBLIC_*` env vars — prefixed with `process.env.` for rolldown replacement + * 2. `__KIDD_VERSION__` — injected when a version string is available + * 3. Explicit `define` from `kidd.config.ts` — user overrides win + * + * @private + * @param params - The version and user-defined constants. + * @returns A define map for tsdown/rolldown. + */ +function buildDefine(params: { + readonly version: string | undefined + readonly define: Readonly> +}): Record { + const envVars = resolveBuildVars() + const envDefines = Object.fromEntries( + Object.entries(envVars).map(([key, value]) => [`process.env.${key}`, value]) + ) + + const versionDefine = match(params.version) + .with(undefined, () => ({})) + .otherwise((v) => ({ __KIDD_VERSION__: JSON.stringify(v) })) + + return { + ...envDefines, + ...versionDefine, + ...params.define, + } +} + +/** + * Resolve `KIDD_PUBLIC_*` environment variables into a define map. + * + * Scans `process.env` for keys prefixed with `KIDD_PUBLIC_` and returns + * clean key-value pairs with JSON-stringified values. + * + * @param env - The environment variables to scan (defaults to `process.env`). + * @returns A define map with clean keys (no `process.env.` prefix). + */ +export function resolveBuildVars( + env: Readonly> = process.env +): Record { + return Object.fromEntries( + Object.entries(env) + .filter(([key]) => key.startsWith('KIDD_PUBLIC_')) + .map(([key, value]) => [key, JSON.stringify(value ?? '')]) + ) +} + +/** + * Build additional plugins needed when compiling to a standalone binary. + * + * When `compile` is true, includes a stub plugin that replaces optional/conditional + * dependencies with empty modules so rolldown (and subsequently bun compile) never + * attempts to resolve packages that don't exist at runtime. + * + * @private + * @param compile - Whether the build targets a compiled binary. + * @returns An array of rolldown plugins (empty when not compiling). + */ +function buildPlugins(compile: boolean): Rolldown.Plugin[] { + return match(compile) + .with(true, () => [createStubPlugin(STUB_PACKAGES)]) + .with(false, () => []) + .exhaustive() +} + +/** + * Create a rolldown plugin that replaces specified packages with empty modules. + * + * Libraries like c12 and ink have optional/conditional dependencies behind + * dynamic `import()` calls or runtime guards (e.g. `isDev()`). When all deps + * are inlined for compile mode, rolldown traces into these statically. Stubbing + * at the resolve level ensures the real package is never loaded. + * + * @private + * @param packages - Package names to replace with empty modules. + * @returns A rolldown plugin. + */ +function createStubPlugin(packages: readonly string[]): Rolldown.Plugin { + const stubbed = new Set(packages) + const STUB_PREFIX = '\0stub:' + + return { + name: 'kidd-stub-packages', + resolveId(source) { + return match(stubbed.has(source)) + .with(true, () => `${STUB_PREFIX}${source}`) + .with(false, () => null) + .exhaustive() + }, + load(id) { + return match(id.startsWith(STUB_PREFIX)) + .with(true, () => 'export default undefined;') + .with(false, () => null) + .exhaustive() + }, + } +} + diff --git a/packages/bundler/src/build/plugins.test.ts b/packages/bundler/src/build/plugins.test.ts deleted file mode 100644 index a17e7fe6..00000000 --- a/packages/bundler/src/build/plugins.test.ts +++ /dev/null @@ -1,192 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' - -import { createExternalsPlugin, createStubPlugin } from './plugins.js' - -type ResolveCall = { readonly filter: RegExp; readonly fn: Function } -type LoadCall = { readonly filter: RegExp; readonly namespace: string | undefined; readonly fn: Function } - -function createMockBuild(): { - readonly mockBuild: Record - readonly onResolveCalls: ResolveCall[] - readonly onLoadCalls: LoadCall[] -} { - const onResolveCalls: ResolveCall[] = [] - const onLoadCalls: LoadCall[] = [] - const mockBuild = { - onResolve: vi.fn((opts: { filter: RegExp }, fn: Function) => { - onResolveCalls.push({ filter: opts.filter, fn }) - }), - onLoad: vi.fn( - (opts: { filter: RegExp; namespace?: string }, fn: Function) => { - onLoadCalls.push({ filter: opts.filter, namespace: opts.namespace, fn }) - }, - ), - } - - return { mockBuild, onResolveCalls, onLoadCalls } -} - -describe('createExternalsPlugin', () => { - const defaultParams = { - compile: false, - external: ['pg', 'better-sqlite3'], - alwaysBundlePatterns: ['^@?kidd'], - nodeBuiltins: ['fs', 'node:fs', 'path', 'node:path'], - } as const - - it('should return a BunPlugin with name kidd-externals', () => { - const plugin = createExternalsPlugin(defaultParams) - - expect(plugin.name).toBe('kidd-externals') - expect(plugin.setup).toBeTypeOf('function') - }) - - it('should pass through relative paths starting with dot', () => { - const plugin = createExternalsPlugin(defaultParams) - const { mockBuild, onResolveCalls } = createMockBuild() - - plugin.setup(mockBuild as never) - - const resolve = onResolveCalls[0].fn - const result = resolve({ path: './utils/helper.js' }) - - expect(result).toBeUndefined() - }) - - it('should pass through absolute paths starting with slash', () => { - const plugin = createExternalsPlugin(defaultParams) - const { mockBuild, onResolveCalls } = createMockBuild() - - plugin.setup(mockBuild as never) - - const resolve = onResolveCalls[0].fn - const result = resolve({ path: '/home/user/project/index.ts' }) - - expect(result).toBeUndefined() - }) - - it('should mark Node builtins as external', () => { - const plugin = createExternalsPlugin(defaultParams) - const { mockBuild, onResolveCalls } = createMockBuild() - - plugin.setup(mockBuild as never) - - const resolve = onResolveCalls[0].fn - - expect(resolve({ path: 'fs' })).toStrictEqual({ external: true, path: 'fs' }) - expect(resolve({ path: 'node:path' })).toStrictEqual({ external: true, path: 'node:path' }) - }) - - it('should mark user externals as external', () => { - const plugin = createExternalsPlugin(defaultParams) - const { mockBuild, onResolveCalls } = createMockBuild() - - plugin.setup(mockBuild as never) - - const resolve = onResolveCalls[0].fn - - expect(resolve({ path: 'pg' })).toStrictEqual({ external: true, path: 'pg' }) - expect(resolve({ path: 'better-sqlite3' })).toStrictEqual({ - external: true, - path: 'better-sqlite3', - }) - }) - - it('should mark bare specifiers as external in normal mode when not matching alwaysBundlePatterns', () => { - const plugin = createExternalsPlugin(defaultParams) - const { mockBuild, onResolveCalls } = createMockBuild() - - plugin.setup(mockBuild as never) - - const resolve = onResolveCalls[0].fn - - expect(resolve({ path: 'lodash' })).toStrictEqual({ external: true, path: 'lodash' }) - expect(resolve({ path: 'express' })).toStrictEqual({ external: true, path: 'express' }) - }) - - it('should pass through specifiers matching alwaysBundlePatterns in normal mode', () => { - const plugin = createExternalsPlugin(defaultParams) - const { mockBuild, onResolveCalls } = createMockBuild() - - plugin.setup(mockBuild as never) - - const resolve = onResolveCalls[0].fn - - expect(resolve({ path: '@kidd-cli/core' })).toBeUndefined() - expect(resolve({ path: 'kidd' })).toBeUndefined() - }) - - it('should not register any onResolve handler in compile mode', () => { - const plugin = createExternalsPlugin({ ...defaultParams, compile: true }) - const { mockBuild } = createMockBuild() - - plugin.setup(mockBuild as never) - - expect(mockBuild.onResolve).not.toHaveBeenCalled() - }) -}) - -describe('createStubPlugin', () => { - const packages = ['chokidar', 'magicast', 'react-devtools-core'] as const - - it('should return a BunPlugin with name kidd-stub-packages', () => { - const plugin = createStubPlugin(packages) - - expect(plugin.name).toBe('kidd-stub-packages') - expect(plugin.setup).toBeTypeOf('function') - }) - - it('should resolve matching packages to kidd-stub namespace', () => { - const plugin = createStubPlugin(packages) - const { mockBuild, onResolveCalls } = createMockBuild() - - plugin.setup(mockBuild as never) - - const resolve = onResolveCalls[0].fn - - expect(resolve({ path: 'chokidar' })).toStrictEqual({ - namespace: 'kidd-stub', - path: 'chokidar', - }) - expect(resolve({ path: 'magicast' })).toStrictEqual({ - namespace: 'kidd-stub', - path: 'magicast', - }) - }) - - it('should not intercept non-matching packages', () => { - const plugin = createStubPlugin(packages) - const { mockBuild, onResolveCalls } = createMockBuild() - - plugin.setup(mockBuild as never) - - const resolveFilter = onResolveCalls[0].filter - - expect(resolveFilter.test('lodash')).toBe(false) - expect(resolveFilter.test('express')).toBe(false) - }) - - it('should return stub contents from onLoad handler', () => { - const plugin = createStubPlugin(packages) - const { mockBuild, onLoadCalls } = createMockBuild() - - plugin.setup(mockBuild as never) - - const load = onLoadCalls[0].fn - const result = load({ path: 'chokidar' }) - - expect(result).toStrictEqual({ - contents: 'export default undefined;', - loader: 'js', - }) - }) - - it('should register onLoad handler with kidd-stub namespace', () => { - const plugin = createStubPlugin(packages) - const { mockBuild, onLoadCalls } = createMockBuild() - - plugin.setup(mockBuild as never) - - expect(onLoadCalls[0].namespace).toBe('kidd-stub') - }) -}) diff --git a/packages/bundler/src/build/plugins.ts b/packages/bundler/src/build/plugins.ts deleted file mode 100644 index efaba444..00000000 --- a/packages/bundler/src/build/plugins.ts +++ /dev/null @@ -1,99 +0,0 @@ -import type { BunPlugin } from 'bun' - -/** - * Create a Bun plugin that controls which modules are externalized. - * - * In normal mode: externalizes all bare specifiers EXCEPT those matching - * the kidd namespace (which must be bundled for the autoload plugin). - * - * In compile mode: relies on Bun's default bundling behavior (bundle everything) - * and only uses the `external` array passed to Bun.build for builtins/user externals. - * Returning `undefined` from `onResolve` in Bun causes modules to be treated as - * external, so compile mode must avoid registering a catch-all handler. - * - * @param params - The compile flag, user externals, and always-bundle patterns. - * @returns A BunPlugin that handles externalization. - */ -export function createExternalsPlugin(params: { - readonly compile: boolean - readonly external: readonly string[] - readonly alwaysBundlePatterns: readonly string[] - readonly nodeBuiltins: readonly string[] -}): BunPlugin { - const builtins = new Set(params.nodeBuiltins) - const userExternals = new Set(params.external) - const alwaysBundleRegexes = params.alwaysBundlePatterns.map((src) => new RegExp(src)) - - return { - name: 'kidd-externals', - setup(build) { - if (params.compile) { - return - } - - build.onResolve({ filter: /.*/ }, (args) => { - if (isRelativeOrAbsolute(args.path)) { - return undefined - } - - if (builtins.has(args.path)) { - return { external: true, path: args.path } - } - - if (userExternals.has(args.path)) { - return { external: true, path: args.path } - } - - const shouldBundle = alwaysBundleRegexes.some((re) => re.test(args.path)) - if (shouldBundle) { - return undefined - } - - return { external: true, path: args.path } - }) - }, - } -} - -/** - * Create a Bun plugin that replaces specified packages with empty modules. - * - * Libraries like c12 and ink have optional/conditional dependencies behind - * dynamic `import()` calls or runtime guards. Stubbing at the resolve level - * ensures the real package is never loaded during bundling. - * - * @param packages - Package names to replace with empty modules. - * @returns A BunPlugin that stubs the specified packages. - */ -export function createStubPlugin(packages: readonly string[]): BunPlugin { - const escaped = packages.map((pkg) => pkg.replaceAll('.', '\\.')) - const filter = new RegExp(`^(${escaped.join('|')})$`) - - return { - name: 'kidd-stub-packages', - setup(build) { - build.onResolve({ filter }, (args) => ({ - namespace: 'kidd-stub', - path: args.path, - })) - - build.onLoad({ filter: /.*/, namespace: 'kidd-stub' }, () => ({ - contents: 'export default undefined;', - loader: 'js', - })) - }, - } -} - -// --------------------------------------------------------------------------- - -/** - * Check whether a module specifier is relative or absolute (not a bare specifier). - * - * @private - * @param specifier - The module specifier to check. - * @returns `true` when the specifier starts with `.` or `/`. - */ -function isRelativeOrAbsolute(specifier: string): boolean { - return specifier.startsWith('.') || specifier.startsWith('/') -} diff --git a/packages/bundler/src/build/watch.test.ts b/packages/bundler/src/build/watch.test.ts index 40c0e061..65634f21 100644 --- a/packages/bundler/src/build/watch.test.ts +++ b/packages/bundler/src/build/watch.test.ts @@ -1,150 +1,75 @@ -import type { FSWatcher } from 'node:fs' - import { beforeEach, describe, expect, it, vi } from 'vitest' -vi.mock(import('node:fs')) -vi.mock(import('./build.js')) +vi.mock(import('tsdown')) -const { watch: fsWatch } = await import('node:fs') -const { build } = await import('./build.js') +const { build: tsdownBuild } = await import('tsdown') const { watch } = await import('./watch.js') -const mockBuild = vi.mocked(build) -const mockFsWatch = vi.mocked(fsWatch) - -type WatchCallback = (_eventType: string, filename: string | null) => void - -const createMockWatcher = (): { readonly watcher: FSWatcher; readonly close: ReturnType } => { - const close = vi.fn() - const watcher = { close } as unknown as FSWatcher - return { watcher, close } -} - -const captureWatchCallback = (): WatchCallback => { - const call = mockFsWatch.mock.calls[0] - return call[2] as WatchCallback -} +const mockTsdownBuild = vi.mocked(tsdownBuild) + +const resolved = { + entry: '/project/src/index.ts', + commands: '/project/commands', + buildOutDir: '/project/dist', + compileOutDir: '/project/dist', + build: { + target: 'node18', + minify: false, + sourcemap: true, + external: [], + clean: false, + }, + compile: { targets: [], name: 'cli' }, + include: [], + cwd: '/project', + version: '1.0.0', +} as const beforeEach(() => { vi.clearAllMocks() }) describe('watch operation', () => { - it('should return err when initial build fails', async () => { - const buildError = new Error('build failed') - mockBuild.mockResolvedValueOnce([buildError, null]) - - const [error, output] = await watch({ config: {}, cwd: '/project' }) - - expect(output).toBeNull() - expect(error).toBeInstanceOf(Error) - expect((error as Error).message).toContain('initial build failed') - }) - - it('should call onSuccess after initial build', async () => { - const { watcher } = createMockWatcher() - mockBuild.mockResolvedValueOnce([null, undefined]) - mockFsWatch.mockReturnValueOnce(watcher) - const onSuccess = vi.fn().mockResolvedValue(undefined) - - const promise = watch({ config: {}, cwd: '/project', onSuccess }) - - await vi.waitFor(() => { - expect(onSuccess).toHaveBeenCalledTimes(1) - }) - - process.emit('SIGINT', 'SIGINT') - const [error] = await promise - - expect(error).toBeNull() - }) - - it('should return ok after signal cleanup', async () => { - const { watcher, close } = createMockWatcher() - mockBuild.mockResolvedValueOnce([null, undefined]) - mockFsWatch.mockReturnValueOnce(watcher) - - const promise = watch({ config: {}, cwd: '/project' }) - - await vi.waitFor(() => { - expect(mockFsWatch).toHaveBeenCalledTimes(1) - }) - - process.emit('SIGINT', 'SIGINT') - const [error, output] = await promise + it('should return ok on success', async () => { + mockTsdownBuild.mockResolvedValueOnce([]) + const [error, output] = await watch({ resolved }) expect(error).toBeNull() expect(output).toBeUndefined() - expect(close).toHaveBeenCalledTimes(1) }) - it('should call build on file changes', async () => { - const { watcher } = createMockWatcher() - mockBuild.mockResolvedValue([null, undefined]) - mockFsWatch.mockReturnValueOnce(watcher) - - const promise = watch({ config: {}, cwd: '/project' }) - - await vi.waitFor(() => { - expect(mockFsWatch).toHaveBeenCalledTimes(1) - }) + it('should return err with Error on failure', async () => { + mockTsdownBuild.mockRejectedValueOnce(new Error('watch crashed')) + const [error, output] = await watch({ resolved }) - const callback = captureWatchCallback() - callback('change', 'src/index.ts') - - await vi.waitFor(() => { - expect(mockBuild).toHaveBeenCalledTimes(2) - }) - - process.emit('SIGINT', 'SIGINT') - await promise + expect(output).toBeNull() + expect(error).toBeInstanceOf(Error) + expect(error!.message).toContain('tsdown watch failed') }) - it('should ignore node_modules changes', async () => { - const { watcher } = createMockWatcher() - mockBuild.mockResolvedValue([null, undefined]) - mockFsWatch.mockReturnValueOnce(watcher) + it('should enable watch mode in tsdown config', async () => { + mockTsdownBuild.mockResolvedValueOnce([]) + await watch({ resolved }) - const promise = watch({ config: {}, cwd: '/project' }) - - await vi.waitFor(() => { - expect(mockFsWatch).toHaveBeenCalledTimes(1) - }) - - const callback = captureWatchCallback() - callback('change', 'node_modules/foo/index.js') - - await new Promise((resolve) => { - setTimeout(resolve, 300) - }) - - expect(mockBuild).toHaveBeenCalledTimes(1) - - process.emit('SIGINT', 'SIGINT') - await promise + expect(mockTsdownBuild).toHaveBeenCalledWith(expect.objectContaining({ watch: true })) }) - it('should ignore dist changes', async () => { - const { watcher } = createMockWatcher() - mockBuild.mockResolvedValue([null, undefined]) - mockFsWatch.mockReturnValueOnce(watcher) - - const promise = watch({ config: {}, cwd: '/project' }) + it('should pass onSuccess callback to tsdown config', async () => { + const onSuccess = vi.fn() + mockTsdownBuild.mockResolvedValueOnce([]) + await watch({ resolved, onSuccess }) - await vi.waitFor(() => { - expect(mockFsWatch).toHaveBeenCalledTimes(1) - }) - - const callback = captureWatchCallback() - callback('change', 'dist/index.js') + expect(mockTsdownBuild).toHaveBeenCalledWith(expect.objectContaining({ onSuccess })) + }) - await new Promise((resolve) => { - setTimeout(resolve, 300) - }) + it('should pass version define to tsdown config', async () => { + mockTsdownBuild.mockResolvedValueOnce([]) - expect(mockBuild).toHaveBeenCalledTimes(1) + const versionResolved = { ...resolved, version: '2.0.0' } + await watch({ resolved: versionResolved }) - process.emit('SIGINT', 'SIGINT') - await promise + expect(mockTsdownBuild).toHaveBeenCalledWith( + expect.objectContaining({ define: { __KIDD_VERSION__: '"2.0.0"' } }) + ) }) }) diff --git a/packages/bundler/src/build/watch.ts b/packages/bundler/src/build/watch.ts index 6c06e6dd..50b2ba37 100644 --- a/packages/bundler/src/build/watch.ts +++ b/packages/bundler/src/build/watch.ts @@ -1,80 +1,31 @@ -import { watch as fsWatch } from 'node:fs' - import { err, ok } from '@kidd-cli/utils/fp' -import { debounce } from 'es-toolkit' +import { attemptAsync } from 'es-toolkit' +import { build as tsdownBuild } from 'tsdown' -import { build } from './build.js' -import type { AsyncBundlerResult, WatchParams } from '../types.js' +import { toTsdownWatchConfig } from './config.js' +import type { AsyncBundlerResult, ResolvedBundlerConfig } from '../types.js' /** - * Debounce interval in milliseconds for batching rapid file changes. - */ -const DEBOUNCE_MS = 250 - -/** - * Start a watch-mode build for a kidd CLI tool. - * - * Runs an initial build, then watches the project directory for file changes - * using `node:fs.watch`. Changes are debounced to avoid excessive rebuilds. - * The `onSuccess` callback fires after each successful build. + * Start a watch-mode build using tsdown. * - * The returned promise resolves only when the process receives SIGINT or SIGTERM. + * The returned promise resolves only when tsdown's watch terminates (typically on process exit). * - * @param params - The watch parameters including config, working directory, and optional success callback. + * @param params - The resolved config and optional success callback. * @returns A result tuple with void on success or an Error on failure. */ -export async function watch(params: WatchParams): AsyncBundlerResult { - const [initialError] = await build({ config: params.config, cwd: params.cwd }) - if (initialError) { - return err(new Error('initial build failed', { cause: initialError })) - } - - if (params.onSuccess) { - await params.onSuccess() - } - - const rebuild = debounce(async () => { - const [rebuildError] = await build({ config: params.config, cwd: params.cwd }) - if (!rebuildError && params.onSuccess) { - await params.onSuccess() - } - }, DEBOUNCE_MS) - - const watcher = fsWatch(params.cwd, { recursive: true }, (_eventType, filename) => { - if (shouldIgnore(filename)) { - return - } - - rebuild() +export async function watch(params: { + readonly resolved: ResolvedBundlerConfig + readonly onSuccess?: () => void | Promise +}): AsyncBundlerResult { + const watchConfig = toTsdownWatchConfig({ + config: params.resolved, + onSuccess: params.onSuccess, }) - return new Promise((resolve) => { - const cleanup = () => { - watcher.close() - resolve(ok()) - } - - process.once('SIGINT', cleanup) - process.once('SIGTERM', cleanup) - }) -} - -// --------------------------------------------------------------------------- - -/** - * Check whether a changed file should be ignored by the watcher. - * - * Ignores changes inside `node_modules`, `dist`, and dotfile directories. - * - * @private - * @param filename - The relative filename from the watcher, or null. - * @returns `true` when the file change should be skipped. - */ -function shouldIgnore(filename: string | null): boolean { - if (!filename) { - return true + const [watchError] = await attemptAsync(() => tsdownBuild(watchConfig)) + if (watchError) { + return err(new Error('tsdown watch failed', { cause: watchError })) } - const segments = filename.split('/') - return segments.some((seg) => seg === 'node_modules' || seg === 'dist' || seg.startsWith('.')) + return ok() } diff --git a/packages/bundler/src/bundler.ts b/packages/bundler/src/bundler.ts new file mode 100644 index 00000000..537755a0 --- /dev/null +++ b/packages/bundler/src/bundler.ts @@ -0,0 +1,132 @@ +import { readManifest } from '@kidd-cli/utils/manifest' +import { isNil, noop } from 'es-toolkit' +import { match, P } from 'ts-pattern' + +import { build } from './build/build.js' +import { watch } from './build/watch.js' +import { compile } from './compile/compile.js' +import type { + AsyncBundlerResult, + BuildOutput, + Bundler, + BundlerLifecycle, + CompileOutput, + CompileOverrides, + CreateBundlerParams, + WatchOverrides, +} from './types.js' +import { resolveConfig } from './utils/resolve-config.js' + +/** + * Create a bundler instance for a kidd CLI project. + * + * Reads the project manifest once, resolves config, and returns methods + * that share the resolved state. Lifecycle hooks fire at phase boundaries. + * Per-call overrides replace base hooks for that invocation. + * + * @param params - The config, working directory, and optional lifecycle hooks. + * @returns A bundler with build, watch, and compile methods. + */ +export async function createBundler(params: CreateBundlerParams): Promise { + const [, manifest] = await readManifest(params.cwd) + const { version, name: packageName } = manifest ?? {} + + const binaryName = match(params.config.compile) + .with({ name: P.string }, (c) => c.name) + .otherwise(() => resolveBinaryName(packageName)) + + const resolved = resolveConfig({ + config: params.config, + cwd: params.cwd, + version, + binaryName, + }) + const hasCompile = !isNil(params.config.compile) + + const baseLifecycle: BundlerLifecycle = { + onFinish: params.onFinish, + onStart: params.onStart, + onStepFinish: params.onStepFinish, + onStepStart: params.onStepStart, + } + + return { + build: async (): AsyncBundlerResult => { + const lifecycle = resolveLifecycle(baseLifecycle) + await lifecycle.onStart({ phase: 'build' }) + const result = await build({ compile: hasCompile, resolved }) + await lifecycle.onFinish({ phase: 'build' }) + return result + }, + + watch: async (overrides: WatchOverrides = {}): AsyncBundlerResult => { + const lifecycle = resolveLifecycle(baseLifecycle, overrides) + await lifecycle.onStart({ phase: 'watch' }) + const result = await watch({ onSuccess: overrides.onSuccess, resolved }) + await lifecycle.onFinish({ phase: 'watch' }) + return result + }, + + compile: async (overrides: CompileOverrides = {}): AsyncBundlerResult => { + const lifecycle = resolveLifecycle(baseLifecycle, overrides) + await lifecycle.onStart({ phase: 'compile' }) + const result = await compile({ lifecycle, resolved, verbose: overrides.verbose }) + await lifecycle.onFinish({ phase: 'compile' }) + return result + }, + } +} + +/** + * Derive the binary name from the package.json name, stripping scope. + * + * @private + * @param packageName - The package name from manifest, or undefined. + * @returns The binary name. + */ +function resolveBinaryName(packageName: string | undefined): string { + if (!packageName) { + return 'cli' + } + + return stripScope(packageName) +} + +/** + * Strip the npm scope prefix from a package name. + * + * @private + * @param name - The package name (e.g. `@scope/my-cli`). + * @returns The unscoped name (e.g. `my-cli`). + */ +function stripScope(name: string): string { + const slashIndex = name.indexOf('/') + if (name.startsWith('@') && slashIndex > 0) { + return name.slice(slashIndex + 1) + } + + return name +} + +/** + * Merge base lifecycle hooks with per-call overrides. + * + * Per-call hooks replace base hooks (no chaining). Missing hooks + * are filled with no-ops so callers don't need null checks. + * + * @private + * @param base - The base lifecycle hooks from the factory. + * @param overrides - Per-call hook overrides. + * @returns A lifecycle with all hooks guaranteed to be defined. + */ +function resolveLifecycle( + base: BundlerLifecycle, + overrides: BundlerLifecycle = {} +): Required { + return { + onFinish: overrides.onFinish ?? base.onFinish ?? noop, + onStart: overrides.onStart ?? base.onStart ?? noop, + onStepFinish: overrides.onStepFinish ?? base.onStepFinish ?? noop, + onStepStart: overrides.onStepStart ?? base.onStepStart ?? noop, + } +} diff --git a/packages/bundler/src/compile/compile.test.ts b/packages/bundler/src/compile/compile.test.ts index 1bd2eec3..0d3f09e2 100644 --- a/packages/bundler/src/compile/compile.test.ts +++ b/packages/bundler/src/compile/compile.test.ts @@ -1,46 +1,70 @@ +import type { CompileTarget } from '@kidd-cli/config' import { beforeEach, describe, expect, it, vi } from 'vitest' -vi.mock(import('node:child_process')) -vi.mock(import('node:fs')) +const mockProcessExec = vi.fn() +const mockProcessExists = vi.fn() +const mockFsExists = vi.fn() +const mockFsList = vi.fn() +const mockFsRemove = vi.fn() + +vi.mock(import('@kidd-cli/utils/node'), () => ({ + fs: { exists: mockFsExists, list: mockFsList, remove: mockFsRemove }, + process: { exec: mockProcessExec, exists: mockProcessExists }, +})) -const { execFile } = await import('node:child_process') -const { existsSync, readdirSync } = await import('node:fs') const { compile } = await import('./compile.js') -const mockExecFile = vi.mocked(execFile) -const mockExistsSync = vi.mocked(existsSync) -const mockReaddirSync = vi.mocked(readdirSync) +const noopLifecycle = { + onStart: vi.fn(), + onFinish: vi.fn(), + onStepStart: vi.fn(), + onStepFinish: vi.fn(), +} /** - * Default execFile mock that succeeds for `bun --version` (existence check) - * and succeeds for all other calls. Override per-test to simulate failures. + * @private */ -function mockExecFileSuccess() { - mockExecFile.mockImplementation( - // @ts-expect-error -- callback signature mismatch with overloaded execFile - (_cmd: string, _args: string[], cb: (err: Error | null, stdout: string) => void) => { - cb(null, '') - } - ) +function makeResolved(overrides?: { + readonly targets?: readonly string[] + readonly name?: string +}): Parameters[0]['resolved'] { + return { + entry: '/project/src/index.ts', + commands: '/project/commands', + buildOutDir: '/project/dist', + compileOutDir: '/project/dist', + build: { + target: 'node18', + minify: false, + sourcemap: true, + external: [], + clean: false, + define: {}, + }, + compile: { + targets: (overrides?.targets ?? []) as readonly CompileTarget[], + name: overrides?.name ?? 'cli', + }, + include: [], + cwd: '/project', + } } beforeEach(() => { vi.clearAllMocks() - mockReaddirSync.mockReturnValue([]) - mockExecFileSuccess() + mockProcessExists.mockResolvedValue(true) + mockProcessExec.mockResolvedValue([null, { stdout: '', stderr: '' }]) + mockFsExists.mockResolvedValue(true) + mockFsList.mockResolvedValue([null, []]) + mockFsRemove.mockResolvedValue([null, undefined]) }) describe('compile operation', () => { it('should return ok with binaries for all default targets when none specified', async () => { - mockExistsSync.mockReturnValue(true) - mockExecFile.mockImplementation( - // @ts-expect-error -- callback signature mismatch with overloaded execFile - (_cmd: string, _args: string[], cb: (err: Error | null, stdout: string) => void) => { - cb(null, '') - } - ) - - const [error, output] = await compile({ config: {}, cwd: '/project' }) + const [error, output] = await compile({ + resolved: makeResolved(), + lifecycle: noopLifecycle, + }) expect(error).toBeNull() expect(output).toMatchObject({ @@ -54,14 +78,12 @@ describe('compile operation', () => { }) it('should return err when bun is not installed', async () => { - mockExecFile.mockImplementation( - // @ts-expect-error -- callback signature mismatch with overloaded execFile - (_cmd: string, _args: string[], cb: (err: Error | null, stdout: string) => void) => { - cb(new Error('spawn bun ENOENT'), '') - } - ) + mockProcessExists.mockResolvedValue(false) - const [error, output] = await compile({ config: {}, cwd: '/project' }) + const [error, output] = await compile({ + resolved: makeResolved(), + lifecycle: noopLifecycle, + }) expect(output).toBeNull() expect(error).toBeInstanceOf(Error) @@ -71,9 +93,12 @@ describe('compile operation', () => { }) it('should return err when bundled entry does not exist', async () => { - mockExistsSync.mockReturnValue(false) + mockFsExists.mockResolvedValue(false) - const [error, output] = await compile({ config: {}, cwd: '/project' }) + const [error, output] = await compile({ + resolved: makeResolved(), + lifecycle: noopLifecycle, + }) expect(output).toBeNull() expect(error).toBeInstanceOf(Error) @@ -81,22 +106,12 @@ describe('compile operation', () => { }) it('should return err when bun build fails', async () => { - mockExistsSync.mockReturnValue(true) - mockExecFile - .mockImplementationOnce( - // @ts-expect-error -- callback signature mismatch with overloaded execFile - (_cmd: string, _args: string[], cb: (err: Error | null, stdout: string) => void) => { - cb(null, '1.0.0') - } - ) - .mockImplementation( - // @ts-expect-error -- callback signature mismatch with overloaded execFile - (_cmd: string, _args: string[], cb: (err: Error | null, stdout: string) => void) => { - cb(new Error('bun build crashed'), '') - } - ) - - const [error, output] = await compile({ config: {}, cwd: '/project' }) + mockProcessExec.mockResolvedValue([new Error('bun build crashed'), null]) + + const [error, output] = await compile({ + resolved: makeResolved(), + lifecycle: noopLifecycle, + }) expect(output).toBeNull() expect(error).toBeInstanceOf(Error) @@ -104,28 +119,16 @@ describe('compile operation', () => { }) it('should include stderr in error message when verbose is true', async () => { - mockExistsSync.mockReturnValue(true) - mockExecFile - .mockImplementationOnce( - // @ts-expect-error -- callback signature mismatch with overloaded execFile - (_cmd: string, _args: string[], cb: (err: Error | null, stdout: string) => void) => { - cb(null, '1.0.0') - } - ) - .mockImplementation( - // @ts-expect-error -- callback signature mismatch with overloaded execFile - ( - _cmd: string, - _args: string[], - cb: (err: Error | null, stdout: string, stderr: string) => void - ) => { - cb(new Error('bun build crashed'), '', 'error: could not resolve "chokidar"') - } - ) + const execError = new Error('bun build crashed') + Object.defineProperty(execError, 'stderr', { + value: 'error: could not resolve "chokidar"', + enumerable: true, + }) + mockProcessExec.mockResolvedValue([execError, null]) const [error] = await compile({ - config: { compile: { name: 'my-app', targets: ['linux-x64'] } }, - cwd: '/project', + resolved: makeResolved({ targets: ['linux-x64'], name: 'my-app' }), + lifecycle: noopLifecycle, verbose: true, }) @@ -135,28 +138,16 @@ describe('compile operation', () => { }) it('should not include stderr in error message when verbose is false', async () => { - mockExistsSync.mockReturnValue(true) - mockExecFile - .mockImplementationOnce( - // @ts-expect-error -- callback signature mismatch with overloaded execFile - (_cmd: string, _args: string[], cb: (err: Error | null, stdout: string) => void) => { - cb(null, '1.0.0') - } - ) - .mockImplementation( - // @ts-expect-error -- callback signature mismatch with overloaded execFile - ( - _cmd: string, - _args: string[], - cb: (err: Error | null, stdout: string, stderr: string) => void - ) => { - cb(new Error('bun build crashed'), '', 'error: could not resolve "chokidar"') - } - ) + const execError = new Error('bun build crashed') + Object.defineProperty(execError, 'stderr', { + value: 'error: could not resolve "chokidar"', + enumerable: true, + }) + mockProcessExec.mockResolvedValue([execError, null]) const [error] = await compile({ - config: { compile: { name: 'my-app', targets: ['linux-x64'] } }, - cwd: '/project', + resolved: makeResolved({ targets: ['linux-x64'], name: 'my-app' }), + lifecycle: noopLifecycle, }) expect(error).toMatchObject({ @@ -165,59 +156,35 @@ describe('compile operation', () => { }) it('should pass correct --target arg for cross-compilation', async () => { - mockExistsSync.mockReturnValue(true) - mockExecFile.mockImplementation( - // @ts-expect-error -- callback signature mismatch with overloaded execFile - (_cmd: string, _args: string[], cb: (err: Error | null, stdout: string) => void) => { - cb(null, '') - } - ) - await compile({ - config: { compile: { name: 'my-app', targets: ['linux-x64'] } }, - cwd: '/project', + resolved: makeResolved({ targets: ['linux-x64'], name: 'my-app' }), + lifecycle: noopLifecycle, }) - expect(mockExecFile).toHaveBeenCalledWith( - 'bun', - expect.arrayContaining(['--target', 'bun-linux-x64']), - expect.any(Function) - ) + expect(mockProcessExec).toHaveBeenCalledWith({ + cmd: 'bun', + args: expect.arrayContaining(['--target', 'bun-linux-x64']), + cwd: '/project', + }) }) - it('should map linux-x64-musl to bun-linux-x64', async () => { - mockExistsSync.mockReturnValue(true) - mockExecFile.mockImplementation( - // @ts-expect-error -- callback signature mismatch with overloaded execFile - (_cmd: string, _args: string[], cb: (err: Error | null, stdout: string) => void) => { - cb(null, '') - } - ) - + it('should map linux-x64-musl to bun-linux-x64-musl', async () => { await compile({ - config: { compile: { name: 'my-app', targets: ['linux-x64-musl'] } }, - cwd: '/project', + resolved: makeResolved({ targets: ['linux-x64-musl'], name: 'my-app' }), + lifecycle: noopLifecycle, }) - expect(mockExecFile).toHaveBeenCalledWith( - 'bun', - expect.arrayContaining(['--target', 'bun-linux-x64']), - expect.any(Function) - ) + expect(mockProcessExec).toHaveBeenCalledWith({ + cmd: 'bun', + args: expect.arrayContaining(['--target', 'bun-linux-x64-musl']), + cwd: '/project', + }) }) it('should append target suffix to binary name for multi-target builds', async () => { - mockExistsSync.mockReturnValue(true) - mockExecFile.mockImplementation( - // @ts-expect-error -- callback signature mismatch with overloaded execFile - (_cmd: string, _args: string[], cb: (err: Error | null, stdout: string) => void) => { - cb(null, '') - } - ) - const [error, output] = await compile({ - config: { compile: { name: 'my-app', targets: ['darwin-arm64', 'linux-x64'] } }, - cwd: '/project', + resolved: makeResolved({ targets: ['darwin-arm64', 'linux-x64'], name: 'my-app' }), + lifecycle: noopLifecycle, }) expect(error).toBeNull() @@ -236,15 +203,10 @@ describe('compile operation', () => { }) it('should append target suffix for default multi-target build', async () => { - mockExistsSync.mockReturnValue(true) - mockExecFile.mockImplementation( - // @ts-expect-error -- callback signature mismatch with overloaded execFile - (_cmd: string, _args: string[], cb: (err: Error | null, stdout: string) => void) => { - cb(null, '') - } - ) - - const [error, output] = await compile({ config: {}, cwd: '/project' }) + const [error, output] = await compile({ + resolved: makeResolved(), + lifecycle: noopLifecycle, + }) expect(error).toBeNull() expect(output).toMatchObject({ @@ -256,19 +218,12 @@ describe('compile operation', () => { }) it('should include human-readable labels on compiled binaries', async () => { - mockExistsSync.mockReturnValue(true) - mockExecFile.mockImplementation( - // @ts-expect-error -- callback signature mismatch with overloaded execFile - (_cmd: string, _args: string[], cb: (err: Error | null, stdout: string) => void) => { - cb(null, '') - } - ) - const [, output] = await compile({ - config: { - compile: { name: 'my-app', targets: ['darwin-arm64', 'linux-x64', 'windows-x64'] }, - }, - cwd: '/project', + resolved: makeResolved({ + targets: ['darwin-arm64', 'linux-x64', 'windows-x64'], + name: 'my-app', + }), + lifecycle: noopLifecycle, }) expect(output).toMatchObject({ @@ -280,50 +235,38 @@ describe('compile operation', () => { }) }) - it('should invoke onTargetStart and onTargetComplete for each target', async () => { - mockExistsSync.mockReturnValue(true) - mockExecFile.mockImplementation( - // @ts-expect-error -- callback signature mismatch with overloaded execFile - (_cmd: string, _args: string[], cb: (err: Error | null, stdout: string) => void) => { - cb(null, '') - } - ) - - const started: string[] = [] - const completed: string[] = [] + it('should invoke onStepStart and onStepFinish for each target', async () => { + const stepStarts: unknown[] = [] + const stepFinishes: unknown[] = [] await compile({ - config: { compile: { name: 'my-app', targets: ['darwin-arm64', 'linux-x64'] } }, - cwd: '/project', - onTargetComplete: (target) => { - completed.push(target) - }, - onTargetStart: (target) => { - started.push(target) + resolved: makeResolved({ targets: ['darwin-arm64', 'linux-x64'], name: 'my-app' }), + lifecycle: { + onStepStart: (event) => { + stepStarts.push(event.meta.target) + }, + onStepFinish: (event) => { + stepFinishes.push(event.meta.target) + }, }, }) - expect(started).toContain('darwin-arm64') - expect(started).toContain('linux-x64') - expect(completed).toContain('darwin-arm64') - expect(completed).toContain('linux-x64') + expect(stepStarts).toContain('darwin-arm64') + expect(stepStarts).toContain('linux-x64') + expect(stepFinishes).toContain('darwin-arm64') + expect(stepFinishes).toContain('linux-x64') }) it('should invoke bun with --compile and --outfile args', async () => { - mockExistsSync.mockReturnValue(true) - mockExecFile.mockImplementation( - // @ts-expect-error -- callback signature mismatch with overloaded execFile - (_cmd: string, _args: string[], cb: (err: Error | null, stdout: string) => void) => { - cb(null, '') - } - ) - - await compile({ config: { compile: { name: 'my-app' } }, cwd: '/project' }) - - expect(mockExecFile).toHaveBeenCalledWith( - 'bun', - expect.arrayContaining(['build', '--compile', '--outfile']), - expect.any(Function) - ) + await compile({ + resolved: makeResolved({ name: 'my-app' }), + lifecycle: noopLifecycle, + }) + + expect(mockProcessExec).toHaveBeenCalledWith({ + cmd: 'bun', + args: expect.arrayContaining(['build', '--compile', '--outfile']), + cwd: '/project', + }) }) }) diff --git a/packages/bundler/src/compile/compile.ts b/packages/bundler/src/compile/compile.ts index b4b55225..9a788bd1 100644 --- a/packages/bundler/src/compile/compile.ts +++ b/packages/bundler/src/compile/compile.ts @@ -1,67 +1,68 @@ -import { execFile as execFileCb } from 'node:child_process' -import { readdir, unlink } from 'node:fs/promises' import { join } from 'node:path' import type { CompileTarget } from '@kidd-cli/config' +import { compileTargets } from '@kidd-cli/config/utils' import { err, ok } from '@kidd-cli/utils/fp' -import type { AsyncResult, Result } from '@kidd-cli/utils/fp' -import { attemptAsync } from 'es-toolkit' - -import { detectBuildEntry, resolveConfig } from '../config/resolve-config.js' -import { DEFAULT_COMPILE_TARGETS } from '../constants.js' -import type { CompileOutput, CompileParams, CompiledBinary } from '../types.js' - -/** - * Human-readable labels for each compile target. - */ -const COMPILE_TARGET_LABELS: Readonly> = { - 'darwin-arm64': 'macOS Apple Silicon', - 'darwin-x64': 'macOS Intel', - 'linux-arm64': 'Linux ARM64', - 'linux-x64': 'Linux x64', - 'linux-x64-musl': 'Linux x64 (musl)', - 'windows-arm64': 'Windows ARM64', - 'windows-x64': 'Windows x64', -} +import type { Result, ResultAsync } from '@kidd-cli/utils/fp' +import { fs, process } from '@kidd-cli/utils/node' +import { match } from 'ts-pattern' + +import type { + AsyncBundlerResult, + BundlerLifecycle, + CompileOutput, + CompiledBinary, + ResolvedBundlerConfig, +} from '../types.js' +import { resolveBuildEntry } from '../utils/resolve-build-entry.js' /** * Compile a kidd CLI tool into standalone binaries using `bun build --compile`. * * Expects the bundled entry to already exist in `outDir` (i.e., `build()` must - * be run first). For each requested target (or the current platform if none - * specified), spawns `bun build --compile` to produce a self-contained binary. + * be run first). For each requested target (or defaults when none configured), + * spawns `bun build --compile` to produce a self-contained binary. * - * @param params - The compile parameters including config and working directory. + * @param params - The resolved config, lifecycle hooks, and verbose flag. * @returns A result tuple with compile output on success or an Error on failure. */ -export async function compile(params: CompileParams): AsyncResult { - const [bunCheckError] = await checkBunExists() - if (bunCheckError) { - return err(bunCheckError) +export async function compile(params: { + readonly resolved: ResolvedBundlerConfig + readonly lifecycle: BundlerLifecycle + readonly verbose?: boolean +}): AsyncBundlerResult { + const bunExists = await process.exists('bun') + if (!bunExists) { + return err( + new Error( + 'bun is not installed or not found in PATH. Install it from https://bun.sh to use compile.' + ) + ) } - const resolved = resolveConfig(params) - const bundledEntry = detectBuildEntry(resolved.buildOutDir) + const bundledEntry = await resolveBuildEntry(params.resolved.buildOutDir) if (!bundledEntry) { - return err(new Error(`bundled entry not found in ${resolved.buildOutDir} — run build() first`)) + return err( + new Error(`bundled entry not found in ${params.resolved.buildOutDir} — run build() first`) + ) } - const targets: readonly CompileTarget[] = resolveTargets(resolved.compile.targets) + const targets: readonly CompileTarget[] = resolveTargets(params.resolved.compile.targets) const isMultiTarget = targets.length > 1 const results = await compileTargetsSequentially({ bundledEntry, + cwd: params.resolved.cwd, isMultiTarget, - name: resolved.compile.name, - onTargetComplete: params.onTargetComplete, - onTargetStart: params.onTargetStart, - outDir: resolved.compileOutDir, + lifecycle: params.lifecycle, + name: params.resolved.compile.name, + outDir: params.resolved.compileOutDir, targets, verbose: params.verbose ?? false, }) - await cleanBunBuildArtifacts(resolved.cwd) + await cleanBunBuildArtifacts(params.resolved.cwd) const failedResult = results.find((r) => r[0] !== null) if (failedResult) { @@ -75,38 +76,50 @@ export async function compile(params: CompileParams): AsyncResult return ok({ binaries }) } -// --------------------------------------------------------------------------- +/** + * Look up the human-readable label for a compile target. + * + * @param target - The compile target identifier. + * @returns A descriptive label (e.g., "macOS Apple Silicon"). + */ +export function resolveTargetLabel(target: CompileTarget): string { + const entry = compileTargets.find((t) => t.target === target) + if (entry) { + return entry.label + } + + return target +} /** * Compile targets one at a time to avoid overwhelming bun with concurrent processes. * - * When turbo runs multiple examples in parallel, each spawning `bun build --compile` - * for every target simultaneously, bun processes compete for resources and fail. - * Sequential execution within each example avoids this. - * * @private * @param params - The targets and compilation parameters. * @returns The accumulated result tuples for each target. */ async function compileTargetsSequentially(params: { readonly bundledEntry: string + readonly cwd: string readonly isMultiTarget: boolean + readonly lifecycle: BundlerLifecycle readonly name: string - readonly onTargetComplete?: (target: CompileTarget) => void | Promise - readonly onTargetStart?: (target: CompileTarget) => void | Promise readonly outDir: string readonly targets: readonly CompileTarget[] readonly verbose: boolean }): Promise[]> { return params.targets.reduce[]>>(async (accPromise, target) => { const acc = await accPromise + const label = resolveTargetLabel(target) + const meta = { target, label } - if (params.onTargetStart) { - await params.onTargetStart(target) + if (params.lifecycle.onStepStart) { + await params.lifecycle.onStepStart({ phase: 'compile', step: 'target', meta }) } const result = await compileSingleTarget({ bundledEntry: params.bundledEntry, + cwd: params.cwd, isMultiTarget: params.isMultiTarget, name: params.name, outDir: params.outDir, @@ -114,8 +127,8 @@ async function compileTargetsSequentially(params: { verbose: params.verbose, }) - if (result[0] === null && params.onTargetComplete) { - await params.onTargetComplete(target) + if (params.lifecycle.onStepFinish) { + await params.lifecycle.onStepFinish({ phase: 'compile', step: 'target', meta }) } return [...acc, result] @@ -131,15 +144,21 @@ async function compileTargetsSequentially(params: { */ async function compileSingleTarget(params: { readonly bundledEntry: string + readonly cwd: string readonly outDir: string readonly name: string readonly target: CompileTarget readonly isMultiTarget: boolean readonly verbose: boolean -}): AsyncResult { +}): ResultAsync { const binaryName = resolveBinaryName(params.name, params.target, params.isMultiTarget) const outfile = join(params.outDir, binaryName) + const [mapError, bunTarget] = mapCompileTarget(params.target) + if (mapError) { + return err(mapError) + } + const args = [ 'build', '--compile', @@ -147,10 +166,10 @@ async function compileSingleTarget(params: { '--outfile', outfile, '--target', - mapCompileTarget(params.target), + bunTarget, ] - const [execError] = await execBunBuild(args) + const [execError] = await process.exec({ cmd: 'bun', args, cwd: params.cwd }) if (execError) { return err( new Error(formatCompileError(params.target, execError, params.verbose), { cause: execError }) @@ -163,9 +182,6 @@ async function compileSingleTarget(params: { /** * Resolve the list of compile targets, falling back to the default set. * - * When no targets are explicitly configured, defaults to linux-x64, - * darwin-arm64, darwin-x64, and windows-x64 to cover ~95% of developers. - * * @private * @param explicit - User-specified targets (may be empty). * @returns The targets to compile for. @@ -175,7 +191,7 @@ function resolveTargets(explicit: readonly CompileTarget[]): readonly CompileTar return explicit } - return DEFAULT_COMPILE_TARGETS + return compileTargets.filter((t) => t.default).map((t) => t.target) } /** @@ -195,45 +211,37 @@ function resolveBinaryName(name: string, target: CompileTarget, isMultiTarget: b return name } -/** - * Look up the human-readable label for a compile target. - * - * @param target - The compile target identifier. - * @returns A descriptive label (e.g., "macOS Apple Silicon"). - */ -export function resolveTargetLabel(target: CompileTarget): string { - return COMPILE_TARGET_LABELS[target] -} - /** * Map a `CompileTarget` to Bun's `--target` string. * - * Note: `linux-x64-musl` maps to `bun-linux-x64` because Bun's Linux - * builds natively handle musl — there is no separate musl target. + * Every supported kidd target must have an explicit mapping. An unrecognized + * target is a fatal error — it means `compileTargets` was extended without + * updating this function. * * @private * @param target - The kidd compile target. - * @returns The Bun target string (e.g., `'bun-darwin-arm64'`). + * @returns A result with the Bun target string, or an Error for unknown targets. */ -function mapCompileTarget(target: CompileTarget): string { - if (target === 'linux-x64-musl') { - return 'bun-linux-x64' - } - - return `bun-${target}` +function mapCompileTarget(target: CompileTarget): Result { + return match(target) + .with('darwin-arm64', () => ok('bun-darwin-arm64')) + .with('darwin-x64', () => ok('bun-darwin-x64')) + .with('linux-arm64', () => ok('bun-linux-arm64')) + .with('linux-x64', () => ok('bun-linux-x64')) + .with('linux-x64-musl', () => ok('bun-linux-x64-musl')) + .with('windows-arm64', () => ok('bun-windows-arm64')) + .with('windows-x64', () => ok('bun-windows-x64')) + .otherwise(() => err(new Error(`unknown compile target: ${target}`))) } /** * Build a descriptive error message for a failed compile target. * - * When verbose is enabled, extracts stderr from the exec error so the - * underlying bun error is surfaced instead of being buried in the cause chain. - * * @private * @param target - The compile target that failed. * @param execError - The error returned by execFile. * @param verbose - Whether to include stderr output in the message. - * @returns A formatted error message, including stderr when verbose is true. + * @returns A formatted error message. */ function formatCompileError(target: CompileTarget, execError: Error, verbose: boolean): string { const header = `bun build --compile failed for target ${target}` @@ -250,53 +258,6 @@ function formatCompileError(target: CompileTarget, execError: Error, verbose: bo return header } -/** - * Check whether the `bun` binary is available on the system PATH. - * - * @private - * @returns A result tuple with `null` on success or an Error when `bun` is not found. - */ -function checkBunExists(): AsyncResult { - return new Promise((resolve) => { - execFileCb('bun', ['--version'], (error) => { - if (error) { - resolve( - err( - new Error( - 'bun is not installed or not found in PATH. Install it from https://bun.sh to use compile.' - ) - ) - ) - return - } - - resolve(ok(null)) - }) - }) -} - -/** - * Promisified wrapper around `execFile` to invoke `bun build`. - * - * @private - * @param args - Arguments to pass to `bun`. - * @returns A result tuple with stdout on success or an Error on failure. - */ -function execBunBuild(args: readonly string[]): AsyncResult { - return new Promise((resolve) => { - execFileCb('bun', [...args], (error, stdout, stderr) => { - if (error) { - const enriched = new Error(error.message, { cause: error }) - Object.defineProperty(enriched, 'stderr', { enumerable: true, value: stderr }) - resolve(err(enriched)) - return - } - - resolve(ok(stdout)) - }) - }) -} - /** * Remove temporary `.bun-build` files that `bun build --compile` leaves behind. * @@ -304,8 +265,8 @@ function execBunBuild(args: readonly string[]): AsyncResult { * @param cwd - The working directory to clean. */ async function cleanBunBuildArtifacts(cwd: string): Promise { - const [readError, entries] = await attemptAsync(() => readdir(cwd)) - if (readError || !entries) { + const [listError, entries] = await fs.list(cwd) + if (listError) { return } @@ -313,5 +274,5 @@ async function cleanBunBuildArtifacts(cwd: string): Promise { .filter((name) => name.endsWith('.bun-build')) .map((name) => join(cwd, name)) - await Promise.allSettled(artifacts.map((filePath) => unlink(filePath))) + await Promise.allSettled(artifacts.map(fs.remove)) } diff --git a/packages/bundler/src/config/detect-build-entry.test.ts b/packages/bundler/src/config/detect-build-entry.test.ts deleted file mode 100644 index 37ada468..00000000 --- a/packages/bundler/src/config/detect-build-entry.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { join } from 'node:path' - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -vi.mock(import('node:fs')) - -const { existsSync } = await import('node:fs') -const { detectBuildEntry } = await import('./resolve-config.js') - -const mockExistsSync = vi.mocked(existsSync) - -beforeEach(() => { - vi.clearAllMocks() -}) - -describe('detectBuildEntry', () => { - it('should return index.js when it exists', () => { - mockExistsSync.mockReturnValue(true) - - const result = detectBuildEntry('/project/dist') - - expect(result).toBe(join('/project/dist', 'index.js')) - }) - - it('should return undefined when no entry file exists', () => { - mockExistsSync.mockReturnValue(false) - - const result = detectBuildEntry('/project/dist') - - expect(result).toBeUndefined() - }) -}) diff --git a/packages/bundler/src/config/read-version.test.ts b/packages/bundler/src/config/read-version.test.ts deleted file mode 100644 index 4bb89347..00000000 --- a/packages/bundler/src/config/read-version.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -vi.mock(import('@kidd-cli/utils/manifest')) - -const { readManifest } = await import('@kidd-cli/utils/manifest') -const { readVersion } = await import('./read-version.js') - -const mockReadManifest = vi.mocked(readManifest) - -beforeEach(() => { - vi.clearAllMocks() -}) - -describe('readVersion', () => { - it('should return version when package.json has one', async () => { - mockReadManifest.mockResolvedValueOnce([ - null, - { - author: undefined, - bin: undefined, - description: 'test', - homepage: undefined, - keywords: [], - license: undefined, - name: 'my-cli', - repository: undefined, - version: '1.2.3', - }, - ]) - - const [error, version] = await readVersion('/project') - - expect(error).toBeNull() - expect(version).toBe('1.2.3') - expect(mockReadManifest).toHaveBeenCalledWith('/project') - }) - - it('should return undefined when package.json has no version field', async () => { - mockReadManifest.mockResolvedValueOnce([ - null, - { - author: undefined, - bin: undefined, - description: undefined, - homepage: undefined, - keywords: [], - license: undefined, - name: 'my-cli', - repository: undefined, - version: undefined, - }, - ]) - - const [error, version] = await readVersion('/project') - - expect(error).toBeNull() - expect(version).toBeUndefined() - }) - - it('should return error when package.json cannot be read', async () => { - mockReadManifest.mockResolvedValueOnce([new Error('ENOENT'), null]) - - const [error, version] = await readVersion('/missing') - - expect(version).toBeNull() - expect(error).toBeInstanceOf(Error) - expect(error).toMatchObject({ message: expect.stringContaining('Failed to read version') }) - }) -}) diff --git a/packages/bundler/src/config/read-version.ts b/packages/bundler/src/config/read-version.ts deleted file mode 100644 index d04daecf..00000000 --- a/packages/bundler/src/config/read-version.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { err, ok } from '@kidd-cli/utils/fp' -import { readManifest } from '@kidd-cli/utils/manifest' - -import type { AsyncBundlerResult } from '../types.js' - -/** - * Read the version string from a project's package.json. - * - * Uses `readManifest` to parse the package.json at the given directory and - * extracts the `version` field. Returns `undefined` when the manifest has no - * version set. - * - * @param cwd - Directory containing the package.json. - * @returns A result tuple with the version string (or undefined) on success, or an Error on failure. - */ -export async function readVersion(cwd: string): AsyncBundlerResult { - const [manifestError, manifest] = await readManifest(cwd) - - if (manifestError) { - return err(new Error(`Failed to read version: ${manifestError.message}`)) - } - - return ok(manifest.version) -} diff --git a/packages/bundler/src/constants.ts b/packages/bundler/src/constants.ts index 7ace489f..53c44fc9 100644 --- a/packages/bundler/src/constants.ts +++ b/packages/bundler/src/constants.ts @@ -1,7 +1,5 @@ import { builtinModules } from 'node:module' -import type { CompileTarget } from '@kidd-cli/config' - /** * Shebang line prepended to CLI entry files. */ @@ -47,24 +45,6 @@ export const DEFAULT_CLEAN = true */ export const BUILD_ARTIFACT_EXTENSIONS: readonly string[] = ['.js', '.mjs', '.js.map', '.mjs.map'] -/** - * Default binary name for compiled SEA output. - */ -export const DEFAULT_BINARY_NAME = 'cli' - -/** - * Default compile targets when none are explicitly configured. - * - * Covers Linux servers/CI, modern and Intel Macs, and Windows — roughly 95% - * of developer environments. - */ -export const DEFAULT_COMPILE_TARGETS: readonly CompileTarget[] = [ - 'darwin-arm64', - 'darwin-x64', - 'linux-x64', - 'windows-x64', -] - /** * Packages that must always be bundled into the output. * diff --git a/packages/bundler/src/index.ts b/packages/bundler/src/index.ts index a7e29902..45bf9961 100644 --- a/packages/bundler/src/index.ts +++ b/packages/bundler/src/index.ts @@ -1,16 +1,4 @@ -export { createAutoloadPlugin } from './autoloader/autoload-plugin.js' -export { build } from './build/build.js' -export { compile, resolveTargetLabel } from './compile/compile.js' -export type { - BuildOptions, - BuildOutput, - BuildParams, - CompileOptions, - CompileOutput, - CompileParams, - CompileTarget, - CompiledBinary, - KiddConfig, - WatchParams, -} from './types.js' -export { watch } from './build/watch.js' +export { createBundler } from './bundler.js' +export { DEFAULT_COMMANDS, DEFAULT_ENTRY } from './constants.js' +export type { BuildOutput, Bundler, CompileOutput, CompiledBinary } from './types.js' +export { normalizeCompileOptions } from './utils/resolve-config.js' diff --git a/packages/bundler/src/types.ts b/packages/bundler/src/types.ts index ff343687..44a6c6b5 100644 --- a/packages/bundler/src/types.ts +++ b/packages/bundler/src/types.ts @@ -1,9 +1,7 @@ import type { BuildOptions, CompileOptions, CompileTarget, KiddConfig } from '@kidd-cli/config' -import type { AsyncResult, Result } from '@kidd-cli/utils/fp' +import type { ResultAsync, Result } from '@kidd-cli/utils/fp' -// --------------------------------------------------------------------------- // Resolved config types (all fields required, paths absolute) -// --------------------------------------------------------------------------- /** * Fully resolved build options with all defaults applied. @@ -14,6 +12,7 @@ export interface ResolvedBuildOptions { readonly sourcemap: boolean readonly external: readonly string[] readonly clean: boolean + readonly define: Readonly> } /** @@ -36,11 +35,10 @@ export interface ResolvedBundlerConfig { readonly compile: ResolvedCompileOptions readonly include: readonly string[] readonly cwd: string + readonly version: string | undefined } -// --------------------------------------------------------------------------- // Result aliases -// --------------------------------------------------------------------------- /** * Synchronous result from a bundler operation. @@ -50,72 +48,108 @@ export type BundlerResult = Result /** * Asynchronous result from a bundler operation. */ -export type AsyncBundlerResult = AsyncResult +export type AsyncBundlerResult = ResultAsync -// --------------------------------------------------------------------------- -// Output types -// --------------------------------------------------------------------------- +// Lifecycle types /** - * Output of a successful build operation. + * Bundler operation phase. */ -export interface BuildOutput { - readonly outDir: string - readonly entryFile: string - readonly version: string | undefined +export type Phase = 'build' | 'watch' | 'compile' + +/** + * Granular step within a phase. + */ +export type Step = 'target' + +/** + * Event fired at phase boundaries (start/finish). + */ +export interface PhaseEvent { + readonly phase: Phase } /** - * A single compiled binary for a specific target platform. + * Event fired at step boundaries within a phase. */ -export interface CompiledBinary { - readonly target: CompileTarget - readonly label: string - readonly path: string +export interface StepEvent { + readonly phase: Phase + readonly step: Step + readonly meta: Readonly> } /** - * Output of a successful compile operation. + * Lifecycle hooks for bundler operations. */ -export interface CompileOutput { - readonly binaries: readonly CompiledBinary[] +export interface BundlerLifecycle { + readonly onStart?: (event: PhaseEvent) => void | Promise + readonly onFinish?: (event: PhaseEvent) => void | Promise + readonly onStepStart?: (event: StepEvent) => void | Promise + readonly onStepFinish?: (event: StepEvent) => void | Promise } -// --------------------------------------------------------------------------- -// Param types -// --------------------------------------------------------------------------- +// Factory types /** - * Parameters for the build function. + * Parameters for creating a bundler instance. */ -export interface BuildParams { +export interface CreateBundlerParams extends BundlerLifecycle { readonly config: KiddConfig readonly cwd: string } /** - * Parameters for the watch function. + * A bundler instance with build, watch, and compile methods. */ -export interface WatchParams { - readonly config: KiddConfig - readonly cwd: string +export interface Bundler { + readonly build: () => AsyncBundlerResult + readonly watch: (params?: WatchOverrides) => AsyncBundlerResult + readonly compile: (params?: CompileOverrides) => AsyncBundlerResult +} + +/** + * Per-call overrides for watch. + */ +export interface WatchOverrides extends BundlerLifecycle { readonly onSuccess?: () => void | Promise } /** - * Parameters for the compile function. + * Per-call overrides for compile. */ -export interface CompileParams { - readonly config: KiddConfig - readonly cwd: string +export interface CompileOverrides extends BundlerLifecycle { readonly verbose?: boolean - readonly onTargetStart?: (target: CompileTarget) => void | Promise - readonly onTargetComplete?: (target: CompileTarget) => void | Promise } -// --------------------------------------------------------------------------- +// Output types + +/** + * Output of a successful build operation. + */ +export interface BuildOutput { + readonly outDir: string + readonly entryFile: string + readonly version: string | undefined + readonly define: Readonly> +} + +/** + * A single compiled binary for a specific target platform. + */ +export interface CompiledBinary { + readonly target: CompileTarget + readonly label: string + readonly path: string +} + +/** + * Output of a successful compile operation. + */ +export interface CompileOutput { + readonly binaries: readonly CompiledBinary[] +} + // Scan types (used by the autoload plugin) -// --------------------------------------------------------------------------- /** * A single command file discovered during a directory scan. @@ -143,8 +177,6 @@ export interface ScanResult { readonly dirs: readonly ScannedDir[] } -// --------------------------------------------------------------------------- // Re-exports from @kidd-cli/config for convenience -// --------------------------------------------------------------------------- export type { BuildOptions, CompileOptions, CompileTarget, KiddConfig } diff --git a/packages/bundler/src/utils/clean.test.ts b/packages/bundler/src/utils/clean.test.ts new file mode 100644 index 00000000..01d9f65f --- /dev/null +++ b/packages/bundler/src/utils/clean.test.ts @@ -0,0 +1,151 @@ +import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +import { clean } from './clean.js' + +const testDir = join(tmpdir(), `kidd-clean-test-${Date.now()}`) + +function makeResolved( + overrides: { + readonly buildOutDir?: string + readonly compileName?: string + readonly compileTargets?: readonly string[] + } = {} +): Parameters[0]['resolved'] { + return { + entry: '/project/src/index.ts', + commands: '/project/commands', + buildOutDir: overrides.buildOutDir ?? testDir, + compileOutDir: testDir, + build: { + target: 'node18', + minify: false, + sourcemap: true, + external: [], + clean: true, + define: {}, + }, + compile: { + name: overrides.compileName ?? 'cli', + targets: (overrides.compileTargets ?? []) as never, + }, + include: [], + cwd: '/project', + version: '1.0.0', + } +} + +describe('clean', () => { + beforeEach(() => { + mkdirSync(testDir, { recursive: true }) + }) + + afterEach(() => { + rmSync(testDir, { force: true, recursive: true }) + }) + + it('should return empty result for non-existent directory', async () => { + const result = await clean({ + resolved: makeResolved({ buildOutDir: '/non/existent/path' }), + compile: false, + }) + + expect(result.removed).toStrictEqual([]) + expect(result.foreign).toStrictEqual([]) + }) + + it('should remove build artifacts', async () => { + writeFileSync(join(testDir, 'index.js'), '') + writeFileSync(join(testDir, 'index.js.map'), '') + + const result = await clean({ resolved: makeResolved(), compile: false }) + + expect(result.removed).toContain('index.js') + expect(result.removed).toContain('index.js.map') + expect(existsSync(join(testDir, 'index.js'))).toBe(false) + expect(existsSync(join(testDir, 'index.js.map'))).toBe(false) + }) + + it('should preserve foreign files and report them', async () => { + writeFileSync(join(testDir, 'index.js'), '') + writeFileSync(join(testDir, 'README.md'), '') + + const result = await clean({ resolved: makeResolved(), compile: false }) + + expect(result.removed).toContain('index.js') + expect(result.foreign).toContain('README.md') + expect(existsSync(join(testDir, 'README.md'))).toBe(true) + }) + + it('should not remove extensionless files when compile is false', async () => { + writeFileSync(join(testDir, 'cli-darwin-arm64'), '') + + const result = await clean({ resolved: makeResolved(), compile: false }) + + expect(result.foreign).toContain('cli-darwin-arm64') + expect(existsSync(join(testDir, 'cli-darwin-arm64'))).toBe(true) + }) + + it('should remove exact binary names when compile is true', async () => { + writeFileSync(join(testDir, 'index.mjs'), '') + writeFileSync(join(testDir, 'my-app-darwin-arm64'), '') + writeFileSync(join(testDir, 'my-app-linux-x64'), '') + writeFileSync(join(testDir, 'README.md'), '') + + const result = await clean({ + resolved: makeResolved({ + compileName: 'my-app', + compileTargets: ['darwin-arm64', 'linux-x64'], + }), + compile: true, + }) + + expect(result.removed).toContain('index.mjs') + expect(result.removed).toContain('my-app-darwin-arm64') + expect(result.removed).toContain('my-app-linux-x64') + expect(result.foreign).toContain('README.md') + expect(existsSync(join(testDir, 'my-app-darwin-arm64'))).toBe(false) + expect(existsSync(join(testDir, 'my-app-linux-x64'))).toBe(false) + expect(existsSync(join(testDir, 'README.md'))).toBe(true) + }) + + it('should remove windows .exe binary names in multi-target builds', async () => { + writeFileSync(join(testDir, 'cli-windows-x64.exe'), '') + writeFileSync(join(testDir, 'cli-darwin-arm64'), '') + + const result = await clean({ + resolved: makeResolved({ + compileName: 'cli', + compileTargets: ['windows-x64', 'darwin-arm64'], + }), + compile: true, + }) + + expect(result.removed).toContain('cli-windows-x64.exe') + expect(result.removed).toContain('cli-darwin-arm64') + }) + + it('should not suffix single-target binary names', async () => { + writeFileSync(join(testDir, 'my-app'), '') + + const result = await clean({ + resolved: makeResolved({ + compileName: 'my-app', + compileTargets: ['darwin-arm64'], + }), + compile: true, + }) + + expect(result.removed).toContain('my-app') + }) + + it('should return empty result for empty directory', async () => { + const result = await clean({ resolved: makeResolved(), compile: false }) + + expect(result.removed).toStrictEqual([]) + expect(result.foreign).toStrictEqual([]) + }) +}) diff --git a/packages/bundler/src/utils/clean.ts b/packages/bundler/src/utils/clean.ts new file mode 100644 index 00000000..46f4479f --- /dev/null +++ b/packages/bundler/src/utils/clean.ts @@ -0,0 +1,109 @@ +import { join } from 'node:path' + +import { compileTargets } from '@kidd-cli/config/utils' +import { fs } from '@kidd-cli/utils/node' +import { match } from 'ts-pattern' + +import { BUILD_ARTIFACT_EXTENSIONS } from '../constants.js' +import type { ResolvedBundlerConfig } from '../types.js' + +/** + * Result of a targeted clean operation. + */ +interface CleanResult { + readonly removed: readonly string[] + readonly foreign: readonly string[] +} + +/** + * Remove kidd build artifacts from the output directory. + * + * Removes files matching known build artifact extensions (`.js`, `.mjs`, + * `.js.map`, `.mjs.map`). When compile mode is active, also removes the + * exact binary files that would be produced based on the resolved compile + * name and targets. + * + * @param params - The resolved config and whether compile mode is active. + * @returns A {@link CleanResult} describing what was removed and what was skipped. + */ +export async function clean(params: { + readonly resolved: ResolvedBundlerConfig + readonly compile: boolean +}): Promise { + const [listError, entries] = await fs.list(params.resolved.buildOutDir) + if (listError) { + return { foreign: [], removed: [] } + } + + const binaryNames = match(params.compile) + .with(true, () => + buildBinaryNames(params.resolved.compile.name, params.resolved.compile.targets) + ) + .with(false, () => new Set()) + .exhaustive() + + const results = await Promise.all( + entries.map(async (name) => { + const shouldRemove = isBuildArtifact(name) || binaryNames.has(name) + if (shouldRemove) { + const [removeError] = await fs.remove(join(params.resolved.buildOutDir, name)) + if (removeError) { + return { type: 'foreign' as const, name } + } + return { type: 'removed' as const, name } + } + return { type: 'foreign' as const, name } + }) + ) + + return { + removed: results.filter((r) => r.type === 'removed').map((r) => r.name), + foreign: results.filter((r) => r.type === 'foreign').map((r) => r.name), + } +} + +/** + * Check whether a filename matches a known build artifact extension. + * + * @private + * @param filename - The filename to check. + * @returns `true` when the file ends with a known build artifact extension. + */ +function isBuildArtifact(filename: string): boolean { + return BUILD_ARTIFACT_EXTENSIONS.some((ext) => filename.endsWith(ext)) +} + +/** + * Build the set of exact binary filenames that compile would produce. + * + * Single-target builds produce `{name}`, multi-target builds produce + * `{name}-{target}`. Windows targets also get `{name}.exe` / `{name}-{target}.exe`. + * + * @private + * @param name - The resolved binary base name. + * @param targets - The resolved compile targets (may be empty → defaults used). + * @returns A set of filenames to remove. + */ +function buildBinaryNames(name: string, targets: readonly string[]): ReadonlySet { + const resolvedTargets = match(targets.length > 0) + .with(true, () => targets) + .with(false, () => compileTargets.filter((t) => t.default).map((t) => t.target)) + .exhaustive() + + const isMultiTarget = resolvedTargets.length > 1 + + const names = resolvedTargets.flatMap((target) => { + const binaryName = match(isMultiTarget) + .with(true, () => `${name}-${target}`) + .with(false, () => name) + .exhaustive() + + if (target.startsWith('windows')) { + return [binaryName, `${binaryName}.exe`] + } + + return [binaryName] + }) + + return new Set(names) +} diff --git a/packages/bundler/src/utils/resolve-build-entry.test.ts b/packages/bundler/src/utils/resolve-build-entry.test.ts new file mode 100644 index 00000000..db88133c --- /dev/null +++ b/packages/bundler/src/utils/resolve-build-entry.test.ts @@ -0,0 +1,33 @@ +import { join } from 'node:path' + +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mockFsExists = vi.fn() + +vi.mock(import('@kidd-cli/utils/node'), () => ({ + fs: { exists: mockFsExists }, +})) + +const { resolveBuildEntry } = await import('./resolve-build-entry.js') + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('resolveBuildEntry', () => { + it('should return index.mjs when it exists (preferred over index.js)', async () => { + mockFsExists.mockResolvedValue(true) + + const result = await resolveBuildEntry('/project/dist') + + expect(result).toBe(join('/project/dist', 'index.mjs')) + }) + + it('should return undefined when no entry file exists', async () => { + mockFsExists.mockResolvedValue(false) + + const result = await resolveBuildEntry('/project/dist') + + expect(result).toBeUndefined() + }) +}) diff --git a/packages/bundler/src/utils/resolve-build-entry.ts b/packages/bundler/src/utils/resolve-build-entry.ts new file mode 100644 index 00000000..fcc6a8c4 --- /dev/null +++ b/packages/bundler/src/utils/resolve-build-entry.ts @@ -0,0 +1,33 @@ +import { join } from 'node:path' + +import { fs } from '@kidd-cli/utils/node' +import { match } from 'ts-pattern' + +/** + * Known entry file names produced by tsdown for ESM builds, in preference order. + */ +const ENTRY_CANDIDATES = ['index.mjs', 'index.js'] as const + +/** + * Resolve the bundled entry file in a build output directory. + * + * tsdown may produce `index.mjs` or `index.js` depending on the project's + * `package.json` `type` field and tsdown configuration. This function checks + * for both candidates and returns the first one that exists on disk. + * + * @param outDir - Absolute path to the build output directory. + * @returns The absolute path to the entry file, or `undefined` when none is found. + */ +export async function resolveBuildEntry(outDir: string): Promise { + const candidates = ENTRY_CANDIDATES.map((name) => join(outDir, name)) + + const results = await Promise.all( + candidates.map(async (path) => ({ path, found: await fs.exists(path) })) + ) + + const found = results.find((r) => r.found) + + return match(found) + .with(undefined, () => undefined) + .otherwise((entry) => entry.path) +} diff --git a/packages/bundler/src/config/resolve-config.test.ts b/packages/bundler/src/utils/resolve-config.test.ts similarity index 80% rename from packages/bundler/src/config/resolve-config.test.ts rename to packages/bundler/src/utils/resolve-config.test.ts index 0611bfff..fd46fd8a 100644 --- a/packages/bundler/src/config/resolve-config.test.ts +++ b/packages/bundler/src/utils/resolve-config.test.ts @@ -3,7 +3,6 @@ import { resolve } from 'node:path' import { describe, expect, it } from 'vitest' import { - DEFAULT_BINARY_NAME, DEFAULT_CLEAN, DEFAULT_COMMANDS, DEFAULT_ENTRY, @@ -18,7 +17,7 @@ describe('config resolution', () => { const cwd = '/project' describe('with empty config', () => { - const resolved = resolveConfig({ config: {}, cwd }) + const resolved = resolveConfig({ config: {}, cwd, version: undefined, binaryName: 'cli' }) it('should resolve entry to default absolute path', () => { expect(resolved.entry).toBe(resolve(cwd, DEFAULT_ENTRY)) @@ -39,6 +38,7 @@ describe('config resolution', () => { it('should apply default build options', () => { expect(resolved.build).toStrictEqual({ clean: DEFAULT_CLEAN, + define: {}, external: [], minify: DEFAULT_MINIFY, sourcemap: DEFAULT_SOURCEMAP, @@ -46,11 +46,8 @@ describe('config resolution', () => { }) }) - it('should apply default compile options', () => { - expect(resolved.compile).toStrictEqual({ - name: DEFAULT_BINARY_NAME, - targets: [], - }) + it('should use binaryName as compile name when no config name', () => { + expect(resolved.compile.name).toBe('cli') }) it('should default include to empty array', () => { @@ -60,6 +57,10 @@ describe('config resolution', () => { it('should preserve cwd', () => { expect(resolved.cwd).toBe(cwd) }) + + it('should preserve version', () => { + expect(resolved.version).toBeUndefined() + }) }) describe('with custom config', () => { @@ -82,6 +83,8 @@ describe('config resolution', () => { include: ['assets/**'], }, cwd, + version: '2.0.0', + binaryName: 'fallback', }) it('should resolve custom entry as absolute path', () => { @@ -103,6 +106,7 @@ describe('config resolution', () => { it('should use custom build options', () => { expect(resolved.build).toStrictEqual({ clean: DEFAULT_CLEAN, + define: {}, external: ['pg'], minify: true, sourcemap: false, @@ -110,29 +114,29 @@ describe('config resolution', () => { }) }) - it('should use custom compile options', () => { - expect(resolved.compile).toStrictEqual({ - name: 'my-cli', - targets: ['darwin-arm64'], - }) + it('should prefer config compile name over binaryName', () => { + expect(resolved.compile.name).toBe('my-cli') }) it('should use custom include globs', () => { expect(resolved.include).toStrictEqual(['assets/**']) }) + + it('should preserve version', () => { + expect(resolved.version).toBe('2.0.0') + }) }) describe('with compile: true (boolean shorthand)', () => { const resolved = resolveConfig({ config: { compile: true }, cwd, + version: undefined, + binaryName: 'my-app', }) - it('should apply default compile options', () => { - expect(resolved.compile).toStrictEqual({ - name: DEFAULT_BINARY_NAME, - targets: [], - }) + it('should use binaryName as compile name', () => { + expect(resolved.compile.name).toBe('my-app') }) }) @@ -140,13 +144,12 @@ describe('config resolution', () => { const resolved = resolveConfig({ config: { compile: false }, cwd, + version: undefined, + binaryName: 'cli', }) - it('should apply default compile options', () => { - expect(resolved.compile).toStrictEqual({ - name: DEFAULT_BINARY_NAME, - targets: [], - }) + it('should use binaryName as compile name', () => { + expect(resolved.compile.name).toBe('cli') }) }) }) diff --git a/packages/bundler/src/config/resolve-config.ts b/packages/bundler/src/utils/resolve-config.ts similarity index 70% rename from packages/bundler/src/config/resolve-config.ts rename to packages/bundler/src/utils/resolve-config.ts index 299386a0..67fed57f 100644 --- a/packages/bundler/src/config/resolve-config.ts +++ b/packages/bundler/src/utils/resolve-config.ts @@ -1,10 +1,8 @@ -import { existsSync } from 'node:fs' -import { join, resolve } from 'node:path' +import { resolve } from 'node:path' import type { CompileOptions, KiddConfig } from '@kidd-cli/config' import { - DEFAULT_BINARY_NAME, DEFAULT_CLEAN, DEFAULT_COMMANDS, DEFAULT_ENTRY, @@ -15,13 +13,6 @@ import { } from '../constants.js' import type { ResolvedBundlerConfig } from '../types.js' -/** - * Known entry file names produced by the bundler, in preference order. - * - * With `"type": "module"` in package.json, Node.js treats `.js` as ESM. - */ -const ENTRY_CANDIDATES = ['index.js'] as const - /** * Normalize the `compile` config field from `boolean | CompileOptions | undefined` to `CompileOptions`. * @@ -54,6 +45,8 @@ export function normalizeCompileOptions( export function resolveConfig(params: { readonly config: KiddConfig readonly cwd: string + readonly version: string | undefined + readonly binaryName: string }): ResolvedBundlerConfig { const { config, cwd } = params @@ -69,6 +62,7 @@ export function resolveConfig(params: { return { build: { clean: buildOpts.clean ?? DEFAULT_CLEAN, + define: buildOpts.define ?? {}, external: buildOpts.external ?? [], minify: buildOpts.minify ?? DEFAULT_MINIFY, sourcemap: buildOpts.sourcemap ?? DEFAULT_SOURCEMAP, @@ -77,26 +71,13 @@ export function resolveConfig(params: { buildOutDir, commands, compile: { - name: compileOpts.name ?? DEFAULT_BINARY_NAME, + name: compileOpts.name ?? params.binaryName, targets: compileOpts.targets ?? [], }, compileOutDir, cwd, entry, include: config.include ?? [], + version: params.version, } } - -/** - * Detect the bundled entry file in a build output directory. - * - * The bundler produces `index.js` (ESM via `"type": "module"` in package.json). - * This function checks for known entry candidates and returns the first one - * that exists on disk. - * - * @param outDir - Absolute path to the build output directory. - * @returns The absolute path to the entry file, or `undefined` when none is found. - */ -export function detectBuildEntry(outDir: string): string | undefined { - return ENTRY_CANDIDATES.map((name) => join(outDir, name)).find(existsSync) -} diff --git a/packages/bundler/tsdown.config.ts b/packages/bundler/tsdown.config.ts index 8741a7f4..43f65b15 100644 --- a/packages/bundler/tsdown.config.ts +++ b/packages/bundler/tsdown.config.ts @@ -6,7 +6,6 @@ export default defineConfig({ fixedExtension: false, outDir: 'dist', entry: { - 'bun-runner': 'src/build/bun-runner.ts', index: 'src/index.ts', }, format: 'esm', diff --git a/packages/cli/src/commands/add/command.test.ts b/packages/cli/src/commands/add/command.test.ts index 0eeff377..62505900 100644 --- a/packages/cli/src/commands/add/command.test.ts +++ b/packages/cli/src/commands/add/command.test.ts @@ -1,7 +1,7 @@ import type { CommandContext } from '@kidd-cli/core' import { beforeEach, describe, expect, it, vi } from 'vitest' -vi.mock(import('@kidd-cli/config/loader'), () => ({ +vi.mock(import('@kidd-cli/config/utils'), () => ({ loadConfig: vi.fn(), })) @@ -17,7 +17,7 @@ vi.mock(import('../../lib/write.js'), () => ({ writeFiles: vi.fn(), })) -const { loadConfig } = await import('@kidd-cli/config/loader') +const { loadConfig } = await import('@kidd-cli/config/utils') const { detectProject } = await import('../../lib/detect.js') const { renderTemplate } = await import('../../lib/render.js') const { writeFiles } = await import('../../lib/write.js') diff --git a/packages/cli/src/commands/add/command.ts b/packages/cli/src/commands/add/command.ts index 54e4ca70..7ed780e0 100644 --- a/packages/cli/src/commands/add/command.ts +++ b/packages/cli/src/commands/add/command.ts @@ -1,7 +1,7 @@ import { join } from 'node:path' -import { loadConfig } from '@kidd-cli/config/loader' -import type { LoadConfigResult } from '@kidd-cli/config/loader' +import { loadConfig } from '@kidd-cli/config/utils' +import type { LoadConfigResult } from '@kidd-cli/config/utils' import { command } from '@kidd-cli/core' import type { Command, CommandContext } from '@kidd-cli/core' import { z } from 'zod' diff --git a/packages/cli/src/commands/build.test.ts b/packages/cli/src/commands/build.test.ts index 30956033..b2c60913 100644 --- a/packages/cli/src/commands/build.test.ts +++ b/packages/cli/src/commands/build.test.ts @@ -1,13 +1,22 @@ import type { CommandContext } from '@kidd-cli/core' import { beforeEach, describe, expect, it, vi } from 'vitest' -vi.mock(import('@kidd-cli/bundler'), () => ({ - build: vi.fn(), - compile: vi.fn(), - resolveTargetLabel: vi.fn((t: string) => t), -})) +const mockBuild = vi.fn() +const mockCompile = vi.fn() -vi.mock(import('@kidd-cli/config/loader'), () => ({ +vi.mock(import('@kidd-cli/bundler'), async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + createBundler: vi.fn(async () => ({ + build: mockBuild, + compile: mockCompile, + watch: vi.fn(), + })), + } +}) + +vi.mock(import('@kidd-cli/config/utils'), () => ({ loadConfig: vi.fn(), })) @@ -15,11 +24,10 @@ vi.mock(import('@kidd-cli/core'), () => ({ command: vi.fn((def) => def), })) -const { build, compile } = await import('@kidd-cli/bundler') -const { loadConfig } = await import('@kidd-cli/config/loader') +const { createBundler } = await import('@kidd-cli/bundler') +const { loadConfig } = await import('@kidd-cli/config/utils') -const mockedBuild = vi.mocked(build) -const mockedCompile = vi.mocked(compile) +const mockedCreateBundler = vi.mocked(createBundler) const mockedLoadConfig = vi.mocked(loadConfig) function makeContext(argOverrides: Record = {}): CommandContext { @@ -61,14 +69,14 @@ function makeContext(argOverrides: Record = {}): CommandContext } function setupBuildSuccess(): void { - mockedBuild.mockResolvedValue([ + mockBuild.mockResolvedValue([ null, - { entryFile: '/project/dist/index.js', outDir: '/project/dist', version: '1.0.0' }, + { entryFile: '/project/dist/index.js', outDir: '/project/dist', version: '1.0.0', define: {} }, ]) } function setupCompileSuccess(): void { - mockedCompile.mockResolvedValue([ + mockCompile.mockResolvedValue([ null, { binaries: [ @@ -90,6 +98,11 @@ describe('build command', () => { null, { config: {}, configFile: '/project/kidd.config.ts' }, ] as never) + mockedCreateBundler.mockResolvedValue({ + build: mockBuild, + compile: mockCompile, + watch: vi.fn(), + }) }) describe('resolveCompileIntent', () => { @@ -100,7 +113,7 @@ describe('build command', () => { const mod = await import('./build.js') await mod.default.handler!(ctx) - expect(mockedCompile).not.toHaveBeenCalled() + expect(mockCompile).not.toHaveBeenCalled() expect(ctx.status.spinner.stop).toHaveBeenCalledWith('Build complete') }) @@ -112,7 +125,7 @@ describe('build command', () => { const mod = await import('./build.js') await mod.default.handler!(ctx) - expect(mockedCompile).toHaveBeenCalled() + expect(mockCompile).toHaveBeenCalled() }) it('should compile when --compile is true', async () => { @@ -123,7 +136,7 @@ describe('build command', () => { const mod = await import('./build.js') await mod.default.handler!(ctx) - expect(mockedCompile).toHaveBeenCalled() + expect(mockCompile).toHaveBeenCalled() }) it('should not compile when --compile is false', async () => { @@ -133,7 +146,7 @@ describe('build command', () => { const mod = await import('./build.js') await mod.default.handler!(ctx) - expect(mockedCompile).not.toHaveBeenCalled() + expect(mockCompile).not.toHaveBeenCalled() }) it('should compile when config.compile is true', async () => { @@ -148,7 +161,7 @@ describe('build command', () => { const mod = await import('./build.js') await mod.default.handler!(ctx) - expect(mockedCompile).toHaveBeenCalled() + expect(mockCompile).toHaveBeenCalled() }) it('should compile when config.compile is an object', async () => { @@ -166,7 +179,7 @@ describe('build command', () => { const mod = await import('./build.js') await mod.default.handler!(ctx) - expect(mockedCompile).toHaveBeenCalled() + expect(mockCompile).toHaveBeenCalled() }) }) @@ -186,8 +199,8 @@ describe('build command', () => { const mod = await import('./build.js') await mod.default.handler!(ctx) - const compileCall = mockedCompile.mock.calls[0]![0]! - expect(compileCall.config).toMatchObject({ + const bundlerCall = mockedCreateBundler.mock.calls[0]![0]! + expect(bundlerCall.config).toMatchObject({ compile: { targets: ['linux-x64'] }, }) }) @@ -207,8 +220,8 @@ describe('build command', () => { const mod = await import('./build.js') await mod.default.handler!(ctx) - const compileCall = mockedCompile.mock.calls[0]![0]! - expect(compileCall.config).toMatchObject({ + const bundlerCall = mockedCreateBundler.mock.calls[0]![0]! + expect(bundlerCall.config).toMatchObject({ compile: { targets: ['darwin-arm64', 'linux-x64'] }, }) }) @@ -238,7 +251,7 @@ describe('build command', () => { { config: { compile: true }, configFile: '/project/kidd.config.ts' }, ] as never) setupBuildSuccess() - mockedCompile.mockResolvedValue([ + mockCompile.mockResolvedValue([ null, { binaries: [ @@ -285,7 +298,7 @@ describe('build command', () => { const mod = await import('./build.js') await mod.default.handler!(ctx) - expect(mockedBuild).toHaveBeenCalledWith( + expect(mockedCreateBundler).toHaveBeenCalledWith( expect.objectContaining({ config: { entry: './src/main.ts' } }) ) }) @@ -298,14 +311,14 @@ describe('build command', () => { const mod = await import('./build.js') await mod.default.handler!(ctx) - expect(mockedBuild).toHaveBeenCalledWith(expect.objectContaining({ config: {} })) + expect(mockedCreateBundler).toHaveBeenCalledWith(expect.objectContaining({ config: {} })) }) }) describe('error handling', () => { it('should call fail when build returns an error', async () => { const ctx = makeContext() - mockedBuild.mockResolvedValue([new Error('tsdown build failed'), null]) + mockBuild.mockResolvedValue([new Error('tsdown build failed'), null]) const mod = await import('./build.js') await expect(mod.default.handler!(ctx)).rejects.toThrow('tsdown build failed') @@ -316,7 +329,7 @@ describe('build command', () => { it('should call fail when compile returns an error', async () => { const ctx = makeContext({ compile: true }) setupBuildSuccess() - mockedCompile.mockResolvedValue([new Error('bun compile failed'), null]) + mockCompile.mockResolvedValue([new Error('bun compile failed'), null]) const mod = await import('./build.js') await expect(mod.default.handler!(ctx)).rejects.toThrow('bun compile failed') diff --git a/packages/cli/src/commands/build.ts b/packages/cli/src/commands/build.ts index 6bb6e7e4..4f39e3a1 100644 --- a/packages/cli/src/commands/build.ts +++ b/packages/cli/src/commands/build.ts @@ -1,11 +1,13 @@ import { relative } from 'node:path' -import { build, compile, resolveTargetLabel } from '@kidd-cli/bundler' +import { createBundler, normalizeCompileOptions } from '@kidd-cli/bundler' import type { CompiledBinary } from '@kidd-cli/bundler' import type { CompileTarget, KiddConfig } from '@kidd-cli/config' -import { loadConfig } from '@kidd-cli/config/loader' +import { loadConfig } from '@kidd-cli/config/utils' import { command } from '@kidd-cli/core' import type { Command, CommandContext } from '@kidd-cli/core' +import pc from 'picocolors' +import { match } from 'ts-pattern' import { z } from 'zod' import { extractConfig } from '../lib/config-helpers.js' @@ -32,49 +34,59 @@ const buildCommand: Command = command({ description: 'Build a kidd CLI project for production', handler: async (ctx: CommandContext) => { const cwd = process.cwd() + const startTime = Date.now() const [, configResult] = await loadConfig({ cwd }) const config = mergeCleanOption({ config: extractConfig(configResult), clean: ctx.args.clean }) + const shouldCompile = resolveCompileIntent({ + compileFlag: ctx.args.compile, + configCompile: config.compile, + targets: ctx.args.targets, + }) + + const mergedConfig = match(shouldCompile) + .with(true, () => mergeCompileTargets({ config, targets: ctx.args.targets })) + .with(false, () => config) + .exhaustive() + + const bundler = await createBundler({ + config: mergedConfig, + cwd, + onStepStart: ({ meta }) => + ctx.status.spinner.message(`Compiling ${String(meta.label ?? 'target')}...`), + onStepFinish: ({ meta }) => + ctx.status.spinner.message(`Compiled ${String(meta.label ?? 'target')}`), + }) + ctx.status.spinner.start('Bundling...') - const [buildError, buildOutput] = await build({ config, cwd }) + const [buildError, buildOutput] = await bundler.build() if (buildError) { ctx.status.spinner.stop('Bundle failed') return ctx.fail(buildError.message) } - const shouldCompile = resolveCompileIntent({ - compileFlag: ctx.args.compile, - configCompile: config.compile, - targets: ctx.args.targets, - }) - if (!shouldCompile) { ctx.status.spinner.stop('Build complete') ctx.log.note( formatBuildNote({ cwd, + define: buildOutput.define, entryFile: buildOutput.entryFile, outDir: buildOutput.outDir, version: buildOutput.version, }), 'Bundle' ) + ctx.log.outro(formatOutroSummary({ binaries: 0, duration: Date.now() - startTime })) return } ctx.status.spinner.message('Bundled, compiling binaries...') - const mergedConfig = mergeCompileTargets({ config, targets: ctx.args.targets }) - const [compileError, compileOutput] = await compile({ - config: mergedConfig, - cwd, - onTargetComplete: (target) => - ctx.status.spinner.message(`Compiled ${resolveTargetLabel(target)}`), - onTargetStart: (target) => - ctx.status.spinner.message(`Compiling ${resolveTargetLabel(target)}...`), + const [compileError, compileOutput] = await bundler.compile({ verbose: ctx.args.verbose, }) @@ -87,6 +99,7 @@ const buildCommand: Command = command({ ctx.log.note( formatBuildNote({ cwd, + define: buildOutput.define, entryFile: buildOutput.entryFile, outDir: buildOutput.outDir, version: buildOutput.version, @@ -94,6 +107,12 @@ const buildCommand: Command = command({ 'Bundle' ) ctx.log.note(formatBinariesNote({ binaries: compileOutput.binaries, cwd }), 'Binaries') + ctx.log.outro( + formatOutroSummary({ + binaries: compileOutput.binaries.length, + duration: Date.now() - startTime, + }) + ) }, }) @@ -140,9 +159,6 @@ function resolveCompileIntent(params: { /** * Merge CLI `--targets` into the config's compile options. * - * When targets are provided via CLI, they override whatever is in config. - * Otherwise the config is returned unchanged. - * * @private * @param params - The config and optional CLI targets. * @returns A config with compile targets merged in. @@ -155,7 +171,7 @@ function mergeCompileTargets(params: { return params.config } - const existingCompile = resolveExistingCompile(params.config.compile) + const existingCompile = normalizeCompileOptions(params.config.compile) return { ...params.config, @@ -166,32 +182,9 @@ function mergeCompileTargets(params: { } } -/** - * Extract compile options object from the config's compile field. - * - * Returns the object as-is when it is an object, or an empty object - * for boolean / undefined values. - * - * @private - * @param value - The raw compile config value. - * @returns A compile options object. - */ -function resolveExistingCompile( - value: boolean | KiddConfig['compile'] -): Exclude { - if (typeof value === 'object') { - return value - } - - return {} -} - /** * Merge the CLI `--clean` / `--no-clean` flag into the loaded config. * - * When the flag is provided it overrides whatever is in config. - * Otherwise the config value is used unchanged. - * * @private * @param params - The loaded config and optional CLI clean flag. * @returns A config with the clean option merged in. @@ -225,11 +218,13 @@ function formatBuildNote(params: { readonly outDir: string readonly cwd: string readonly version: string | undefined + readonly define: Readonly> }): string { return [ `entry ${relative(params.cwd, params.entryFile)}`, `output ${relative(params.cwd, params.outDir)}`, ...formatVersionLine(params.version), + ...formatDefineLines(params.define), ].join('\n') } @@ -248,6 +243,56 @@ function formatVersionLine(version: string | undefined): string[] { return [`version ${version}`] } +/** + * Format define constants into display lines. + * + * Omits `__KIDD_VERSION__` (already shown as `version`) and returns + * remaining entries as `define key = value` lines. + * + * @private + * @param define - The resolved define map. + * @returns An array of formatted lines (empty when no user-defined constants). + */ +/** + * Format the outro summary line with build stats. + * + * @private + * @param params - Build stats for the summary. + * @returns A formatted inline summary string. + */ +function formatOutroSummary(params: { + readonly binaries: number + readonly duration: number +}): string { + const stats = [ + ...match(params.binaries > 0) + .with(true, () => [`${params.binaries} binaries compiled`]) + .with(false, () => []) + .exhaustive(), + `finished in ${formatDuration(params.duration)}`, + ] + + return stats.join(pc.gray(' · ')) +} + +/** + * Format a millisecond duration into a human-readable string. + * + * @private + * @param ms - Duration in milliseconds. + * @returns A formatted duration string (e.g. "1.2s", "350ms"). + */ +function formatDuration(ms: number): string { + return match(ms >= 1000) + .with(true, () => `${(ms / 1000).toFixed(1)}s`) + .with(false, () => `${ms}ms`) + .exhaustive() +} + +function formatDefineLines(define: Readonly>): string[] { + return Object.entries(define).map(([key, value]) => `build_var ${key} = ${value}`) +} + /** * Format compiled binaries into an aligned, multi-line string for display. * diff --git a/packages/cli/src/commands/commands.test.ts b/packages/cli/src/commands/commands.test.ts index efe62db7..c0ac8f4b 100644 --- a/packages/cli/src/commands/commands.test.ts +++ b/packages/cli/src/commands/commands.test.ts @@ -1,11 +1,13 @@ import type { CommandContext } from '@kidd-cli/core' import { beforeEach, describe, expect, it, vi } from 'vitest' -vi.mock(import('node:fs'), () => ({ - existsSync: vi.fn(), +const mockFsExists = vi.fn() + +vi.mock(import('@kidd-cli/utils/node'), () => ({ + fs: { exists: mockFsExists }, })) -vi.mock(import('@kidd-cli/config/loader'), () => ({ +vi.mock(import('@kidd-cli/config/utils'), () => ({ loadConfig: vi.fn(), })) @@ -14,10 +16,8 @@ vi.mock(import('@kidd-cli/core'), () => ({ command: vi.fn((def) => def), })) -const { existsSync } = await import('node:fs') -const { loadConfig } = await import('@kidd-cli/config/loader') +const { loadConfig } = await import('@kidd-cli/config/utils') const { autoload } = await import('@kidd-cli/core') -const mockedExistsSync = vi.mocked(existsSync) const mockedLoadConfig = vi.mocked(loadConfig) const mockedAutoload = vi.mocked(autoload) @@ -63,7 +63,7 @@ describe('commands command', () => { it('should fail when commands directory not found', async () => { const ctx = makeContext() mockedLoadConfig.mockResolvedValue([null, { config: {} }] as never) - mockedExistsSync.mockReturnValue(false) + mockFsExists.mockResolvedValue(false) const mod = await import('./commands.js') @@ -73,7 +73,7 @@ describe('commands command', () => { it('should display "No commands found" when autoload returns empty map', async () => { const ctx = makeContext() mockedLoadConfig.mockResolvedValue([null, { config: {} }] as never) - mockedExistsSync.mockReturnValue(true) + mockFsExists.mockResolvedValue(true) mockedAutoload.mockResolvedValue({}) const mod = await import('./commands.js') @@ -85,7 +85,7 @@ describe('commands command', () => { it('should render single command with description', async () => { const ctx = makeContext() mockedLoadConfig.mockResolvedValue([null, { config: {} }] as never) - mockedExistsSync.mockReturnValue(true) + mockFsExists.mockResolvedValue(true) mockedAutoload.mockResolvedValue({ deploy: { description: 'Deploy the app' }, } as never) @@ -99,7 +99,7 @@ describe('commands command', () => { it('should render command without description', async () => { const ctx = makeContext() mockedLoadConfig.mockResolvedValue([null, { config: {} }] as never) - mockedExistsSync.mockReturnValue(true) + mockFsExists.mockResolvedValue(true) mockedAutoload.mockResolvedValue({ build: {}, } as never) @@ -113,7 +113,7 @@ describe('commands command', () => { it('should render multiple commands sorted alphabetically', async () => { const ctx = makeContext() mockedLoadConfig.mockResolvedValue([null, { config: {} }] as never) - mockedExistsSync.mockReturnValue(true) + mockFsExists.mockResolvedValue(true) mockedAutoload.mockResolvedValue({ add: { description: 'Add' }, build: { description: 'Build' }, @@ -130,7 +130,7 @@ describe('commands command', () => { it('should use continuation connector for non-final entries', async () => { const ctx = makeContext() mockedLoadConfig.mockResolvedValue([null, { config: {} }] as never) - mockedExistsSync.mockReturnValue(true) + mockFsExists.mockResolvedValue(true) mockedAutoload.mockResolvedValue({ alpha: { description: 'First' }, beta: { description: 'Second' }, @@ -146,7 +146,7 @@ describe('commands command', () => { it('should use last-item connector for final entry', async () => { const ctx = makeContext() mockedLoadConfig.mockResolvedValue([null, { config: {} }] as never) - mockedExistsSync.mockReturnValue(true) + mockFsExists.mockResolvedValue(true) mockedAutoload.mockResolvedValue({ alpha: { description: 'First' }, beta: { description: 'Second' }, @@ -162,7 +162,7 @@ describe('commands command', () => { it('should render nested subcommands with tree connectors', async () => { const ctx = makeContext() mockedLoadConfig.mockResolvedValue([null, { config: {} }] as never) - mockedExistsSync.mockReturnValue(true) + mockFsExists.mockResolvedValue(true) mockedAutoload.mockResolvedValue({ parent: { commands: { @@ -187,7 +187,7 @@ describe('commands command', () => { it('should handle deeply nested commands with three levels', async () => { const ctx = makeContext() mockedLoadConfig.mockResolvedValue([null, { config: {} }] as never) - mockedExistsSync.mockReturnValue(true) + mockFsExists.mockResolvedValue(true) mockedAutoload.mockResolvedValue({ root: { commands: { @@ -214,7 +214,7 @@ describe('commands command', () => { it('should use config commands directory when available', async () => { const ctx = makeContext() mockedLoadConfig.mockResolvedValue([null, { config: { commands: 'src/cmds' } }] as never) - mockedExistsSync.mockReturnValue(true) + mockFsExists.mockResolvedValue(true) mockedAutoload.mockResolvedValue({}) const mod = await import('./commands.js') @@ -227,7 +227,7 @@ describe('commands command', () => { it('should default to commands directory when config has no commands field', async () => { const ctx = makeContext() mockedLoadConfig.mockResolvedValue([null, { config: {} }] as never) - mockedExistsSync.mockReturnValue(true) + mockFsExists.mockResolvedValue(true) mockedAutoload.mockResolvedValue({}) const mod = await import('./commands.js') @@ -240,7 +240,7 @@ describe('commands command', () => { it('should handle config load error gracefully with defaults', async () => { const ctx = makeContext() mockedLoadConfig.mockResolvedValue([new Error('no config'), null] as never) - mockedExistsSync.mockReturnValue(true) + mockFsExists.mockResolvedValue(true) mockedAutoload.mockResolvedValue({}) const mod = await import('./commands.js') @@ -252,7 +252,7 @@ describe('commands command', () => { it('should render sibling and nested commands with correct prefixes', async () => { const ctx = makeContext() mockedLoadConfig.mockResolvedValue([null, { config: {} }] as never) - mockedExistsSync.mockReturnValue(true) + mockFsExists.mockResolvedValue(true) mockedAutoload.mockResolvedValue({ alpha: { commands: { @@ -273,7 +273,7 @@ describe('commands command', () => { it('should respect subcommand order when specified', async () => { const ctx = makeContext() mockedLoadConfig.mockResolvedValue([null, { config: {} }] as never) - mockedExistsSync.mockReturnValue(true) + mockFsExists.mockResolvedValue(true) mockedAutoload.mockResolvedValue({ deploy: { commands: { @@ -302,7 +302,7 @@ describe('commands command', () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) const ctx = makeContext() mockedLoadConfig.mockResolvedValue([null, { config: {} }] as never) - mockedExistsSync.mockReturnValue(true) + mockFsExists.mockResolvedValue(true) mockedAutoload.mockResolvedValue({ parent: { commands: { @@ -330,7 +330,7 @@ describe('commands command', () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) const ctx = makeContext() mockedLoadConfig.mockResolvedValue([null, { config: {} }] as never) - mockedExistsSync.mockReturnValue(true) + mockFsExists.mockResolvedValue(true) mockedAutoload.mockResolvedValue({ parent: { commands: { diff --git a/packages/cli/src/commands/commands.ts b/packages/cli/src/commands/commands.ts index e74d1094..e95d64e6 100644 --- a/packages/cli/src/commands/commands.ts +++ b/packages/cli/src/commands/commands.ts @@ -1,9 +1,9 @@ -import { existsSync } from 'node:fs' import { join } from 'node:path' -import { loadConfig } from '@kidd-cli/config/loader' +import { loadConfig } from '@kidd-cli/config/utils' import { autoload, command } from '@kidd-cli/core' import type { Command as KiddCommand, CommandContext } from '@kidd-cli/core' +import { fs } from '@kidd-cli/utils/node' import { extractConfig } from '../lib/config-helpers.js' @@ -33,7 +33,7 @@ const commandsCommand: KiddCommand = command({ const commandsDir = join(cwd, config.commands ?? 'commands') - if (!existsSync(commandsDir)) { + if (!(await fs.exists(commandsDir))) { return ctx.fail(`Commands directory not found: ${commandsDir}`) } diff --git a/packages/cli/src/commands/dev.test.ts b/packages/cli/src/commands/dev.test.ts index 6c744085..08ef06f2 100644 --- a/packages/cli/src/commands/dev.test.ts +++ b/packages/cli/src/commands/dev.test.ts @@ -1,11 +1,17 @@ import type { CommandContext } from '@kidd-cli/core' import { beforeEach, describe, expect, it, vi } from 'vitest' +const mockWatch = vi.fn() + vi.mock(import('@kidd-cli/bundler'), () => ({ - watch: vi.fn(), + createBundler: vi.fn(async () => ({ + build: vi.fn(), + compile: vi.fn(), + watch: mockWatch, + })), })) -vi.mock(import('@kidd-cli/config/loader'), () => ({ +vi.mock(import('@kidd-cli/config/utils'), () => ({ loadConfig: vi.fn(), })) @@ -13,9 +19,9 @@ vi.mock(import('@kidd-cli/core'), () => ({ command: vi.fn((def) => def), })) -const { watch } = await import('@kidd-cli/bundler') -const { loadConfig } = await import('@kidd-cli/config/loader') -const mockedWatch = vi.mocked(watch) +const { createBundler } = await import('@kidd-cli/bundler') +const { loadConfig } = await import('@kidd-cli/config/utils') +const mockedCreateBundler = vi.mocked(createBundler) const mockedLoadConfig = vi.mocked(loadConfig) function makeContext(): CommandContext { @@ -53,12 +59,17 @@ function makeContext(): CommandContext { describe('dev command', () => { beforeEach(() => { vi.clearAllMocks() + mockedCreateBundler.mockResolvedValue({ + build: vi.fn(), + compile: vi.fn(), + watch: mockWatch, + }) }) it('should start spinner with dev server message', async () => { const ctx = makeContext() mockedLoadConfig.mockResolvedValue([null, { config: {}, configFile: undefined }] as never) - mockedWatch.mockResolvedValue([null, undefined] as never) + mockWatch.mockResolvedValue([null, undefined] as never) const mod = await import('./dev.js') await mod.default.handler!(ctx) @@ -69,8 +80,8 @@ describe('dev command', () => { it('should stop spinner with watching message on first build success', async () => { const ctx = makeContext() mockedLoadConfig.mockResolvedValue([null, { config: {}, configFile: undefined }] as never) - mockedWatch.mockImplementation(async (opts) => { - if (opts.onSuccess) { + mockWatch.mockImplementation(async (opts) => { + if (opts?.onSuccess) { opts.onSuccess() } return [null, undefined] as never @@ -85,8 +96,8 @@ describe('dev command', () => { it('should log rebuilt successfully on subsequent builds', async () => { const ctx = makeContext() mockedLoadConfig.mockResolvedValue([null, { config: {}, configFile: undefined }] as never) - mockedWatch.mockImplementation(async (opts) => { - if (opts.onSuccess) { + mockWatch.mockImplementation(async (opts) => { + if (opts?.onSuccess) { opts.onSuccess() opts.onSuccess() } @@ -102,7 +113,7 @@ describe('dev command', () => { it('should call fail when watch returns an error', async () => { const ctx = makeContext() mockedLoadConfig.mockResolvedValue([null, { config: {}, configFile: undefined }] as never) - mockedWatch.mockResolvedValue([new Error('tsdown watch failed'), null] as never) + mockWatch.mockResolvedValue([new Error('tsdown watch failed'), null] as never) const mod = await import('./dev.js') await mod.default.handler!(ctx) @@ -114,11 +125,11 @@ describe('dev command', () => { it('should use empty config when loadConfig returns error', async () => { const ctx = makeContext() mockedLoadConfig.mockResolvedValue([new Error('no config'), null] as never) - mockedWatch.mockResolvedValue([null, undefined] as never) + mockWatch.mockResolvedValue([null, undefined] as never) const mod = await import('./dev.js') await mod.default.handler!(ctx) - expect(mockedWatch).toHaveBeenCalledWith(expect.objectContaining({ config: {} })) + expect(mockedCreateBundler).toHaveBeenCalledWith(expect.objectContaining({ config: {} })) }) }) diff --git a/packages/cli/src/commands/dev.ts b/packages/cli/src/commands/dev.ts index a7893089..dd45bdce 100644 --- a/packages/cli/src/commands/dev.ts +++ b/packages/cli/src/commands/dev.ts @@ -1,5 +1,5 @@ -import { watch } from '@kidd-cli/bundler' -import { loadConfig } from '@kidd-cli/config/loader' +import { createBundler } from '@kidd-cli/bundler' +import { loadConfig } from '@kidd-cli/config/utils' import { command } from '@kidd-cli/core' import type { Command, CommandContext } from '@kidd-cli/core' @@ -21,9 +21,10 @@ const devCommand: Command = command({ ctx.status.spinner.start('Starting dev server...') + const bundler = await createBundler({ config, cwd }) const onSuccess = createOnSuccess(ctx) - const [watchError] = await watch({ config, cwd, onSuccess }) + const [watchError] = await bundler.watch({ onSuccess }) if (watchError) { ctx.status.spinner.stop('Watch failed') diff --git a/packages/cli/src/commands/doctor.test.ts b/packages/cli/src/commands/doctor.test.ts index 0228369f..d2a63c9e 100644 --- a/packages/cli/src/commands/doctor.test.ts +++ b/packages/cli/src/commands/doctor.test.ts @@ -3,7 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { CheckContext, CheckResult, DiagnosticCheck, FixResult } from '../lib/checks.js' -vi.mock(import('@kidd-cli/config/loader'), () => ({ +vi.mock(import('@kidd-cli/config/utils'), () => ({ loadConfig: vi.fn(), })) @@ -34,7 +34,7 @@ vi.mock(import('picocolors'), () => { } as never }) -const { loadConfig } = await import('@kidd-cli/config/loader') +const { loadConfig } = await import('@kidd-cli/config/utils') const { readManifest } = await import('@kidd-cli/utils/manifest') const { CHECKS, createCheckContext, readRawPackageJson } = await import('../lib/checks.js') diff --git a/packages/cli/src/commands/doctor.ts b/packages/cli/src/commands/doctor.ts index 58667bb9..2f5eb416 100644 --- a/packages/cli/src/commands/doctor.ts +++ b/packages/cli/src/commands/doctor.ts @@ -1,4 +1,4 @@ -import { loadConfig } from '@kidd-cli/config/loader' +import { loadConfig } from '@kidd-cli/config/utils' import { command } from '@kidd-cli/core' import type { Command, CommandContext } from '@kidd-cli/core' import { match } from '@kidd-cli/utils/fp' diff --git a/packages/cli/src/commands/run.ts b/packages/cli/src/commands/run.ts index ad2205e6..34d8e214 100644 --- a/packages/cli/src/commands/run.ts +++ b/packages/cli/src/commands/run.ts @@ -1,30 +1,24 @@ -import { spawn } from 'node:child_process' import { resolve } from 'node:path' -import { build, compile } from '@kidd-cli/bundler' -import type { BuildOutput, CompileOutput, CompiledBinary } from '@kidd-cli/bundler' +import { createBundler, DEFAULT_ENTRY, normalizeCompileOptions } from '@kidd-cli/bundler' +import type { BuildOutput, Bundler, CompileOutput, CompiledBinary } from '@kidd-cli/bundler' import type { CompileTarget, KiddConfig } from '@kidd-cli/config' -import { loadConfig } from '@kidd-cli/config/loader' +import { compileTargets, loadConfig } from '@kidd-cli/config/utils' import { command } from '@kidd-cli/core' import type { Command, CommandContext } from '@kidd-cli/core' +import { process as proc } from '@kidd-cli/utils/node' import { match } from 'ts-pattern' import { z } from 'zod' import { extractConfig } from '../lib/config-helpers.js' -const DEFAULT_ENTRY = './src/index.ts' - const EngineSchema = z.enum(['node', 'tsx', 'binary']) -const TargetSchema = z.enum([ - 'darwin-arm64', - 'darwin-x64', - 'linux-arm64', - 'linux-x64', - 'linux-x64-musl', - 'windows-arm64', - 'windows-x64', -]) +const compileTargetValues = compileTargets.map((entry) => entry.target) as [ + CompileTarget, + ...CompileTarget[], +] +const TargetSchema = z.enum(compileTargetValues) const options = z.object({ engine: EngineSchema.default('node').describe( @@ -78,7 +72,7 @@ const runCommand: Command = command({ ) } - const passthroughArgs = extractPassthroughArgs({ knownArgs: ctx.args }) + const passthroughArgs = extractPassthroughArgs() const exitCode = await match(ctx.args.engine) .with('node', () => runWithNode({ args: ctx.args, config, cwd, passthroughArgs, ctx })) @@ -104,7 +98,8 @@ export default runCommand * @returns The exit code of the spawned process. */ async function runWithNode(params: EngineParams): Promise { - const buildOutput = await buildProject(params) + const bundler = await createBundler({ config: params.config, cwd: params.cwd }) + const buildOutput = await buildProject({ bundler, ctx: params.ctx }) const inspectFlags = buildInspectFlags(params.args) return spawnProcess({ @@ -157,15 +152,13 @@ async function runWithBinary(params: EngineParams): Promise { ) } - await buildProject({ ...params, config: configWithTarget }) + const bundler = await createBundler({ config: configWithTarget, cwd: params.cwd }) + + await buildProject({ bundler, ctx: params.ctx }) params.ctx.status.spinner.message('Compiling binary...') - const compileOutput = await compileProject({ - config: configWithTarget, - ctx: params.ctx, - cwd: params.cwd, - }) + const compileOutput = await compileProject({ bundler, ctx: params.ctx }) const binary = resolveBinary({ compileOutput, @@ -201,17 +194,16 @@ interface EngineParams { * Starts a spinner, invokes the build, and fails the command on error. * * @private - * @param params - The config, cwd, and command context. + * @param params - The bundler instance and command context. * @returns The successful build output. */ async function buildProject(params: { - readonly config: KiddConfig + readonly bundler: Bundler readonly ctx: CommandContext - readonly cwd: string }): Promise { params.ctx.status.spinner.start('Building...') - const [buildError, buildOutput] = await build({ config: params.config, cwd: params.cwd }) + const [buildError, buildOutput] = await params.bundler.build() if (buildError) { params.ctx.status.spinner.stop('Build failed') @@ -227,18 +219,14 @@ async function buildProject(params: { * Compile the project into standalone binaries. * * @private - * @param params - The config, cwd, and command context. + * @param params - The bundler instance and command context. * @returns The successful compile output. */ async function compileProject(params: { - readonly config: KiddConfig + readonly bundler: Bundler readonly ctx: CommandContext - readonly cwd: string }): Promise { - const [compileError, compileOutput] = await compile({ - config: params.config, - cwd: params.cwd, - }) + const [compileError, compileOutput] = await params.bundler.compile() if (compileError) { params.ctx.status.spinner.stop('Compile failed') @@ -429,7 +417,7 @@ function applyTargetOverride(params: { return params.config } - const existingCompile = resolveExistingCompile(params.config.compile) + const existingCompile = normalizeCompileOptions(params.config.compile) return { ...params.config, @@ -440,26 +428,6 @@ function applyTargetOverride(params: { } } -/** - * Extract compile options from the config's compile field. - * - * Returns the object as-is when it is an object, or an empty object - * for boolean / undefined values. - * - * @private - * @param value - The raw compile config value. - * @returns A compile options object. - */ -function resolveExistingCompile( - value: KiddConfig['compile'] -): Exclude { - if (typeof value === 'object') { - return value - } - - return {} -} - /** * Extract passthrough arguments by sequentially walking argv tokens * and skipping known `kidd run` flags and their values. @@ -470,11 +438,9 @@ function resolveExistingCompile( * consume user CLI arguments that happen to match a known flag's value. * * @private - * @param params - The known parsed args (unused but kept for API stability). * @returns An array of arguments to forward to the user's CLI. */ -function extractPassthroughArgs(params: { readonly knownArgs: RunArgs }): readonly string[] { - void params.knownArgs +function extractPassthroughArgs(): readonly string[] { const argv = process.argv.slice(2) const runIndex = argv.indexOf('run') @@ -642,8 +608,6 @@ function formatInspectFlag(flag: string, port: number | undefined): string { /** * Spawn a process with the given command and arguments, inheriting stdio. * - * Returns a promise that resolves to the exit code of the child process. - * * @private * @param params - The command, arguments, and working directory. * @returns The exit code of the spawned process. @@ -653,19 +617,5 @@ function spawnProcess(params: { readonly args: readonly string[] readonly cwd: string }): Promise { - return new Promise((_resolve) => { - const child = spawn(params.cmd, [...params.args], { - cwd: params.cwd, - stdio: 'inherit', - }) - - child.on('error', (spawnError) => { - console.error(`Failed to spawn "${params.cmd}": ${spawnError.message}`) - _resolve(1) - }) - - child.on('close', (code) => { - _resolve(code ?? 1) - }) - }) + return proc.spawn(params) } diff --git a/packages/cli/src/lib/checks.test.ts b/packages/cli/src/lib/checks.test.ts index 2a819fd6..ee8b7a62 100644 --- a/packages/cli/src/lib/checks.test.ts +++ b/packages/cli/src/lib/checks.test.ts @@ -1,6 +1,6 @@ import { access, mkdir, readFile, writeFile } from 'node:fs/promises' -import type { LoadConfigResult } from '@kidd-cli/config/loader' +import type { LoadConfigResult } from '@kidd-cli/config/utils' import type { Manifest } from '@kidd-cli/utils/manifest' import { beforeEach, describe, expect, it, vi } from 'vitest' diff --git a/packages/cli/src/lib/checks.ts b/packages/cli/src/lib/checks.ts index 43367b3e..66852a70 100644 --- a/packages/cli/src/lib/checks.ts +++ b/packages/cli/src/lib/checks.ts @@ -1,26 +1,12 @@ -import { mkdir, readFile, writeFile } from 'node:fs/promises' import { dirname, join, relative } from 'node:path' -import type { LoadConfigResult } from '@kidd-cli/config/loader' -import { attemptAsync, err, ok } from '@kidd-cli/utils/fp' -import type { AsyncResult } from '@kidd-cli/utils/fp' -import { fileExists } from '@kidd-cli/utils/fs' +import { DEFAULT_COMMANDS, DEFAULT_ENTRY } from '@kidd-cli/bundler' +import type { LoadConfigResult } from '@kidd-cli/config/utils' +import { err, ok } from '@kidd-cli/utils/fp' +import type { ResultAsync } from '@kidd-cli/utils/fp' import { jsonParse, jsonStringify } from '@kidd-cli/utils/json' import type { Manifest } from '@kidd-cli/utils/manifest' - -// --------------------------------------------------------------------------- -// Constants -// --------------------------------------------------------------------------- - -/** - * Default entry point for the CLI source. - */ -const DEFAULT_ENTRY = './src/index.ts' - -/** - * Default directory for CLI commands. - */ -const DEFAULT_COMMANDS = './commands' +import { fs } from '@kidd-cli/utils/node' // --------------------------------------------------------------------------- // Types @@ -326,7 +312,7 @@ async function checkEntryPoint(context: CheckContext): Promise { const entryPath = resolveEntryPath(config) const absolutePath = join(context.cwd, entryPath) - const exists = await fileExists(absolutePath) + const exists = await fs.exists(absolutePath) if (exists) { return checkResult({ message: `Found: ${entryPath}`, name: 'entry point', status: 'pass' }) @@ -361,7 +347,7 @@ async function checkCommandsDirectory(context: CheckContext): Promise { - const exists = await fileExists(join(context.cwd, 'tsconfig.json')) + const exists = await fs.exists(join(context.cwd, 'tsconfig.json')) if (exists) { return checkResult({ message: 'Found', name: 'tsconfig.json', status: 'pass' }) @@ -478,10 +464,7 @@ async function fixEntryPoint(context: CheckContext): Promise { const entryPath = resolveEntryPath(config) const absolutePath = join(context.cwd, entryPath) - const [mkdirError] = await attemptAsync(() => - mkdir(dirname(absolutePath), { recursive: true }) - ) - + const [mkdirError] = await fs.mkdir(dirname(absolutePath)) if (mkdirError) { return fixResult({ fixed: false, @@ -491,10 +474,7 @@ async function fixEntryPoint(context: CheckContext): Promise { } const content = `import { create } from '@kidd-cli/core'\n` - const [writeError] = await attemptAsync(() => - writeFile(absolutePath, content, 'utf8') - ) - + const [writeError] = await fs.write(absolutePath, content) if (writeError) { return fixResult({ fixed: false, @@ -518,10 +498,7 @@ async function fixCommandsDirectory(context: CheckContext): Promise { const commandsPath = resolveCommandsPath(config) const absolutePath = join(context.cwd, commandsPath) - const [mkdirError] = await attemptAsync(() => - mkdir(absolutePath, { recursive: true }) - ) - + const [mkdirError] = await fs.mkdir(absolutePath) if (mkdirError) { return fixResult({ fixed: false, @@ -560,10 +537,9 @@ export const CHECKS: readonly DiagnosticCheck[] = [ * @param cwd - The directory to read from. * @returns A Result tuple with the raw package.json data or an error message. */ -export async function readRawPackageJson(cwd: string): AsyncResult { +export async function readRawPackageJson(cwd: string): ResultAsync { const filePath = join(cwd, 'package.json') - const [readError, content] = await attemptAsync(() => readFile(filePath, 'utf8')) - + const [readError, content] = await fs.read(filePath) if (readError) { return err(`Failed to read package.json: ${readError.message}`) } @@ -637,17 +613,15 @@ function resolveCommandsPath(config: LoadConfigResult['config'] | null): string async function updatePackageJson( cwd: string, transform: (data: Record) => Record -): AsyncResult { +): ResultAsync { const filePath = join(cwd, 'package.json') - const [readError, content] = await attemptAsync(() => readFile(filePath, 'utf8')) - + const [readError, content] = await fs.read(filePath) if (readError) { return err(`Failed to read package.json: ${readError.message}`) } const [parseError, data] = jsonParse(content) - if (parseError) { return err(parseError) } @@ -655,15 +629,11 @@ async function updatePackageJson( const updated = transform(data as Record) const [stringifyError, json] = jsonStringify(updated, { pretty: true }) - if (stringifyError) { return err(stringifyError) } - const [writeError] = await attemptAsync(() => - writeFile(filePath, `${json}\n`, 'utf8') - ) - + const [writeError] = await fs.write(filePath, `${json}\n`) if (writeError) { return err(`Failed to write package.json: ${writeError.message}`) } diff --git a/packages/cli/src/lib/config-helpers.ts b/packages/cli/src/lib/config-helpers.ts index 8e6f21d6..e782a15c 100644 --- a/packages/cli/src/lib/config-helpers.ts +++ b/packages/cli/src/lib/config-helpers.ts @@ -1,5 +1,5 @@ import type { KiddConfig } from '@kidd-cli/config' -import type { LoadConfigResult } from '@kidd-cli/config/loader' +import type { LoadConfigResult } from '@kidd-cli/config/utils' /** * Extract a KiddConfig from a load result, falling back to empty defaults. diff --git a/packages/cli/src/lib/detect.ts b/packages/cli/src/lib/detect.ts index edc2678a..1eb8a228 100644 --- a/packages/cli/src/lib/detect.ts +++ b/packages/cli/src/lib/detect.ts @@ -1,10 +1,9 @@ -import { readFile } from 'node:fs/promises' import { join } from 'node:path' -import { attemptAsync, ok, toError } from '@kidd-cli/utils/fp' -import type { AsyncResult } from '@kidd-cli/utils/fp' -import { fileExists } from '@kidd-cli/utils/fs' +import { ok } from '@kidd-cli/utils/fp' +import type { ResultAsync } from '@kidd-cli/utils/fp' import { jsonParse } from '@kidd-cli/utils/json' +import { fs } from '@kidd-cli/utils/node' import type { GenerateError, ProjectInfo } from './types.js' @@ -17,10 +16,10 @@ import type { GenerateError, ProjectInfo } from './types.js' * @param cwd - The directory to inspect. * @returns An async Result containing project info or null when no kidd project is found. */ -export async function detectProject(cwd: string): AsyncResult { +export async function detectProject(cwd: string): ResultAsync { const packageJsonPath = join(cwd, 'package.json') - const exists = await fileExists(packageJsonPath) - if (!exists) { + const packageExists = await fs.exists(packageJsonPath) + if (!packageExists) { return ok(null) } @@ -38,7 +37,7 @@ export async function detectProject(cwd: string): AsyncResult @@ -76,12 +71,12 @@ interface PackageJson { * @returns A Result tuple with the parsed package data or a GenerateError. * @private */ -async function readPackageJson(filePath: string): AsyncResult { - const [readError, content] = await attemptAsync(() => readFile(filePath, 'utf8')) +async function readPackageJson(filePath: string): ResultAsync { + const [readError, content] = await fs.read(filePath) if (readError) { return [ { - message: `Failed to read package.json: ${toError(readError).message}`, + message: `Failed to read package.json: ${readError.message}`, path: filePath, type: 'read_error' as const, }, @@ -89,11 +84,11 @@ async function readPackageJson(filePath: string): AsyncResult { +): ResultAsync { const engine = new Liquid({ root: params.templateDir }) const entries = await collectLiquidFiles(params.templateDir) @@ -81,7 +81,7 @@ async function renderSingleFile( engine: Liquid, absolutePath: string, variables: Record -): AsyncResult { +): ResultAsync { const [renderError, content] = await attemptAsync(async () => { const template = await readFile(absolutePath, 'utf8') return engine.parseAndRender(template, variables) diff --git a/packages/cli/src/lib/write.ts b/packages/cli/src/lib/write.ts index 88a779ca..2dea6dc4 100644 --- a/packages/cli/src/lib/write.ts +++ b/packages/cli/src/lib/write.ts @@ -1,9 +1,8 @@ -import { mkdir, writeFile } from 'node:fs/promises' import { dirname, join } from 'node:path' -import { attemptAsync, ok, toError } from '@kidd-cli/utils/fp' -import type { AsyncResult } from '@kidd-cli/utils/fp' -import { fileExists } from '@kidd-cli/utils/fs' +import { ok } from '@kidd-cli/utils/fp' +import type { ResultAsync } from '@kidd-cli/utils/fp' +import { fs } from '@kidd-cli/utils/node' import type { GenerateError, RenderedFile, WriteFilesParams, WriteResult } from './types.js' @@ -19,7 +18,7 @@ import type { GenerateError, RenderedFile, WriteFilesParams, WriteResult } from */ export async function writeFiles( params: WriteFilesParams -): AsyncResult { +): ResultAsync { const results = await Promise.all( params.files.map((file) => writeSingleFile(file, params.outputDir, params.overwrite)) ) @@ -42,10 +41,6 @@ export async function writeFiles( return ok({ skipped, written }) } -// --------------------------------------------------------------------------- -// Private helpers -// --------------------------------------------------------------------------- - /** * Status of a single file write operation. * @@ -69,20 +64,19 @@ async function writeSingleFile( file: RenderedFile, outputDir: string, overwrite: boolean -): AsyncResult { +): ResultAsync { const targetPath = join(outputDir, file.relativePath) - const exists = await fileExists(targetPath) - if (exists && !overwrite) { + const pathExists = await fs.exists(targetPath) + if (pathExists && !overwrite) { return ok({ action: 'skipped' as const, path: file.relativePath }) } - const parentDir = dirname(targetPath) - const [mkdirError] = await attemptAsync(() => mkdir(parentDir, { recursive: true })) + const [mkdirError] = await fs.mkdir(dirname(targetPath)) if (mkdirError) { return [ { - message: `Failed to write file: ${toError(mkdirError).message}`, + message: `Failed to write file: ${mkdirError.message}`, path: targetPath, type: 'write_error' as const, }, @@ -90,11 +84,11 @@ async function writeSingleFile( ] } - const [writeError] = await attemptAsync(() => writeFile(targetPath, file.content, 'utf8')) + const [writeError] = await fs.write(targetPath, file.content) if (writeError) { return [ { - message: `Failed to write file: ${toError(writeError).message}`, + message: `Failed to write file: ${writeError.message}`, path: targetPath, type: 'write_error' as const, }, diff --git a/packages/cli/src/manifest.ts b/packages/cli/src/manifest.ts index 39920585..ab7e1db0 100644 --- a/packages/cli/src/manifest.ts +++ b/packages/cli/src/manifest.ts @@ -1,6 +1,6 @@ import { join } from 'node:path' -import type { AsyncResult } from '@kidd-cli/utils/fp' +import type { ResultAsync } from '@kidd-cli/utils/fp' import { err, ok } from '@kidd-cli/utils/fp' import { readManifest } from '@kidd-cli/utils/manifest' @@ -24,7 +24,7 @@ export interface CLIManifest { * @param baseDir - The directory the CLI entry file lives in (typically `import.meta.dirname`). * @returns A Result tuple: error on failure, validated {@link CLIManifest} on success. */ -export async function readCLIManifest(baseDir: string): AsyncResult { +export async function readCLIManifest(baseDir: string): ResultAsync { const [manifestError, manifest] = await readManifest(join(baseDir, '..')) if (manifestError) { diff --git a/packages/config/package.json b/packages/config/package.json index cc10b3d6..ecaa01b0 100644 --- a/packages/config/package.json +++ b/packages/config/package.json @@ -29,9 +29,9 @@ "types": "./dist/index.d.ts", "default": "./dist/index.js" }, - "./loader": { - "types": "./dist/loader.d.ts", - "default": "./dist/loader.js" + "./utils": { + "types": "./dist/utils.d.ts", + "default": "./dist/utils.js" } }, "scripts": { diff --git a/packages/config/src/index.ts b/packages/config/src/index.ts index abfcaec7..109b4e07 100644 --- a/packages/config/src/index.ts +++ b/packages/config/src/index.ts @@ -1,2 +1,3 @@ +export type { CompileTarget } from './utils/compile.js' export { defineConfig } from './define-config.js' -export type { BuildOptions, CompileOptions, CompileTarget, KiddConfig } from './types.js' +export type { BuildOptions, CompileOptions, KiddConfig } from './types.js' diff --git a/packages/config/src/types.ts b/packages/config/src/types.ts index 524d4cf6..c6491410 100644 --- a/packages/config/src/types.ts +++ b/packages/config/src/types.ts @@ -1,3 +1,5 @@ +import type { CompileTarget } from './utils/compile.js' + /** * Build options passed to tsdown during `kidd build`. */ @@ -30,6 +32,16 @@ export interface BuildOptions { * and a warning is printed. */ readonly clean?: boolean + /** + * Compile-time constants injected via rolldown `define`. + * + * Keys are replaced in source code at build time with the corresponding + * string values. Values should be JSON-stringified when embedding strings + * (e.g. `JSON.stringify('my-value')`). + * + * Merged with auto-resolved `KIDD_PUBLIC_*` env vars (explicit takes precedence). + */ + define?: Record } /** @@ -50,18 +62,6 @@ export interface CompileOptions { name?: string } -/** - * Supported cross-compilation targets for `kidd compile`. - */ -export type CompileTarget = - | 'darwin-arm64' - | 'darwin-x64' - | 'linux-x64' - | 'linux-arm64' - | 'linux-x64-musl' - | 'windows-x64' - | 'windows-arm64' - /** * Configuration for kidd.config.ts. */ diff --git a/packages/config/src/utils/compile.ts b/packages/config/src/utils/compile.ts new file mode 100644 index 00000000..930b4b2f --- /dev/null +++ b/packages/config/src/utils/compile.ts @@ -0,0 +1,28 @@ +/** + * All supported cross-compilation targets with human-readable labels. + * + * This is the single source of truth — the {@link CompileTarget} union type + * and the Zod validation schema are both derived from this array. + * + * Targets marked `default: true` are compiled when no explicit targets are configured, + * covering ~95% of developer environments. + */ +export const compileTargets = [ + { target: 'darwin-arm64', label: 'macOS Apple Silicon', default: true }, + { target: 'darwin-x64', label: 'macOS Intel', default: true }, + { target: 'linux-arm64', label: 'Linux ARM64', default: false }, + { target: 'linux-x64', label: 'Linux x64', default: true }, + { target: 'linux-x64-musl', label: 'Linux x64 (musl)', default: false }, + { target: 'windows-arm64', label: 'Windows ARM64', default: false }, + { target: 'windows-x64', label: 'Windows x64', default: true }, +] as const + +/** + * Metadata for a single cross-compilation target. + */ +export type CompileTargetEntry = (typeof compileTargets)[number] + +/** + * Supported cross-compilation targets for `kidd compile`. + */ +export type CompileTarget = CompileTargetEntry['target'] diff --git a/packages/config/src/utils/index.ts b/packages/config/src/utils/index.ts new file mode 100644 index 00000000..94c2c500 --- /dev/null +++ b/packages/config/src/utils/index.ts @@ -0,0 +1,5 @@ +export { compileTargets } from './compile.js' +export type { CompileTarget, CompileTargetEntry } from './compile.js' +export { loadConfig } from './loader.js' +export type { LoadConfigOptions, LoadConfigResult } from './loader.js' +export { KiddConfigSchema, validateConfig } from './schema.js' diff --git a/packages/config/src/loader.test.ts b/packages/config/src/utils/loader.test.ts similarity index 100% rename from packages/config/src/loader.test.ts rename to packages/config/src/utils/loader.test.ts diff --git a/packages/config/src/loader.ts b/packages/config/src/utils/loader.ts similarity index 94% rename from packages/config/src/loader.ts rename to packages/config/src/utils/loader.ts index 1cc6a268..f591f85f 100644 --- a/packages/config/src/loader.ts +++ b/packages/config/src/utils/loader.ts @@ -1,12 +1,12 @@ -import type { AsyncResult } from '@kidd-cli/utils' +import type { ResultAsync } from '@kidd-cli/utils' import { err, ok, toError } from '@kidd-cli/utils/fp' import type { Tagged } from '@kidd-cli/utils/tag' import { withTag } from '@kidd-cli/utils/tag' import { loadConfig as c12LoadConfig } from 'c12' import { attemptAsync } from 'es-toolkit' +import type { KiddConfig } from '../types.js' import { validateConfig } from './schema.js' -import type { KiddConfig } from './types.js' export { KiddConfigSchema, validateConfig } from './schema.js' @@ -54,7 +54,7 @@ export interface LoadConfigResult { */ export async function loadConfig( options?: LoadConfigOptions -): AsyncResult { +): ResultAsync { const { cwd, defaults, overrides } = options ?? {} const [loadError, loaded] = await attemptAsync(() => diff --git a/packages/config/src/schema.test.ts b/packages/config/src/utils/schema.test.ts similarity index 100% rename from packages/config/src/schema.test.ts rename to packages/config/src/utils/schema.test.ts diff --git a/packages/config/src/schema.ts b/packages/config/src/utils/schema.ts similarity index 78% rename from packages/config/src/schema.ts rename to packages/config/src/utils/schema.ts index 1c49b34a..33cdc1ee 100644 --- a/packages/config/src/schema.ts +++ b/packages/config/src/utils/schema.ts @@ -2,20 +2,22 @@ import type { Result } from '@kidd-cli/utils' import { validate } from '@kidd-cli/utils/validate' import { z } from 'zod' -import type { KiddConfig } from './types.js' +import type { KiddConfig } from '../types.js' +import type { CompileTarget } from './compile.js' +import { compileTargets } from './compile.js' /** * @private */ -const CompileTargetSchema = z.enum([ - 'darwin-arm64', - 'darwin-x64', - 'linux-arm64', - 'linux-x64', - 'linux-x64-musl', - 'windows-arm64', - 'windows-x64', -]) +const compileTargetValues = compileTargets.map((entry) => entry.target) as [ + CompileTarget, + ...CompileTarget[], +] + +/** + * @private + */ +const CompileTargetSchema = z.enum(compileTargetValues) /** * @private @@ -23,6 +25,7 @@ const CompileTargetSchema = z.enum([ const BuildOptionsSchema = z .object({ clean: z.boolean().optional(), + define: z.record(z.string(), z.string()).optional(), external: z.array(z.string()).optional(), minify: z.boolean().optional(), out: z.string().optional(), diff --git a/packages/config/tsdown.config.ts b/packages/config/tsdown.config.ts index 5de69943..92f43aae 100644 --- a/packages/config/tsdown.config.ts +++ b/packages/config/tsdown.config.ts @@ -7,7 +7,7 @@ export default defineConfig({ outDir: 'dist', entry: { index: 'src/index.ts', - loader: 'src/loader.ts', + utils: 'src/utils/index.ts', }, format: 'esm', }) diff --git a/packages/core/src/cli.test.ts b/packages/core/src/cli.test.ts index 7ec6cc3a..a91fa1a8 100644 --- a/packages/core/src/cli.test.ts +++ b/packages/core/src/cli.test.ts @@ -16,7 +16,7 @@ const mockSpinnerInstance = vi.hoisted(() => ({ const mockLoadConfig = vi.hoisted(() => vi.fn()) const mockAutoload = vi.hoisted(() => vi.fn()) -vi.mock(import('@kidd-cli/config/loader'), () => ({ +vi.mock(import('@kidd-cli/config/utils'), () => ({ loadConfig: mockLoadConfig, })) diff --git a/packages/core/src/cli.ts b/packages/core/src/cli.ts index 0513a9f8..0577fba2 100644 --- a/packages/core/src/cli.ts +++ b/packages/core/src/cli.ts @@ -252,7 +252,7 @@ async function resolveCommandsConfig(config: CommandsConfig): Promise { const DEFAULT_COMMANDS_DIR = './commands' - const { loadConfig } = await import('@kidd-cli/config/loader') + const { loadConfig } = await import('@kidd-cli/config/utils') const [configError, configResult] = await loadConfig() if (configError || !configResult) { return { commands: await autoload({ dir: DEFAULT_COMMANDS_DIR }) } diff --git a/packages/core/src/middleware/auth/context.ts b/packages/core/src/middleware/auth/context.ts index 6baed2dc..f305c0e8 100644 --- a/packages/core/src/middleware/auth/context.ts +++ b/packages/core/src/middleware/auth/context.ts @@ -1,5 +1,5 @@ import { ok } from '@kidd-cli/utils/fp' -import type { AsyncResult, Result } from '@kidd-cli/utils/fp' +import type { ResultAsync, Result } from '@kidd-cli/utils/fp' import type { Prompts } from '@/context/types.js' import { createStore } from '@/lib/store/create-store.js' @@ -72,7 +72,7 @@ export function createAuthContext(options: CreateAuthContextOptions): AuthContex * @param loginOptions - Optional overrides for the login attempt. * @returns A Result with the credential on success or an AuthError on failure. */ - async function login(loginOptions?: LoginOptions): AsyncResult { + async function login(loginOptions?: LoginOptions): ResultAsync { const activeStrategies = resolveLoginStrategies(loginOptions, strategies) const resolved = await runStrategyChain({ @@ -118,7 +118,7 @@ export function createAuthContext(options: CreateAuthContextOptions): AuthContex * @private * @returns A Result with the removed file path on success or an AuthError on failure. */ - async function logout(): AsyncResult { + async function logout(): ResultAsync { // Writes/deletes always target global. A project-local auth file is an // Explicit per-project override (similar to a .env) and is intentionally // Not removed by logout — only the user-scoped global credential is @@ -214,7 +214,7 @@ function resolveLoginValidate( async function runValidation( validateFn: ValidateCredential | undefined, credential: AuthCredential -): AsyncResult { +): ResultAsync { if (validateFn === undefined) { return ok(credential) } diff --git a/packages/core/src/middleware/auth/types.ts b/packages/core/src/middleware/auth/types.ts index ad07f8a5..c5c198eb 100644 --- a/packages/core/src/middleware/auth/types.ts +++ b/packages/core/src/middleware/auth/types.ts @@ -1,4 +1,4 @@ -import type { AsyncResult } from '@kidd-cli/utils/fp' +import type { ResultAsync } from '@kidd-cli/utils/fp' import type { DirsConfig } from '@/types/index.js' @@ -159,7 +159,7 @@ export type StrategyConfig = */ export type ValidateCredential = ( credential: AuthCredential -) => AsyncResult +) => ResultAsync // --------------------------------------------------------------------------- // Auth error @@ -200,15 +200,15 @@ export interface LoginOptions { * * `login()` runs the configured interactive strategies (OAuth, prompt, * etc.), persists the resulting credential to disk, and returns a - * {@link AsyncResult}. + * {@link ResultAsync}. * * `logout()` removes the stored credential from disk. */ export interface AuthContext { readonly credential: () => AuthCredential | null readonly authenticated: () => boolean - readonly login: (options?: LoginOptions) => AsyncResult - readonly logout: () => AsyncResult + readonly login: (options?: LoginOptions) => ResultAsync + readonly logout: () => ResultAsync } // --------------------------------------------------------------------------- diff --git a/packages/core/src/middleware/icons/context.ts b/packages/core/src/middleware/icons/context.ts index 6049d4bb..e98c77b3 100644 --- a/packages/core/src/middleware/icons/context.ts +++ b/packages/core/src/middleware/icons/context.ts @@ -1,5 +1,5 @@ import { ok } from '@kidd-cli/utils/fp' -import type { AsyncResult } from '@kidd-cli/utils/fp' +import type { ResultAsync } from '@kidd-cli/utils/fp' import { match } from 'ts-pattern' import type { CommandContext } from '@/context/types.js' @@ -66,7 +66,7 @@ export function createIconsContext(options: CreateIconsContextOptions): IconsCon match(forceSetup) .with(true, () => false) .otherwise(() => state.isInstalled), - setup: async (): AsyncResult => { + setup: async (): ResultAsync => { const [error, result] = await installNerdFont({ ctx, font }) if (error) { diff --git a/packages/core/src/middleware/icons/install.ts b/packages/core/src/middleware/icons/install.ts index 4da28fae..9239802f 100644 --- a/packages/core/src/middleware/icons/install.ts +++ b/packages/core/src/middleware/icons/install.ts @@ -5,7 +5,7 @@ import { join } from 'node:path' import { promisify } from 'node:util' import { attemptAsync, ok } from '@kidd-cli/utils/fp' -import type { AsyncResult, Result } from '@kidd-cli/utils/fp' +import type { ResultAsync, Result } from '@kidd-cli/utils/fp' import { match } from 'ts-pattern' import { z } from 'zod' @@ -124,7 +124,7 @@ export interface InstallFontOptions { */ export async function installNerdFont( options: InstallFontOptions -): AsyncResult { +): ResultAsync { const { ctx, font } = options if (font !== undefined) { @@ -189,7 +189,7 @@ interface SlugSpinnerParams { * @param ctx - The icons context with the unified log API. * @returns A Result with true on success or an IconsError on failure. */ -async function installWithSelection(ctx: IconsCtx): AsyncResult { +async function installWithSelection(ctx: IconsCtx): ResultAsync { ctx.status.spinner.start('Detecting installed fonts...') const matches = await detectMatchingFonts() ctx.status.spinner.stop('Font detection complete') @@ -243,7 +243,7 @@ async function installWithSelection(ctx: IconsCtx): AsyncResult { +}: CtxFontParams): ResultAsync { const confirmed = await ctx.prompts.confirm({ message: `Nerd Fonts not detected. Install ${fontName} Nerd Font?`, }) @@ -320,7 +320,7 @@ function buildFontChoices( async function showInstallCommands({ ctx, fontName, -}: CtxFontParams): AsyncResult { +}: CtxFontParams): ResultAsync { const slug = fontNameToSlug(fontName) const url = `https://github.com/ryanoasis/nerd-fonts/releases/latest/download/${fontName}.zip` const fontDir = match(process.platform) @@ -381,7 +381,7 @@ async function showInstallCommands({ async function installFontWithSpinner({ ctx, fontName, -}: CtxFontParams): AsyncResult { +}: CtxFontParams): ResultAsync { ctx.status.spinner.start(`Installing ${fontName} Nerd Font...`) const result = await installFont({ fontName, spinner: ctx.status.spinner }) @@ -406,7 +406,7 @@ async function installFontWithSpinner({ async function installFont({ fontName, spinner, -}: FontSpinnerParams): AsyncResult { +}: FontSpinnerParams): ResultAsync { return match(process.platform) .with('darwin', () => installDarwin({ fontName, spinner })) .with('linux', () => installLinux({ fontName, spinner })) @@ -427,7 +427,7 @@ async function installFont({ async function installDarwin({ fontName, spinner, -}: FontSpinnerParams): AsyncResult { +}: FontSpinnerParams): ResultAsync { const slug = fontNameToSlug(fontName) const hasBrew = await checkBrewAvailable() @@ -448,7 +448,7 @@ async function installDarwin({ async function installLinux({ fontName, spinner, -}: FontSpinnerParams): AsyncResult { +}: FontSpinnerParams): ResultAsync { return installViaDownload({ fontName, spinner }) } @@ -473,7 +473,7 @@ async function checkBrewAvailable(): Promise { async function installViaBrew({ slug, spinner, -}: SlugSpinnerParams): AsyncResult { +}: SlugSpinnerParams): ResultAsync { try { spinner.message(`Installing font-${slug}-nerd-font via Homebrew...`) await execAsync(`brew install --cask font-${slug}-nerd-font`) @@ -499,7 +499,7 @@ async function installViaBrew({ async function installViaDownload({ fontName, spinner, -}: FontSpinnerParams): AsyncResult { +}: FontSpinnerParams): ResultAsync { const fontDir = match(process.platform) .with('darwin', () => join(homedir(), 'Library', 'Fonts')) .otherwise(() => join(homedir(), '.local', 'share', 'fonts')) diff --git a/packages/core/src/middleware/icons/list-system-fonts.ts b/packages/core/src/middleware/icons/list-system-fonts.ts index 08e40b57..94650947 100644 --- a/packages/core/src/middleware/icons/list-system-fonts.ts +++ b/packages/core/src/middleware/icons/list-system-fonts.ts @@ -13,7 +13,7 @@ import { homedir } from 'node:os' import { join } from 'node:path' import { err, ok } from '@kidd-cli/utils/fp' -import type { AsyncResult } from '@kidd-cli/utils/fp' +import type { ResultAsync } from '@kidd-cli/utils/fp' import { match } from 'ts-pattern' // --------------------------------------------------------------------------- @@ -45,7 +45,7 @@ const FONT_EXTENSIONS: ReadonlySet = new Set(['.ttf', '.otf', '.ttc', '. * * @returns A Result tuple with font file paths on success, or an Error on failure. */ -export async function listSystemFonts(): AsyncResult { +export async function listSystemFonts(): ResultAsync { const dirs = getFontDirectories() try { diff --git a/packages/core/src/middleware/icons/types.ts b/packages/core/src/middleware/icons/types.ts index 199f3f85..0b32872f 100644 --- a/packages/core/src/middleware/icons/types.ts +++ b/packages/core/src/middleware/icons/types.ts @@ -1,4 +1,4 @@ -import type { AsyncResult } from '@kidd-cli/utils/fp' +import type { ResultAsync } from '@kidd-cli/utils/fp' import type { Middleware } from '@/types/index.js' @@ -93,7 +93,7 @@ export interface IconsContext { * * @returns A Result with true on success or an IconsError on failure. */ - readonly setup: () => AsyncResult + readonly setup: () => ResultAsync /** * Get all resolved icons for a given category. diff --git a/packages/core/src/runtime/runtime.ts b/packages/core/src/runtime/runtime.ts index b5ecba5a..2649b0a5 100644 --- a/packages/core/src/runtime/runtime.ts +++ b/packages/core/src/runtime/runtime.ts @@ -1,5 +1,5 @@ import { attemptAsync, err, ok } from '@kidd-cli/utils/fp' -import type { AsyncResult } from '@kidd-cli/utils/fp' +import type { ResultAsync } from '@kidd-cli/utils/fp' import type { z } from 'zod' import { createContext } from '@/context/index.js' @@ -18,18 +18,18 @@ import type { ResolvedExecution, Runtime, RuntimeOptions } from './types.js' * and middleware chain execution for each command invocation. * * @param options - Runtime configuration including name, version, config, and middleware. - * @returns An AsyncResult containing the runtime or an error. + * @returns An ResultAsync containing the runtime or an error. */ export async function createRuntime( options: RuntimeOptions -): AsyncResult { +): ResultAsync { const config = await resolveConfig(options.config, options.name) const middleware: Middleware[] = options.middleware ?? [] const runner = createMiddlewareExecutor(middleware) const runtime = { - async execute(command: ResolvedExecution): AsyncResult { + async execute(command: ResolvedExecution): ResultAsync { const parser = createArgsParser({ options: command.options, positionals: command.positionals, diff --git a/packages/core/src/runtime/types.ts b/packages/core/src/runtime/types.ts index 32ed5158..8cb3289d 100644 --- a/packages/core/src/runtime/types.ts +++ b/packages/core/src/runtime/types.ts @@ -1,4 +1,4 @@ -import type { AsyncResult, Result } from '@kidd-cli/utils/fp' +import type { ResultAsync, Result } from '@kidd-cli/utils/fp' import type { z } from 'zod' import type { CommandContext, DisplayConfig, Log, Prompts, Status } from '@/context/types.js' @@ -42,7 +42,7 @@ export interface ResolvedExecution { * A runtime instance that orchestrates config and middleware execution. */ export interface Runtime { - readonly execute: (command: ResolvedExecution) => AsyncResult + readonly execute: (command: ResolvedExecution) => ResultAsync } /** diff --git a/packages/utils/package.json b/packages/utils/package.json index e5978235..81381970 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -33,9 +33,9 @@ "types": "./dist/fp.d.ts", "default": "./dist/fp.js" }, - "./fs": { - "types": "./dist/fs.d.ts", - "default": "./dist/fs.js" + "./node": { + "types": "./dist/node.d.ts", + "default": "./dist/node.js" }, "./json": { "types": "./dist/json.d.ts", diff --git a/packages/utils/src/fp/result.test.ts b/packages/utils/src/fp/result.test.ts index 8b7741ca..dd651058 100644 --- a/packages/utils/src/fp/result.test.ts +++ b/packages/utils/src/fp/result.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import { err, ok } from '../fp/index.js' -import type { AsyncResult, Result } from '../fp/index.js' +import type { ResultAsync, Result } from '../fp/index.js' describe(ok, () => { describe('void overload', () => { @@ -111,9 +111,9 @@ describe('Result destructuring', () => { }) }) -describe('AsyncResult type', () => { +describe('ResultAsync type', () => { it('should resolve to a success Result', async () => { - const asyncResult: AsyncResult = Promise.resolve(ok('async value')) + const asyncResult: ResultAsync = Promise.resolve(ok('async value')) const [error, value] = await asyncResult expect(error).toBeNull() @@ -121,7 +121,7 @@ describe('AsyncResult type', () => { }) it('should resolve to a failure Result', async () => { - const asyncResult: AsyncResult = Promise.resolve(err(new Error('async fail'))) + const asyncResult: ResultAsync = Promise.resolve(err(new Error('async fail'))) const [error, value] = await asyncResult expect(error).toBeInstanceOf(Error) diff --git a/packages/utils/src/fp/result.ts b/packages/utils/src/fp/result.ts index efedb311..54419d94 100644 --- a/packages/utils/src/fp/result.ts +++ b/packages/utils/src/fp/result.ts @@ -9,7 +9,7 @@ export type Result = readonly [TError, null] | readonly /** * A Promise that resolves to a {@link Result} tuple. */ -export type AsyncResult = Promise> +export type ResultAsync = Promise> /** * Construct a success Result tuple. diff --git a/packages/utils/src/fs.ts b/packages/utils/src/fs.ts deleted file mode 100644 index 71d63f4e..00000000 --- a/packages/utils/src/fs.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { access } from 'node:fs/promises' - -import { attemptAsync } from 'es-toolkit' - -/** - * Check whether a file exists at the given path. - * - * Wraps `fs.access` into a boolean check. Returns `true` when the file is - * accessible and `false` otherwise -- never throws. - * - * @param filePath - The absolute file path to check. - * @returns True when the file exists and is accessible. - */ -export async function fileExists(filePath: string): Promise { - const [error] = await attemptAsync(() => access(filePath)) - return error === null -} diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index bc0faec5..580d438c 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -1,6 +1,6 @@ export { err, ok, toError } from './fp/index.js' -export type { AsyncResult, Result } from './fp/index.js' -export { fileExists } from './fs.js' +export type { Result, ResultAsync } from './fp/index.js' +export { exists, list, read, remove, write } from './node/fs.js' export { jsonParse, jsonStringify } from './json.js' export type { JsonStringifyOptions } from './json.js' export { readManifest } from './manifest.js' diff --git a/packages/utils/src/manifest.ts b/packages/utils/src/manifest.ts index eedc64f0..87147c5e 100644 --- a/packages/utils/src/manifest.ts +++ b/packages/utils/src/manifest.ts @@ -4,7 +4,7 @@ import { resolve } from 'node:path' import { attemptAsync } from 'es-toolkit' import { z } from 'zod' -import type { AsyncResult } from './fp/result.js' +import type { ResultAsync } from './fp/result.js' import { err, ok } from './fp/result.js' import { jsonParse } from './json.js' @@ -56,7 +56,7 @@ export interface Manifest { /** * Result type for manifest operations. Error is a plain message string. */ -export type ManifestResult = AsyncResult +export type ManifestResult = ResultAsync /** * Read a package.json file and extract common manifest fields. @@ -94,7 +94,7 @@ export async function readManifest(dir?: string): ManifestResult { * @param filePath - Absolute path to the package.json. * @returns A Result tuple with the file contents or an error message. */ -async function readManifestFile(filePath: string): AsyncResult { +async function readManifestFile(filePath: string): ResultAsync { const [error, content] = await attemptAsync(() => readFile(filePath, 'utf8')) if (error) { return err(`Failed to read ${filePath}: ${error.message}`) diff --git a/packages/utils/src/fs.test.ts b/packages/utils/src/node/fs.test.ts similarity index 61% rename from packages/utils/src/fs.test.ts rename to packages/utils/src/node/fs.test.ts index 72cbde24..e1df4a21 100644 --- a/packages/utils/src/fs.test.ts +++ b/packages/utils/src/node/fs.test.ts @@ -2,30 +2,30 @@ import { access } from 'node:fs/promises' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { fileExists } from './fs.js' +import { exists } from './fs.js' vi.mock(import('node:fs/promises')) -describe(fileExists, () => { +describe(exists, () => { beforeEach(() => { vi.resetAllMocks() }) - it('should return true when file exists', async () => { + it('should return true when path exists', async () => { vi.mocked(access).mockResolvedValue(undefined) - const result = await fileExists('/some/existing/file.txt') + const result = await exists('/some/existing/file.txt') expect(result).toBeTruthy() }) - it('should return false when file does not exist', async () => { + it('should return false when path does not exist', async () => { vi.mocked(access).mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })) - const result = await fileExists('/some/missing/file.txt') + const result = await exists('/some/missing/file.txt') expect(result).toBeFalsy() }) it('should return false on permission error', async () => { vi.mocked(access).mockRejectedValue(Object.assign(new Error('EACCES'), { code: 'EACCES' })) - const result = await fileExists('/some/restricted/file.txt') + const result = await exists('/some/restricted/file.txt') expect(result).toBeFalsy() }) }) diff --git a/packages/utils/src/node/fs.ts b/packages/utils/src/node/fs.ts new file mode 100644 index 00000000..f5f67f83 --- /dev/null +++ b/packages/utils/src/node/fs.ts @@ -0,0 +1,95 @@ +import * as fsNative from 'node:fs/promises' + +import { attemptAsync } from 'es-toolkit' + +import type { ResultAsync } from '../fp/result.js' +import { err, ok } from '../fp/result.js' + +/** + * Check whether a path exists and is accessible. + * + * Wraps `fs.access` into a boolean check — never throws. + * + * @param path - The absolute path to check. + * @returns True when the path exists and is accessible. + */ +export async function exists(path: string): Promise { + const [error] = await attemptAsync(() => fsNative.access(path)) + return error === null +} + +/** + * Read a file's contents as a UTF-8 string. + * + * @param path - The absolute file path to read. + * @returns A result tuple with the file contents or an Error. + */ +export async function read(path: string): ResultAsync { + const [error, contents] = await attemptAsync(() => fsNative.readFile(path, 'utf8')) + if (error || contents === null) { + return err(new Error(`failed to read ${path}`, { cause: error })) + } + + return ok(contents) +} + +/** + * Write a UTF-8 string to a file. + * + * @param path - The absolute file path to write. + * @param content - The string content to write. + * @returns A result tuple with void on success or an Error. + */ +export async function write(path: string, content: string): ResultAsync { + const [error] = await attemptAsync(() => fsNative.writeFile(path, content, 'utf8')) + if (error) { + return err(new Error(`failed to write ${path}`, { cause: error })) + } + + return ok() +} + +/** + * List filenames in a directory. + * + * @param path - The absolute directory path to list. + * @returns A result tuple with filenames or an Error. + */ +export async function list(path: string): ResultAsync { + const [error, entries] = await attemptAsync(() => fsNative.readdir(path)) + if (error || entries === null) { + return err(new Error(`failed to list ${path}`, { cause: error })) + } + + return ok(entries) +} + +/** + * Create a directory, including parent directories. + * + * @param path - The absolute directory path to create. + * @returns A result tuple with void on success or an Error. + */ +export async function mkdir(path: string): ResultAsync { + const [error] = await attemptAsync(() => fsNative.mkdir(path, { recursive: true })) + if (error) { + return err(new Error(`failed to create directory ${path}`, { cause: error })) + } + + return ok() +} + +/** + * Remove a file or directory. Silently succeeds if the path does not exist. + * + * @param path - The absolute path to remove. + * @returns A result tuple with void on success or an Error. + */ +export async function remove(path: string): ResultAsync { + const [error] = await attemptAsync(() => fsNative.rm(path, { recursive: true, force: true })) + if (error) { + return err(new Error(`failed to remove ${path}`, { cause: error })) + } + + return ok() +} diff --git a/packages/utils/src/node/index.ts b/packages/utils/src/node/index.ts new file mode 100644 index 00000000..b4fb703f --- /dev/null +++ b/packages/utils/src/node/index.ts @@ -0,0 +1,2 @@ +export * as fs from './fs.js' +export * as process from './process.js' diff --git a/packages/utils/src/node/process.ts b/packages/utils/src/node/process.ts new file mode 100644 index 00000000..6649c81c --- /dev/null +++ b/packages/utils/src/node/process.ts @@ -0,0 +1,96 @@ +import { execFile, spawn as nodeSpawn } from 'node:child_process' + +import { match } from 'ts-pattern' + +import type { ResultAsync } from '../fp/result.js' +import { err, ok } from '../fp/result.js' + +/** + * Output from a successful command execution. + */ +export interface ExecOutput { + readonly stdout: string + readonly stderr: string +} + +/** + * Execute a command with arguments and return the result. + * + * Wraps `child_process.execFile` into an async Result tuple. On failure, + * the error includes stderr as a property for diagnostic access. + * + * @param params - The command and arguments to execute. + * @returns A result tuple with stdout/stderr on success or an Error on failure. + */ +export function exec(params: { + readonly cmd: string + readonly args?: readonly string[] + readonly cwd?: string +}): ResultAsync { + const { cmd, args = [], cwd } = params + return new Promise((resolve) => { + execFile(cmd, [...args], { cwd }, (error, stdout, stderr) => { + if (error) { + const enriched = new Error(`${cmd} failed: ${error.message}`, { cause: error }) + Object.defineProperty(enriched, 'stderr', { enumerable: true, value: stderr }) + resolve(err(enriched)) + return + } + + resolve(ok({ stdout, stderr })) + }) + }) +} + +/** + * Spawn an interactive process with inherited stdio. + * + * Returns the exit code of the child process. Stdio is inherited so the + * child process shares the parent's terminal. + * + * @param cmd - The command to spawn. + * @param args - Arguments to pass to the command. + * @param cwd - Working directory for the child process. + * @returns The exit code of the spawned process. + */ +export function spawn(params: { + readonly cmd: string + readonly args: readonly string[] + readonly cwd: string +}): Promise { + return new Promise((resolve) => { + const child = nodeSpawn(params.cmd, [...params.args], { + cwd: params.cwd, + stdio: 'inherit', + }) + + child.on('error', () => { + resolve(1) + }) + + child.on('close', (code) => { + resolve(code ?? 1) + }) + }) +} + +/** + * Check whether a binary is available on the system PATH. + * + * Uses `which` (unix) or `where` (windows) to resolve the binary + * without executing it. + * + * @param cmd - The command name to check. + * @returns `true` when the command is found on PATH. + */ +export function exists(cmd: string): Promise { + const lookup = match(process.platform) + .with('win32', () => 'where') + .otherwise(() => 'which') + + return new Promise((resolve) => { + execFile(lookup, [cmd], (error) => { + resolve(error === null) + }) + }) +} diff --git a/packages/utils/tsdown.config.ts b/packages/utils/tsdown.config.ts index 77219b8f..2f504600 100644 --- a/packages/utils/tsdown.config.ts +++ b/packages/utils/tsdown.config.ts @@ -7,8 +7,8 @@ export default defineConfig({ outDir: 'dist', entry: { fp: 'src/fp/index.ts', - fs: 'src/fs.ts', index: 'src/index.ts', + node: 'src/node/index.ts', json: 'src/json.ts', manifest: 'src/manifest.ts', tag: 'src/tag.ts', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2559cebc..9c83ce48 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,9 +6,6 @@ settings: catalogs: default: - '@types/bun': - specifier: ^1.3.11 - version: 1.3.11 '@types/node': specifier: ^25.5.0 version: 25.5.0 @@ -276,19 +273,16 @@ importers: ts-pattern: specifier: 'catalog:' version: 5.9.0 + tsdown: + specifier: 'catalog:' + version: 0.21.7(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@typescript/native-preview@7.0.0-dev.20260331.1)(typescript@6.0.2) zod: specifier: 'catalog:' version: 4.3.6 devDependencies: - '@types/bun': - specifier: 'catalog:' - version: 1.3.11 '@types/node': specifier: 'catalog:' version: 25.5.0 - tsdown: - specifier: 'catalog:' - version: 0.21.7(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@typescript/native-preview@7.0.0-dev.20260331.1)(typescript@6.0.2) typescript: specifier: 'catalog:' version: 6.0.2 @@ -2321,9 +2315,6 @@ packages: '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} - '@types/bun@1.3.11': - resolution: {integrity: sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg==} - '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -2694,9 +2685,6 @@ packages: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} - bun-types@1.3.11: - resolution: {integrity: sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg==} - c12@4.0.0-beta.4: resolution: {integrity: sha512-gcWQAloC/SwGx4U7l3iQdalUQQLLXwYS1d3SqIwFj4UUrTXh8L9yGkBcA00B0gxELMwbxtsrt6VrAxtSgqZZoA==} peerDependencies: @@ -7274,10 +7262,6 @@ snapshots: tslib: 2.8.1 optional: true - '@types/bun@1.3.11': - dependencies: - bun-types: 1.3.11 - '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 @@ -7752,10 +7736,6 @@ snapshots: dependencies: fill-range: 7.1.1 - bun-types@1.3.11: - dependencies: - '@types/node': 25.5.0 - c12@4.0.0-beta.4(chokidar@5.0.0)(dotenv@17.3.1)(giget@1.2.5)(jiti@2.6.1)(magicast@0.5.2): dependencies: confbox: 0.2.4 diff --git a/tests/helpers.ts b/tests/helpers.ts index b8878818..17a861e4 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -69,7 +69,7 @@ type SubprocessRunner = (...args: readonly string[]) => string */ export function createNodeRunner({ example, - distPath = 'dist/index.js', + distPath = 'dist/index.mjs', }: NodeRunnerOptions): SubprocessRunner { const cwd = `${EXAMPLES_DIR}/${example}` @@ -102,9 +102,13 @@ interface BinaryRunnerOptions { readonly example: string /** * Relative path to the dist directory (default: `dist`). - * The binary name `cli-` is appended automatically. */ readonly distDir?: string + /** + * Binary base name (default: same as `example`). + * The host target suffix is appended automatically. + */ + readonly name?: string } /** @@ -119,9 +123,11 @@ interface BinaryRunnerOptions { export function createBinaryRunner({ example, distDir = 'dist', + name, }: BinaryRunnerOptions): SubprocessRunner { const cwd = `${EXAMPLES_DIR}/${example}` - const binary = `${cwd}/${distDir}/cli-${resolveHostTarget()}` + const binaryName = name ?? example + const binary = `${cwd}/${distDir}/${binaryName}-${resolveHostTarget()}` return (...args: readonly string[]): string => { const result = spawnSync(binary, [...args], { diff --git a/tests/integration/authenticated-service.test.ts b/tests/integration/authenticated-service.test.ts index 4a8d628d..269c9ec7 100644 --- a/tests/integration/authenticated-service.test.ts +++ b/tests/integration/authenticated-service.test.ts @@ -5,11 +5,15 @@ import { createBinaryRunner, createNodeRunner } from '../helpers.js' const runners = [ { label: 'node', - run: createNodeRunner({ example: 'authenticated-service', distPath: 'cli/dist/index.js' }), + run: createNodeRunner({ example: 'authenticated-service', distPath: 'cli/dist/index.mjs' }), }, { label: 'binary', - run: createBinaryRunner({ example: 'authenticated-service', distDir: 'cli/dist' }), + run: createBinaryRunner({ + example: 'authenticated-service', + distDir: 'cli/dist', + name: 'authenticated-service-cli', + }), }, ] as const