@burger-api/cli 0.9.1 → 0.9.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  All notable changes to the Burger API CLI will be documented in this file.
4
4
 
5
- ## Version 0.9.0 - (March 15, 2026)
5
+ ## Version 0.9.3 - (March 17, 2026)
6
6
 
7
7
  - ✨ **Create** – New projects get a config file (`burger.config.ts`) from
8
8
  your answers; the build uses this config when present.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@burger-api/cli",
3
- "version": "0.9.1",
3
+ "version": "0.9.4",
4
4
  "description": "Simple command-line tool for Burger API projects",
5
5
  "module": "src/index.ts",
6
6
  "type": "module",
@@ -20,10 +20,10 @@
20
20
  "dev": "bun run src/index.ts",
21
21
  "test": "bun test --timeout 30000 ./test",
22
22
  "test:build": "bun test test/scanner.test.ts test/virtual-entry.test.ts test/entry-options.test.ts test/build-preserve-options.test.ts",
23
- "build:win": "bun build ./src/index.ts --compile --target bun-windows-x64 --outfile dist/burger-api.exe --minify",
24
- "build:linux": "bun build ./src/index.ts --compile --target bun-linux-x64 --outfile dist/burger-api-linux --minify",
25
- "build:mac": "bun build ./src/index.ts --compile --target bun-darwin-arm64 --outfile dist/burger-api-mac --minify",
26
- "build:mac-intel": "bun build ./src/index.ts --compile --target bun-darwin-x64 --outfile dist/burger-api-mac-intel --minify",
23
+ "build:win": "bun run scripts/build-executable.ts win",
24
+ "build:linux": "bun run scripts/build-executable.ts linux",
25
+ "build:mac": "bun run scripts/build-executable.ts mac",
26
+ "build:mac-intel": "bun run scripts/build-executable.ts mac-intel",
27
27
  "build:all": "bun run build:win && bun run build:linux && bun run build:mac && bun run build:mac-intel"
28
28
  },
29
29
  "dependencies": {
@@ -69,7 +69,7 @@ export const addCommand = new Command('add')
69
69
  for (const name of middlewareNames) {
70
70
  try {
71
71
  // Check if it exists on GitHub
72
- const spin = spinner(`Checking ${name}...`);
72
+ let spin = spinner(`Checking ${name}...`);
73
73
 
74
74
  let exists;
75
75
  try {
@@ -106,6 +106,7 @@ export const addCommand = new Command('add')
106
106
  results.skipped.push(name);
107
107
  continue;
108
108
  }
109
+ spin = spinner(`Downloading ${name}...`);
109
110
  }
110
111
 
111
112
  // Download the middleware
@@ -11,6 +11,7 @@
11
11
  import { Command } from 'commander';
12
12
  import { existsSync } from 'fs';
13
13
  import { dirname, resolve } from 'path';
14
+ import type { Spinner } from '../utils/logger';
14
15
  import {
15
16
  spinner,
16
17
  success,
@@ -20,9 +21,51 @@ import {
20
21
  formatSize,
21
22
  dim,
22
23
  } from '../utils/logger';
23
- import { runVirtualEntryBuild } from '../utils/build/pipeline';
24
+ import {
25
+ runVirtualEntryBuild,
26
+ type VirtualBuildResult,
27
+ } from '../utils/build/pipeline';
24
28
  import { getProjectName } from '../utils/build/project';
25
29
 
30
+ function ensureEntryFileExists(cwd: string, file: string): void {
31
+ const entryPath = resolve(cwd, file);
32
+ if (!existsSync(entryPath)) {
33
+ logError(`Entry file not found: ${file}`);
34
+ info('Make sure you are in the project directory.');
35
+ process.exit(1);
36
+ }
37
+ }
38
+
39
+ async function runBuildWithSpinner(params: {
40
+ file: string;
41
+ cwd: string;
42
+ spinMessage: string;
43
+ failMessage: string;
44
+ buildOptions: Parameters<typeof runVirtualEntryBuild>[0];
45
+ onBeforeBuild?: (spin: Spinner) => void;
46
+ onSuccess: (result: VirtualBuildResult, spin: Spinner) => void;
47
+ }): Promise<void> {
48
+ ensureEntryFileExists(params.cwd, params.file);
49
+ const spin = spinner(params.spinMessage);
50
+ try {
51
+ info('Build-time route discovery enabled.');
52
+ params.onBeforeBuild?.(spin);
53
+ const result = await runVirtualEntryBuild(params.buildOptions);
54
+ if (!result.success) {
55
+ spin.stop(params.failMessage, true);
56
+ logError(
57
+ 'Bun.build failed. Check that api/page directories and route files are valid.'
58
+ );
59
+ process.exit(1);
60
+ }
61
+ params.onSuccess(result, spin);
62
+ } catch (err) {
63
+ spin.stop(params.failMessage, true);
64
+ logError(err instanceof Error ? err.message : 'Unknown error');
65
+ process.exit(1);
66
+ }
67
+ }
68
+
26
69
  /**
27
70
  * Options for the `build` command.
28
71
  */
@@ -73,58 +116,39 @@ export const buildCommand = new Command('build')
73
116
  .option('--target <env>', 'Target environment (default: bun)')
74
117
  .action(async (file: string, options: BuildCommandOptions) => {
75
118
  const cwd = process.cwd();
76
- const entryPath = resolve(cwd, file);
77
- if (!existsSync(entryPath)) {
78
- logError(`Entry file not found: ${file}`);
79
- info('Make sure you are in the project directory.');
80
- process.exit(1);
81
- }
82
-
83
- const spin = spinner('Building project...');
84
-
85
- try {
86
- info('Build-time route discovery enabled.');
87
-
88
- const { success: ok, hasPages } = await runVirtualEntryBuild({
119
+ await runBuildWithSpinner({
120
+ file,
121
+ cwd,
122
+ spinMessage: 'Building project...',
123
+ failMessage: 'Build failed',
124
+ buildOptions: {
89
125
  cwd,
90
126
  entryFile: file,
91
127
  outfile: options.outfile,
92
128
  target: options.target || 'bun',
93
129
  minify: options.minify,
94
130
  sourcemap: options.sourcemap,
95
- });
96
-
97
- if (!ok) {
98
- spin.stop('Build failed', true);
99
- logError(
100
- 'Bun.build failed. Check that api/page directories and route files are valid.'
101
- );
102
- process.exit(1);
103
- }
104
-
105
- const size = Bun.file(options.outfile).size;
106
- const bundleDir = dirname(options.outfile);
107
-
108
- spin.stop('Build completed successfully!');
109
- newline();
110
- success(`Bundle: ${options.outfile} (${formatSize(size)})`);
111
- newline();
112
- if (hasPages) {
113
- info(`Pages and assets are in: ${bundleDir}/`);
114
- dim(
115
- 'Deploy the entire directory — HTML pages depend on their chunks.'
116
- );
117
- dim(`Run: bun ${options.outfile}`);
118
- } else {
119
- info('API-only bundle — self-contained single file.');
120
- dim(`Run anywhere: bun ${options.outfile}`);
121
- }
122
- newline();
123
- } catch (err) {
124
- spin.stop('Build failed', true);
125
- logError(err instanceof Error ? err.message : 'Unknown error');
126
- process.exit(1);
127
- }
131
+ },
132
+ onSuccess: (result, spin) => {
133
+ const size = Bun.file(options.outfile).size;
134
+ const bundleDir = dirname(options.outfile);
135
+ spin.stop('Build completed successfully!');
136
+ newline();
137
+ success(`Bundle: ${options.outfile} (${formatSize(size)})`);
138
+ newline();
139
+ if (result.hasPages) {
140
+ info(`Pages and assets are in: ${bundleDir}/`);
141
+ dim(
142
+ 'Deploy the entire directory — HTML pages depend on their chunks.'
143
+ );
144
+ dim(`Run: bun ${options.outfile}`);
145
+ } else {
146
+ info('API-only bundle — self-contained single file.');
147
+ dim(`Run anywhere: bun ${options.outfile}`);
148
+ }
149
+ newline();
150
+ },
151
+ });
128
152
  });
129
153
 
130
154
  /**
@@ -151,13 +175,6 @@ export const buildExecutableCommand = new Command('build:exec')
151
175
  .option('--no-bytecode', 'Disable bytecode compilation')
152
176
  .action(async (file: string, options: BuildExecutableOptions) => {
153
177
  const cwd = process.cwd();
154
- const entryPath = resolve(cwd, file);
155
- if (!existsSync(entryPath)) {
156
- logError(`Entry file not found: ${file}`);
157
- info('Make sure you are in the project directory.');
158
- process.exit(1);
159
- }
160
-
161
178
  let outfile = options.outfile;
162
179
  if (!outfile) {
163
180
  const projectName = getProjectName(cwd);
@@ -168,14 +185,14 @@ export const buildExecutableCommand = new Command('build:exec')
168
185
  ? `.build/executable/${projectName}.exe`
169
186
  : `.build/executable/${projectName}`;
170
187
  }
171
-
172
- const spin = spinner('Compiling to executable...');
173
-
174
- try {
175
- info('Build-time route discovery enabled.');
176
- spin.update('Compiling... (this may take a minute)');
177
-
178
- const { success: ok, outputs } = await runVirtualEntryBuild({
188
+ const outfileFinal = outfile;
189
+
190
+ await runBuildWithSpinner({
191
+ file,
192
+ cwd,
193
+ spinMessage: 'Compiling to executable...',
194
+ failMessage: 'Compilation failed',
195
+ buildOptions: {
179
196
  cwd,
180
197
  entryFile: file,
181
198
  outfile,
@@ -183,42 +200,32 @@ export const buildExecutableCommand = new Command('build:exec')
183
200
  minify: options.minify,
184
201
  bytecode: options.bytecode !== false,
185
202
  compile: true,
186
- });
187
-
188
- if (!ok) {
189
- spin.stop('Compilation failed', true);
190
- logError(
191
- 'Bun.build failed. Check that api/page directories and route files are valid.'
203
+ },
204
+ onBeforeBuild: (spin) =>
205
+ spin.update('Compiling... (this may take a minute)'),
206
+ onSuccess: (result, spin) => {
207
+ const size = existsSync(outfileFinal)
208
+ ? Bun.file(outfileFinal).size
209
+ : (result.outputs[0]?.size ?? 0);
210
+ spin.stop('Compilation completed successfully!');
211
+ newline();
212
+ success(`Executable: ${outfileFinal}`);
213
+ if (size > 0) info(`Size: ${formatSize(size)}`);
214
+ newline();
215
+ info(
216
+ 'Standalone binary — copy it anywhere, no Bun required on the target.'
192
217
  );
193
- process.exit(1);
194
- }
195
-
196
- const size = existsSync(outfile)
197
- ? Bun.file(outfile).size
198
- : (outputs[0]?.size ?? 0);
199
-
200
- spin.stop('Compilation completed successfully!');
201
- newline();
202
- success(`Executable: ${outfile}`);
203
- if (size > 0) info(`Size: ${formatSize(size)}`);
204
- newline();
205
- info(
206
- 'Standalone binary — copy it anywhere, no Bun required on the target.'
207
- );
208
- dim(
209
- 'All routes, pages, and assets are embedded inside the binary.'
210
- );
211
- newline();
212
- if (process.platform !== 'win32') {
213
- dim(`Make executable: chmod +x ${outfile}`);
214
- dim(`Run: ./${outfile}`);
215
- } else {
216
- dim(`Run: ${outfile}`);
217
- }
218
- newline();
219
- } catch (err) {
220
- spin.stop('Compilation failed', true);
221
- logError(err instanceof Error ? err.message : 'Unknown error');
222
- process.exit(1);
223
- }
218
+ dim(
219
+ 'All routes, pages, and assets are embedded inside the binary.'
220
+ );
221
+ newline();
222
+ if (process.platform !== 'win32') {
223
+ dim(`Make executable: chmod +x ${outfileFinal}`);
224
+ dim(`Run: ./${outfileFinal}`);
225
+ } else {
226
+ dim(`Run: ${outfileFinal}`);
227
+ }
228
+ newline();
229
+ },
230
+ });
224
231
  });
@@ -10,7 +10,7 @@
10
10
  import { Command } from 'commander';
11
11
  import * as clack from '@clack/prompts';
12
12
  import { existsSync } from 'fs';
13
- import { join } from 'path';
13
+ import { isAbsolute, join, relative, resolve } from 'path';
14
14
  import type { CreateOptions } from '../types/index';
15
15
  import { createProject, installDependencies } from '../utils/templates';
16
16
  import {
@@ -45,6 +45,26 @@ function validateProjectName(name: string): string | undefined {
45
45
  return undefined;
46
46
  }
47
47
 
48
+ /**
49
+ * Ensure directory name (apiDir/pageDir) resolves under targetDir/src to prevent path traversal.
50
+ */
51
+ function validateDirUnderSrc(
52
+ targetDir: string,
53
+ dirName: string,
54
+ label: string
55
+ ): string | undefined {
56
+ if (!dirName || dirName.includes('..')) {
57
+ return `${label} cannot be empty or contain '..'`;
58
+ }
59
+ const srcRoot = resolve(targetDir, 'src');
60
+ const resolved = resolve(targetDir, 'src', dirName);
61
+ const rel = relative(srcRoot, resolved);
62
+ if (rel.startsWith('..') || isAbsolute(rel)) {
63
+ return `${label} must resolve inside the project's src directory`;
64
+ }
65
+ return undefined;
66
+ }
67
+
48
68
  export const createCommand = new Command('create')
49
69
  .description('Create a new Burger API project')
50
70
  .argument('<project-name>', 'Name of your project')
@@ -78,6 +98,32 @@ export const createCommand = new Command('create')
78
98
  process.exit(0);
79
99
  }
80
100
 
101
+ // Validate apiDir/pageDir stay under targetDir/src (prevent path traversal)
102
+ if (options.useApi) {
103
+ const apiDirError = validateDirUnderSrc(
104
+ targetDir,
105
+ options.apiDir || 'api',
106
+ 'API directory'
107
+ );
108
+ if (apiDirError) {
109
+ clack.outro('Invalid configuration');
110
+ logError(apiDirError);
111
+ process.exit(1);
112
+ }
113
+ }
114
+ if (options.usePages) {
115
+ const pageDirError = validateDirUnderSrc(
116
+ targetDir,
117
+ options.pageDir || 'pages',
118
+ 'Page directory'
119
+ );
120
+ if (pageDirError) {
121
+ clack.outro('Invalid configuration');
122
+ logError(pageDirError);
123
+ process.exit(1);
124
+ }
125
+ }
126
+
81
127
  // Show what we're about to create
82
128
  info('Creating project with the following configuration:');
83
129
  newline();
@@ -154,6 +200,8 @@ async function askQuestions(projectName: string): Promise<CreateOptions> {
154
200
  return 'Please enter a directory name';
155
201
  if (value.includes(' '))
156
202
  return 'Directory name cannot contain spaces';
203
+ if (value.includes('..'))
204
+ return 'Directory name cannot contain ..';
157
205
  },
158
206
  })
159
207
  : Promise.resolve('api'),
@@ -196,6 +244,8 @@ async function askQuestions(projectName: string): Promise<CreateOptions> {
196
244
  return 'Please enter a directory name';
197
245
  if (value.includes(' '))
198
246
  return 'Directory name cannot contain spaces';
247
+ if (value.includes('..'))
248
+ return 'Directory name cannot contain ..';
199
249
  },
200
250
  })
201
251
  : Promise.resolve('pages'),
@@ -11,7 +11,7 @@ import { Command } from 'commander';
11
11
  import { getMiddlewareList, getMiddlewareInfo } from '../utils/github';
12
12
  import {
13
13
  header,
14
- spinner,
14
+ withSpinner,
15
15
  error as logError,
16
16
  table,
17
17
  newline,
@@ -28,54 +28,49 @@ export const listCommand = new Command('list')
28
28
  .description('Show available middleware from the ecosystem')
29
29
  .alias('ls') // Allow users to type "burger-api ls" too
30
30
  .action(async () => {
31
- // Show a spinner while fetching from GitHub
32
- const spin = spinner('Fetching middleware list from GitHub...');
33
-
34
31
  try {
35
- // Get the list of middleware names
36
- const middlewareNames = await getMiddlewareList();
32
+ await withSpinner(
33
+ 'Fetching middleware list from GitHub...',
34
+ async (spin) => {
35
+ const middlewareNames = await getMiddlewareList();
37
36
 
38
- // Fetch details for each middleware (in parallel for speed!)
39
- const middlewareDetails = await Promise.all(
40
- middlewareNames.map((name) =>
41
- getMiddlewareInfo(name).catch(() => ({
42
- name,
43
- description: 'No description available',
44
- path: '',
45
- files: [],
46
- }))
47
- )
48
- );
37
+ const middlewareDetails = await Promise.all(
38
+ middlewareNames.map((name) =>
39
+ getMiddlewareInfo(name).catch(() => ({
40
+ name,
41
+ description: 'No description available',
42
+ path: '',
43
+ files: [],
44
+ }))
45
+ )
46
+ );
49
47
 
50
- spin.stop('Found available middleware!');
51
- newline();
48
+ spin.stop('Found available middleware!');
49
+ newline();
52
50
 
53
- // Display header
54
- header('Available Middleware');
51
+ header('Available Middleware');
55
52
 
56
- // Create table data
57
- const tableData: string[][] = [
58
- ['Name', 'Description'],
59
- ...middlewareDetails.map((m) => [
60
- m.name,
61
- m.description.length > 60
62
- ? m.description.substring(0, 57) + '...'
63
- : m.description,
64
- ]),
65
- ];
53
+ const tableData: string[][] = [
54
+ ['Name', 'Description'],
55
+ ...middlewareDetails.map((m) => [
56
+ m.name,
57
+ m.description.length > 60
58
+ ? m.description.substring(0, 57) + '...'
59
+ : m.description,
60
+ ]),
61
+ ];
66
62
 
67
- // Show the table
68
- table(tableData);
69
- newline();
63
+ table(tableData);
64
+ newline();
70
65
 
71
- // Show usage instructions
72
- info('To add middleware to your project, run:');
73
- command('burger-api add <middleware-name>');
74
- newline();
75
- dim('Example: burger-api add cors logger rate-limiter');
76
- newline();
66
+ info('To add middleware to your project, run:');
67
+ command('burger-api add <middleware-name>');
68
+ newline();
69
+ dim('Example: burger-api add cors logger rate-limiter');
70
+ newline();
71
+ }
72
+ );
77
73
  } catch (err) {
78
- spin.stop('Failed to fetch middleware list', true);
79
74
  logError(
80
75
  err instanceof Error
81
76
  ? err.message
package/src/index.ts CHANGED
@@ -20,11 +20,18 @@ import { buildCommand, buildExecutableCommand } from './commands/build';
20
20
  import { serveCommand } from './commands/serve';
21
21
  import { showBanner } from './utils/logger';
22
22
 
23
+ /** Injected at build time when compiling to executable (--define CLI_VERSION). */
24
+ declare const CLI_VERSION: string | undefined;
25
+
23
26
  /**
24
27
  * Read CLI version from package.json (single source of truth for publish).
28
+ * When running as compiled binary, version is injected at build time via CLI_VERSION.
25
29
  * @returns The version of the CLI.
26
30
  */
27
31
  function getVersion(): string {
32
+ if (typeof CLI_VERSION !== 'undefined') {
33
+ return CLI_VERSION;
34
+ }
28
35
  try {
29
36
  const pkgPath = join(import.meta.dir, '..', 'package.json');
30
37
  const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8')) as {
@@ -73,5 +80,25 @@ program.configureOutput({
73
80
  },
74
81
  });
75
82
 
83
+ // Use exit code 2 for usage errors (invalid options/args) per CLI guidelines
84
+ const USAGE_ERROR_CODES = [
85
+ 'commander.unknownOption',
86
+ 'commander.unknownArgument',
87
+ 'commander.missingMandatoryOptionValue',
88
+ 'commander.invalidOptionArgument',
89
+ 'commander.invalidArgument',
90
+ ];
91
+ program.exitOverride((err) => {
92
+ const errorCode = (err as { code?: string }).code;
93
+ const code =
94
+ err.exitCode === 0
95
+ ? 0
96
+ : typeof errorCode === 'string' &&
97
+ USAGE_ERROR_CODES.includes(errorCode)
98
+ ? 2
99
+ : 1;
100
+ process.exit(code);
101
+ });
102
+
76
103
  // Run the CLI - this parses the arguments the user typed
77
104
  program.parse();
@@ -0,0 +1,134 @@
1
+ import { resolve } from 'path';
2
+
3
+ function formatBunBuildLogs(logs: unknown): string {
4
+ if (!Array.isArray(logs) || logs.length === 0) {
5
+ return '';
6
+ }
7
+
8
+ const messages: string[] = [];
9
+ for (const item of logs) {
10
+ if (!item || typeof item !== 'object') continue;
11
+
12
+ const log = item as {
13
+ level?: string;
14
+ message?: string;
15
+ text?: string;
16
+ name?: string;
17
+ position?: { file?: string; line?: number; column?: number };
18
+ };
19
+ const text =
20
+ (typeof log.message === 'string' && log.message) ||
21
+ (typeof log.text === 'string' && log.text) ||
22
+ (typeof log.name === 'string' && log.name) ||
23
+ '';
24
+ if (!text) continue;
25
+
26
+ const level =
27
+ typeof log.level === 'string' ? log.level.toUpperCase() : 'ERROR';
28
+ const file = log.position?.file ? `${log.position.file}` : '';
29
+ const line =
30
+ typeof log.position?.line === 'number'
31
+ ? `:${log.position.line}`
32
+ : '';
33
+ const column =
34
+ typeof log.position?.column === 'number'
35
+ ? `:${log.position.column}`
36
+ : '';
37
+ const location = file ? ` (${file}${line}${column})` : '';
38
+ messages.push(`- [${level}] ${text}${location}`);
39
+ }
40
+ return messages.join('\n');
41
+ }
42
+
43
+ function extractBunBuildDetails(err: unknown): string {
44
+ if (!err || typeof err !== 'object') {
45
+ return '';
46
+ }
47
+
48
+ const anyErr = err as {
49
+ logs?: unknown;
50
+ errors?: unknown;
51
+ cause?: { logs?: unknown; errors?: unknown };
52
+ };
53
+ const candidates = [
54
+ anyErr.logs,
55
+ anyErr.errors,
56
+ anyErr.cause?.logs,
57
+ anyErr.cause?.errors,
58
+ ];
59
+
60
+ for (const candidate of candidates) {
61
+ const detail = formatBunBuildLogs(candidate);
62
+ if (detail) return detail;
63
+ }
64
+ return '';
65
+ }
66
+
67
+ export function createBunBuildOptions(options: {
68
+ entryPath: string;
69
+ outDir: string;
70
+ cwd: string;
71
+ outfile: string;
72
+ target?: string;
73
+ minify?: boolean;
74
+ sourcemap?: string;
75
+ compile?: boolean;
76
+ bytecode?: boolean;
77
+ }): Parameters<typeof Bun.build>[0] {
78
+ const buildOptions: Parameters<typeof Bun.build>[0] = {
79
+ entrypoints: [options.entryPath],
80
+ outdir: options.outDir,
81
+ target: (options.target as 'bun') || 'bun',
82
+ minify: options.minify ?? false,
83
+ splitting: false,
84
+ sourcemap:
85
+ options.sourcemap === undefined
86
+ ? undefined
87
+ : (options.sourcemap as 'none' | 'linked' | 'inline' | 'external'),
88
+ };
89
+
90
+ const ext = buildOptions as unknown as Record<string, unknown>;
91
+ ext.naming = {
92
+ chunk: '[name]-[hash].[ext]',
93
+ asset: '[name]-[hash].[ext]',
94
+ };
95
+
96
+ if (options.compile) {
97
+ ext.compile = {
98
+ outfile: resolve(options.cwd, options.outfile),
99
+ ...(options.target && { target: options.target }),
100
+ };
101
+ if (options.bytecode !== false) {
102
+ ext.bytecode = true;
103
+ }
104
+ delete ext.outdir;
105
+ }
106
+
107
+ return buildOptions;
108
+ }
109
+
110
+ export async function runBunBuildOrThrow(
111
+ buildOptions: Parameters<typeof Bun.build>[0]
112
+ ): Promise<Awaited<ReturnType<typeof Bun.build>>> {
113
+ let result: Awaited<ReturnType<typeof Bun.build>>;
114
+ try {
115
+ result = await Bun.build(buildOptions);
116
+ } catch (err) {
117
+ const detail = extractBunBuildDetails(err);
118
+ const message = err instanceof Error ? err.message : 'Bun.build failed.';
119
+ if (detail) {
120
+ throw new Error(`${message}\n${detail}`);
121
+ }
122
+ throw new Error(message);
123
+ }
124
+
125
+ if (!result.success) {
126
+ const detail = formatBunBuildLogs((result as { logs?: unknown }).logs);
127
+ if (detail) {
128
+ throw new Error(`Bun.build failed.\n${detail}`);
129
+ }
130
+ throw new Error('Bun.build failed.');
131
+ }
132
+
133
+ return result;
134
+ }
@@ -0,0 +1,74 @@
1
+ import { dirname, resolve } from 'path';
2
+ import { existsSync, mkdirSync, unlinkSync, writeFileSync } from 'fs';
3
+
4
+ export function prepareVirtualEntry(options: {
5
+ cwd: string;
6
+ outfile: string;
7
+ pageDir: string;
8
+ source: string;
9
+ hasPages: boolean;
10
+ }): { outDir: string; virtualSourcePath: string; virtualPath: string } {
11
+ const outDir = resolve(options.cwd, dirname(options.outfile));
12
+ mkdirSync(outDir, { recursive: true });
13
+
14
+ const virtualEntryDir = options.hasPages
15
+ ? resolve(options.cwd, options.pageDir)
16
+ : outDir;
17
+ mkdirSync(virtualEntryDir, { recursive: true });
18
+
19
+ const virtualSourcePath = resolve(virtualEntryDir, '__burger_build_entry__.ts');
20
+ const virtualPath = virtualSourcePath.split('\\').join('/');
21
+ writeFileSync(virtualSourcePath, options.source, 'utf-8');
22
+
23
+ return { outDir, virtualSourcePath, virtualPath };
24
+ }
25
+
26
+ export function cleanupVirtualEntry(virtualSourcePath: string): void {
27
+ if (existsSync(virtualSourcePath)) {
28
+ unlinkSync(virtualSourcePath);
29
+ }
30
+ }
31
+
32
+ export async function finalizeBuildOutputs(options: {
33
+ result: Awaited<ReturnType<typeof Bun.build>>;
34
+ cwd: string;
35
+ outfile: string;
36
+ outDir: string;
37
+ compile?: boolean;
38
+ }): Promise<{ path: string; size: number }[]> {
39
+ const desiredOut = resolve(options.cwd, options.outfile);
40
+ const outputs: { path: string; size: number }[] = [];
41
+
42
+ if (!options.result.outputs?.length) {
43
+ return outputs;
44
+ }
45
+
46
+ const first = options.result.outputs[0] as Blob & { path?: string };
47
+ const outPath = first.path ? resolve(first.path) : undefined;
48
+
49
+ const entryArtifacts = new Set<string>([
50
+ resolve(options.outDir, '__burger_build_entry__.js'),
51
+ ]);
52
+ if (outPath?.endsWith('__burger_build_entry__.js')) {
53
+ entryArtifacts.add(outPath);
54
+ }
55
+
56
+ if (!options.compile && outPath && outPath !== desiredOut) {
57
+ const blob = first as Blob;
58
+ await Bun.write(desiredOut, blob);
59
+ outputs.push({ path: desiredOut, size: blob.size });
60
+ } else {
61
+ outputs.push({
62
+ path: outPath ?? desiredOut,
63
+ size: (first as Blob).size ?? 0,
64
+ });
65
+ }
66
+
67
+ for (const entryArtifact of entryArtifacts) {
68
+ if (entryArtifact !== desiredOut && existsSync(entryArtifact)) {
69
+ unlinkSync(entryArtifact);
70
+ }
71
+ }
72
+
73
+ return outputs;
74
+ }
@@ -0,0 +1,90 @@
1
+ import { resolveBuildConfig } from '../config';
2
+ import { scanApiRoutes, scanPageRoutes } from '../scanner';
3
+ import { generateVirtualEntrySource } from '../virtual-entry';
4
+ import { createBunBuildOptions, runBunBuildOrThrow } from './bun';
5
+ import {
6
+ cleanupVirtualEntry,
7
+ finalizeBuildOutputs,
8
+ prepareVirtualEntry,
9
+ } from './entry';
10
+ import {
11
+ cleanupEntryOptionsModule,
12
+ prepareEntryOptionsModule,
13
+ } from '../entry-options';
14
+
15
+ export interface VirtualBuildResult {
16
+ success: boolean;
17
+ hasPages: boolean;
18
+ outputs: { path: string; size: number }[];
19
+ }
20
+
21
+ export async function runVirtualEntryBuild(options: {
22
+ cwd: string;
23
+ entryFile: string;
24
+ outfile: string;
25
+ target?: string;
26
+ minify?: boolean;
27
+ sourcemap?: string;
28
+ compile?: boolean;
29
+ bytecode?: boolean;
30
+ }): Promise<VirtualBuildResult> {
31
+ const config = await resolveBuildConfig(options.cwd);
32
+ const entryOptions = prepareEntryOptionsModule({
33
+ cwd: options.cwd,
34
+ entryFile: options.entryFile,
35
+ });
36
+
37
+ const [apiEntries, pageEntries] = await Promise.all([
38
+ scanApiRoutes(options.cwd, config.apiDir, config.apiPrefix),
39
+ scanPageRoutes(options.cwd, config.pageDir, config.pagePrefix),
40
+ ]);
41
+
42
+ if (apiEntries.length === 0 && pageEntries.length === 0) {
43
+ throw new Error(
44
+ `No routes found. Ensure ${config.apiDir} or ${config.pageDir} ` +
45
+ `exist and contain route.ts files or page files.`
46
+ );
47
+ }
48
+
49
+ const source = generateVirtualEntrySource(
50
+ config,
51
+ apiEntries,
52
+ pageEntries,
53
+ entryOptions.importPath
54
+ );
55
+ const hasPages = pageEntries.length > 0;
56
+ const { outDir, virtualPath, virtualSourcePath } = prepareVirtualEntry({
57
+ cwd: options.cwd,
58
+ outfile: options.outfile,
59
+ pageDir: config.pageDir,
60
+ source,
61
+ hasPages,
62
+ });
63
+
64
+ const buildOptions = createBunBuildOptions({
65
+ entryPath: virtualPath,
66
+ outDir,
67
+ cwd: options.cwd,
68
+ outfile: options.outfile,
69
+ target: options.target,
70
+ minify: options.minify,
71
+ sourcemap: options.sourcemap,
72
+ compile: options.compile,
73
+ bytecode: options.bytecode,
74
+ });
75
+
76
+ try {
77
+ const result = await runBunBuildOrThrow(buildOptions);
78
+ const outputs = await finalizeBuildOutputs({
79
+ result,
80
+ cwd: options.cwd,
81
+ outfile: options.outfile,
82
+ outDir,
83
+ compile: options.compile,
84
+ });
85
+ return { success: result.success ?? false, hasPages, outputs };
86
+ } finally {
87
+ cleanupVirtualEntry(virtualSourcePath);
88
+ cleanupEntryOptionsModule(entryOptions.tempFilePath);
89
+ }
90
+ }
@@ -0,0 +1,21 @@
1
+ import { existsSync, readFileSync } from 'fs';
2
+ import { join } from 'path';
3
+
4
+ /**
5
+ * Read the project name from package.json.
6
+ * Falls back to 'app' if not found or on any error.
7
+ */
8
+ export function getProjectName(cwd: string = process.cwd()): string {
9
+ try {
10
+ const packageJsonPath = join(cwd, 'package.json');
11
+ if (existsSync(packageJsonPath)) {
12
+ const pkg = JSON.parse(readFileSync(packageJsonPath, 'utf-8')) as {
13
+ name?: string;
14
+ };
15
+ return pkg?.name || 'app';
16
+ }
17
+ } catch {
18
+ // Ignore — use fallback
19
+ }
20
+ return 'app';
21
+ }
@@ -7,7 +7,9 @@
7
7
 
8
8
  import { existsSync } from 'fs';
9
9
  import { join } from 'path';
10
+ import { pathToFileURL } from 'url';
10
11
  import type { BuildConfig } from '../types/index';
12
+ import { warning } from './logger';
11
13
 
12
14
  const CONVENTION_DEFAULTS: BuildConfig = {
13
15
  apiDir: './src/api',
@@ -41,15 +43,16 @@ export async function resolveBuildConfig(cwd: string): Promise<BuildConfig> {
41
43
  }
42
44
 
43
45
  try {
44
- const mod = await import(configPath);
46
+ const configUrl = pathToFileURL(configPath).href;
47
+ const mod = await import(configUrl);
45
48
  const user = mod.default ?? mod;
46
49
  if (!user || typeof user !== 'object') {
47
50
  return { ...CONVENTION_DEFAULTS };
48
51
  }
49
52
  return mergeBuildConfig(CONVENTION_DEFAULTS, user);
50
53
  } catch (err) {
51
- console.warn(
52
- `[burger-api] Could not load ${configPath}: ${err instanceof Error ? err.message : String(err)}. Using convention defaults.`
54
+ warning(
55
+ `Could not load ${configPath}: ${err instanceof Error ? err.message : String(err)}. Using convention defaults.`
53
56
  );
54
57
  return { ...CONVENTION_DEFAULTS };
55
58
  }
@@ -60,8 +63,7 @@ function mergeBuildConfig(
60
63
  user: Record<string, unknown>
61
64
  ): BuildConfig {
62
65
  return {
63
- apiDir:
64
- typeof user.apiDir === 'string' ? user.apiDir : defaults.apiDir,
66
+ apiDir: typeof user.apiDir === 'string' ? user.apiDir : defaults.apiDir,
65
67
  pageDir:
66
68
  typeof user.pageDir === 'string' ? user.pageDir : defaults.pageDir,
67
69
  apiPrefix:
@@ -72,7 +74,6 @@ function mergeBuildConfig(
72
74
  typeof user.pagePrefix === 'string'
73
75
  ? user.pagePrefix
74
76
  : defaults.pagePrefix,
75
- debug:
76
- typeof user.debug === 'boolean' ? user.debug : defaults.debug,
77
+ debug: typeof user.debug === 'boolean' ? user.debug : defaults.debug,
77
78
  };
78
79
  }
@@ -14,17 +14,37 @@ import type { GitHubFile, MiddlewareInfo } from '../types/index';
14
14
  import { unlinkSync } from 'fs';
15
15
 
16
16
  /**
17
- * Configuration for GitHub repository
18
- * Change these if you fork the project or want to test with a different repo
17
+ * Configuration for GitHub repository.
18
+ * Override via env: BURGER_API_REPO_OWNER, BURGER_API_REPO_NAME, BURGER_API_BRANCH.
19
19
  */
20
- const REPO_OWNER = 'isfhan';
21
- const REPO_NAME = 'burger-api';
22
- const BRANCH = 'main';
20
+ const REPO_OWNER = process.env.BURGER_API_REPO_OWNER ?? 'isfhan';
21
+ const REPO_NAME = process.env.BURGER_API_REPO_NAME ?? 'burger-api';
22
+ const BRANCH = process.env.BURGER_API_BRANCH ?? 'main';
23
23
 
24
24
  // Build the URLs we'll use to access GitHub
25
25
  const RAW_URL = `https://raw.githubusercontent.com/${REPO_OWNER}/${REPO_NAME}/${BRANCH}`;
26
26
  const API_URL = `https://api.github.com/repos/${REPO_OWNER}/${REPO_NAME}`;
27
27
 
28
+ const FETCH_TIMEOUT_MS = 20_000;
29
+
30
+ function createFetchSignal(): AbortSignal {
31
+ const controller = new AbortController();
32
+ const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
33
+ controller.signal.addEventListener('abort', () => clearTimeout(timer), {
34
+ once: true,
35
+ });
36
+ return controller.signal;
37
+ }
38
+
39
+ function wrapFetchError(err: unknown, fallbackMessage: string): Error {
40
+ if (err instanceof Error && err.name === 'AbortError') {
41
+ return new Error(
42
+ 'Request timed out. Please check your internet connection.'
43
+ );
44
+ }
45
+ return new Error(err instanceof Error ? err.message : fallbackMessage);
46
+ }
47
+
28
48
  /**
29
49
  * Get list of available middleware from GitHub
30
50
  * This scans the ecosystem/middlewares folder and returns what's available
@@ -41,9 +61,9 @@ export async function getMiddlewareList(): Promise<string[]> {
41
61
  const response = await fetch(
42
62
  `${API_URL}/contents/ecosystem/middlewares`,
43
63
  {
64
+ signal: createFetchSignal(),
44
65
  headers: {
45
66
  Accept: 'application/vnd.github.v3+json',
46
- // Add User-Agent to be nice to GitHub
47
67
  'User-Agent': 'burger-api-cli',
48
68
  },
49
69
  }
@@ -64,8 +84,8 @@ export async function getMiddlewareList(): Promise<string[]> {
64
84
  .map((f) => f.name)
65
85
  .sort();
66
86
  } catch (err) {
67
- // Provide helpful error message
68
- throw new Error(
87
+ throw wrapFetchError(
88
+ err,
69
89
  'Could not get middleware list from GitHub. Please check your internet connection.'
70
90
  );
71
91
  }
@@ -87,6 +107,7 @@ export async function getMiddlewareInfo(name: string): Promise<MiddlewareInfo> {
87
107
  const response = await fetch(
88
108
  `${API_URL}/contents/ecosystem/middlewares/${name}`,
89
109
  {
110
+ signal: createFetchSignal(),
90
111
  headers: {
91
112
  Accept: 'application/vnd.github.v3+json',
92
113
  'User-Agent': 'burger-api-cli',
@@ -108,7 +129,9 @@ export async function getMiddlewareInfo(name: string): Promise<MiddlewareInfo> {
108
129
 
109
130
  if (readmeFile && readmeFile.download_url) {
110
131
  try {
111
- const readmeResponse = await fetch(readmeFile.download_url);
132
+ const readmeResponse = await fetch(readmeFile.download_url, {
133
+ signal: createFetchSignal(),
134
+ });
112
135
  const readmeContent = await readmeResponse.text();
113
136
 
114
137
  // Extract first non-empty line after the title as description
@@ -132,7 +155,10 @@ export async function getMiddlewareInfo(name: string): Promise<MiddlewareInfo> {
132
155
  files: files.map((f) => f.name),
133
156
  };
134
157
  } catch (err) {
135
- throw new Error(`Could not get info for middleware "${name}"`);
158
+ throw wrapFetchError(
159
+ err,
160
+ `Could not get info for middleware "${name}"`
161
+ );
136
162
  }
137
163
  }
138
164
 
@@ -154,8 +180,9 @@ export async function downloadFile(
154
180
  // Build the URL to the raw file content
155
181
  const url = `${RAW_URL}/${path}`;
156
182
 
157
- // Download using Bun's native fetch
158
- const response = await fetch(url);
183
+ const response = await fetch(url, {
184
+ signal: createFetchSignal(),
185
+ });
159
186
 
160
187
  if (!response.ok) {
161
188
  throw new Error(`Could not download ${path}`);
@@ -168,7 +195,8 @@ export async function downloadFile(
168
195
  // Bun.write is much faster than Node's fs.writeFile!
169
196
  await Bun.write(destination, content);
170
197
  } catch (err) {
171
- throw new Error(
198
+ throw wrapFetchError(
199
+ err,
172
200
  `Failed to download ${path}: ${
173
201
  err instanceof Error ? err.message : 'Unknown error'
174
202
  }`
@@ -246,6 +274,7 @@ export async function middlewareExists(name: string): Promise<boolean> {
246
274
  const response = await fetch(
247
275
  `${API_URL}/contents/ecosystem/middlewares/${name}`,
248
276
  {
277
+ signal: createFetchSignal(),
249
278
  headers: {
250
279
  Accept: 'application/vnd.github.v3+json',
251
280
  'User-Agent': 'burger-api-cli',
@@ -379,6 +379,27 @@ export function spinner(message: string): Spinner {
379
379
  return new Spinner(message);
380
380
  }
381
381
 
382
+ /**
383
+ * Run an async command with a spinner. On throw, stops the spinner with error state and rethrows.
384
+ * Use so catch blocks do not need to remember to stop the spinner.
385
+ *
386
+ * @param message - Spinner message
387
+ * @param fn - Async callback receiving the spinner
388
+ * @returns Result of fn
389
+ */
390
+ export async function withSpinner<T>(
391
+ message: string,
392
+ fn: (spin: Spinner) => Promise<T>
393
+ ): Promise<T> {
394
+ const spin = new Spinner(message);
395
+ try {
396
+ return await fn(spin);
397
+ } catch (err) {
398
+ spin.stop(message.replace(/\s*\.\.\.\s*$/, '') + ' failed', true);
399
+ throw err;
400
+ }
401
+ }
402
+
382
403
  /**
383
404
  * Format a file size in a human-readable way
384
405
  * Converts bytes to KB, MB, etc.
@@ -30,7 +30,7 @@ export function generatePackageJson(projectName: string): string {
30
30
  build: 'bun build src/index.ts --outdir ./dist',
31
31
  },
32
32
  dependencies: {
33
- 'burger-api': '^0.9.0',
33
+ 'burger-api': '^0.9.3',
34
34
  },
35
35
  devDependencies: {
36
36
  '@types/bun': 'latest',
@@ -962,7 +962,7 @@ export function generateIndexPage(projectName: string): string {
962
962
 
963
963
  <!-- Footer -->
964
964
  <footer class="footer">
965
- <div class="version">BurgerAPI v0.9.0 • Bun v1.3+</div>
965
+ <div class="version">BurgerAPI v0.9.3 • Bun v1.3+</div>
966
966
  <div class="social-links">
967
967
  <a href="https://github.com/isfhan/burger-api" target="_blank">GitHub</a>
968
968
  <a href="https://www.npmjs.com/package/burger-api" target="_blank">NPM</a>
@@ -1142,7 +1142,12 @@ export async function installDependencies(projectDir: string): Promise<void> {
1142
1142
  const exitCode = await proc.exited;
1143
1143
 
1144
1144
  if (exitCode !== 0) {
1145
- throw new Error('bun install failed');
1145
+ const stderrText = await new Response(proc.stderr).text();
1146
+ const message =
1147
+ stderrText.trim().length > 0
1148
+ ? `bun install failed:\n${stderrText.trim()}`
1149
+ : 'bun install failed';
1150
+ throw new Error(message);
1146
1151
  }
1147
1152
 
1148
1153
  spin.stop('Dependencies installed!');