@teyik0/furin 0.1.0-alpha.3

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.
Files changed (71) hide show
  1. package/dist/adapter/bun.d.ts +3 -0
  2. package/dist/build/client.d.ts +14 -0
  3. package/dist/build/compile-entry.d.ts +22 -0
  4. package/dist/build/entry-template.d.ts +13 -0
  5. package/dist/build/hydrate.d.ts +20 -0
  6. package/dist/build/index.d.ts +7 -0
  7. package/dist/build/index.js +2212 -0
  8. package/dist/build/route-types.d.ts +20 -0
  9. package/dist/build/scan-server.d.ts +8 -0
  10. package/dist/build/server-routes-entry.d.ts +22 -0
  11. package/dist/build/shared.d.ts +12 -0
  12. package/dist/build/types.d.ts +53 -0
  13. package/dist/cli/config.d.ts +9 -0
  14. package/dist/cli/index.d.ts +1 -0
  15. package/dist/cli/index.js +2240 -0
  16. package/dist/client.d.ts +158 -0
  17. package/dist/client.js +20 -0
  18. package/dist/config.d.ts +16 -0
  19. package/dist/config.js +23 -0
  20. package/dist/furin.d.ts +45 -0
  21. package/dist/furin.js +937 -0
  22. package/dist/internal.d.ts +18 -0
  23. package/dist/link.d.ts +119 -0
  24. package/dist/link.js +281 -0
  25. package/dist/plugin/index.d.ts +20 -0
  26. package/dist/plugin/index.js +1408 -0
  27. package/dist/plugin/transform-client.d.ts +9 -0
  28. package/dist/render/assemble.d.ts +13 -0
  29. package/dist/render/cache.d.ts +7 -0
  30. package/dist/render/element.d.ts +4 -0
  31. package/dist/render/index.d.ts +26 -0
  32. package/dist/render/loaders.d.ts +12 -0
  33. package/dist/render/shell.d.ts +17 -0
  34. package/dist/render/template.d.ts +4 -0
  35. package/dist/router.d.ts +32 -0
  36. package/dist/router.js +575 -0
  37. package/dist/runtime-env.d.ts +3 -0
  38. package/dist/tsconfig.dts.tsbuildinfo +1 -0
  39. package/dist/utils.d.ts +6 -0
  40. package/package.json +74 -0
  41. package/src/adapter/README.md +13 -0
  42. package/src/adapter/bun.ts +119 -0
  43. package/src/build/client.ts +110 -0
  44. package/src/build/compile-entry.ts +99 -0
  45. package/src/build/entry-template.ts +62 -0
  46. package/src/build/hydrate.ts +106 -0
  47. package/src/build/index.ts +120 -0
  48. package/src/build/route-types.ts +88 -0
  49. package/src/build/scan-server.ts +88 -0
  50. package/src/build/server-routes-entry.ts +38 -0
  51. package/src/build/shared.ts +80 -0
  52. package/src/build/types.ts +60 -0
  53. package/src/cli/config.ts +68 -0
  54. package/src/cli/index.ts +106 -0
  55. package/src/client.ts +237 -0
  56. package/src/config.ts +31 -0
  57. package/src/furin.ts +251 -0
  58. package/src/internal.ts +36 -0
  59. package/src/link.tsx +480 -0
  60. package/src/plugin/index.ts +80 -0
  61. package/src/plugin/transform-client.ts +372 -0
  62. package/src/render/assemble.ts +57 -0
  63. package/src/render/cache.ts +9 -0
  64. package/src/render/element.tsx +28 -0
  65. package/src/render/index.ts +312 -0
  66. package/src/render/loaders.ts +67 -0
  67. package/src/render/shell.ts +128 -0
  68. package/src/render/template.ts +54 -0
  69. package/src/router.ts +234 -0
  70. package/src/runtime-env.ts +6 -0
  71. package/src/utils.ts +68 -0
@@ -0,0 +1,88 @@
1
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import type { ResolvedRoute } from "../router";
4
+
5
+ /** @internal Exported for unit testing only. */
6
+ export function patternToTypeString(pattern: string): string {
7
+ // biome-ignore lint/suspicious/noTemplateCurlyInString: intentional — generates TS template literal syntax
8
+ const t = pattern.replace(/:[^/]+/g, "${string}").replace(/\*/g, "${string}");
9
+ return t.includes("${") ? `\`${t}\`` : `"${t}"`;
10
+ }
11
+
12
+ /**
13
+ * Converts a runtime TypeBox/JSON Schema object to a TypeScript type string.
14
+ * Handles the common cases found in Elysia query schemas (string, number, boolean,
15
+ * optional fields, nullable via anyOf).
16
+ *
17
+ * @internal Exported for unit testing only.
18
+ */
19
+ export function schemaToTypeString(schema: unknown): string {
20
+ if (!schema || typeof schema !== "object") {
21
+ return "unknown";
22
+ }
23
+ const s = schema as Record<string, unknown>;
24
+ if (s.anyOf && Array.isArray(s.anyOf)) {
25
+ const parts = (s.anyOf as unknown[]).map(schemaToTypeString).filter((t) => t !== "null");
26
+ return parts.join(" | ") || "unknown";
27
+ }
28
+ switch (s.type) {
29
+ case "string":
30
+ return "string";
31
+ case "number":
32
+ case "integer":
33
+ return "number";
34
+ case "boolean":
35
+ return "boolean";
36
+ case "null":
37
+ return "null";
38
+ case "object": {
39
+ if (!s.properties || typeof s.properties !== "object") {
40
+ return "Record<string, unknown>";
41
+ }
42
+ const required = new Set<string>(Array.isArray(s.required) ? (s.required as string[]) : []);
43
+ const props = Object.entries(s.properties as Record<string, unknown>)
44
+ .map(([k, v]) => `${k}${required.has(k) ? "" : "?"}: ${schemaToTypeString(v)}`)
45
+ .join("; ");
46
+ return `{ ${props} }`;
47
+ }
48
+ default:
49
+ return "unknown";
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Generates .furin/routes.d.ts — augments RouteManifest in furin/link
55
+ * so that <Link to="..."> has type-safe autocompletion and <Link search={...}>
56
+ * is typed per-route from the route's query schema.
57
+ *
58
+ * Users must add ".furin/routes.d.ts" to their tsconfig.json "include" array once.
59
+ */
60
+ /** @internal Exported for unit testing only. */
61
+ export function writeRouteTypes(routes: ResolvedRoute[], outDir: string): void {
62
+ const entries = routes.map((r) => {
63
+ const typeKey = patternToTypeString(r.pattern);
64
+ const isDynamic = typeKey.startsWith("`");
65
+ const querySchema = r.routeChain?.find((rt) => rt.query)?.query;
66
+ const searchType = querySchema ? schemaToTypeString(querySchema) : "never";
67
+ return isDynamic
68
+ ? ` [key: ${typeKey}]: { search?: ${searchType} }`
69
+ : ` ${typeKey}: { search?: ${searchType} }`;
70
+ });
71
+
72
+ const content = `// Auto-generated by Furin. Do not edit manually.
73
+ // Add ".furin/routes.d.ts" to your tsconfig.json "include" array to enable typed navigation.
74
+ import "@teyik0/furin/link";
75
+
76
+ declare module "@teyik0/furin/link" {
77
+ interface RouteManifest {
78
+ ${entries.join(";\n")};
79
+ }
80
+ }
81
+ `;
82
+
83
+ const typesPath = join(outDir, "routes.d.ts");
84
+ const existing = existsSync(typesPath) ? readFileSync(typesPath, "utf8") : "";
85
+ if (content !== existing) {
86
+ writeFileSync(typesPath, content);
87
+ }
88
+ }
@@ -0,0 +1,88 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { parseSync } from "oxc-parser";
3
+
4
+ // Minimal AST node shapes — just what we need
5
+ interface AstNode {
6
+ type: string;
7
+ [key: string]: unknown;
8
+ }
9
+
10
+ /**
11
+ * Statically scans a server entry file and returns all `pagesDir` string
12
+ * literal values found inside `furin({ pagesDir: "..." })` call expressions.
13
+ *
14
+ * Dynamic paths (template literals, variables) are silently ignored.
15
+ * Returns an empty array when nothing is detected.
16
+ */
17
+ export function scanFurinInstances(serverEntryPath: string): string[] {
18
+ const code = readFileSync(serverEntryPath, "utf8");
19
+ const { program, errors } = parseSync(serverEntryPath, code);
20
+ if (errors.length > 0) {
21
+ return [];
22
+ }
23
+
24
+ const results: string[] = [];
25
+ walkNode(program as unknown as AstNode, results);
26
+ return results;
27
+ }
28
+
29
+ function walkNode(node: AstNode, out: string[]): void {
30
+ if (!node || typeof node !== "object") return;
31
+
32
+ if (node.type === "CallExpression") {
33
+ const callee = node.callee as AstNode | undefined;
34
+ const args = node.arguments as AstNode[] | undefined;
35
+
36
+ const isElyraCall =
37
+ callee?.type === "Identifier" && (callee as { name?: string }).name === "furin";
38
+
39
+ if (isElyraCall && Array.isArray(args) && args.length > 0) {
40
+ const firstArg = args[0] as AstNode;
41
+ if (firstArg?.type === "ObjectExpression") {
42
+ const pagesDir = extractStringProperty(firstArg, "pagesDir");
43
+ if (pagesDir !== null) {
44
+ out.push(pagesDir);
45
+ }
46
+ }
47
+ }
48
+ }
49
+
50
+ // Recurse into all child node values
51
+ for (const key of Object.keys(node)) {
52
+ if (key === "type" || key === "start" || key === "end") continue;
53
+ const child = node[key];
54
+ if (Array.isArray(child)) {
55
+ for (const item of child) {
56
+ if (item && typeof item === "object") {
57
+ walkNode(item as AstNode, out);
58
+ }
59
+ }
60
+ } else if (child && typeof child === "object") {
61
+ walkNode(child as AstNode, out);
62
+ }
63
+ }
64
+ }
65
+
66
+ function extractStringProperty(obj: AstNode, propName: string): string | null {
67
+ const properties = obj.properties as AstNode[] | undefined;
68
+ if (!Array.isArray(properties)) return null;
69
+
70
+ for (const prop of properties) {
71
+ if (prop.type !== "Property") continue;
72
+ const key = prop.key as AstNode & { name?: string; value?: unknown };
73
+ const value = prop.value as AstNode & { value?: unknown };
74
+
75
+ const keyMatches =
76
+ (key.type === "Identifier" && key.name === propName) ||
77
+ (key.type === "Literal" && key.value === propName);
78
+
79
+ if (!keyMatches) continue;
80
+
81
+ // Only accept string literals — ignore template literals, identifiers, etc.
82
+ if (value?.type === "Literal" && typeof value.value === "string") {
83
+ return value.value;
84
+ }
85
+ return null; // dynamic path — silently skip
86
+ }
87
+ return null;
88
+ }
@@ -0,0 +1,38 @@
1
+ import { writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { buildEntrySource } from "./entry-template";
4
+ import { ensureDir } from "./shared";
5
+
6
+ export interface ServerRoutesEntryOptions {
7
+ outDir: string;
8
+ rootPath: string;
9
+ routes: Array<{ mode: "ssr" | "ssg" | "isr"; path: string; pattern: string }>;
10
+ serverEntry: string;
11
+ }
12
+
13
+ /**
14
+ * Generates `server.ts` — an intermediate entry used to produce `server.js`.
15
+ *
16
+ * Equivalent to `_compile-entry.ts` but for disk (non-binary) production builds:
17
+ * 1. Statically imports every page module so Bun bundles them into server.js
18
+ * 2. Sets production mode and registers everything in a single CompileContext
19
+ * 3. Imports server.ts to boot the app
20
+ *
21
+ * This file is intermediate: `adapter/bun.ts` runs `Bun.build()` on it to produce
22
+ * the self-contained `server.js` bundle, then deletes this file.
23
+ */
24
+ export function generateServerRoutesEntry(options: ServerRoutesEntryOptions): string {
25
+ const { outDir, rootPath, routes, serverEntry } = options;
26
+ ensureDir(outDir);
27
+ const source = buildEntrySource({
28
+ headerComment: "// Auto-generated by furin build — do not edit",
29
+ rootPath,
30
+ routes,
31
+ serverEntry,
32
+ });
33
+
34
+ // Named "server.ts" so Bun.build() outputs "server.js"
35
+ const entryPath = join(outDir, "server.ts");
36
+ writeFileSync(entryPath, source);
37
+ return entryPath;
38
+ }
@@ -0,0 +1,80 @@
1
+ import { cpSync, existsSync, mkdirSync, readdirSync, rmSync, writeFileSync } from "node:fs";
2
+ import { join, relative, resolve } from "node:path";
3
+ import type { BuildTarget } from "../config";
4
+ import type { ResolvedRoute } from "../router";
5
+ import type { BuildRouteManifestEntry, TargetBuildManifest } from "./types";
6
+
7
+ export const CLIENT_MODULE_PATH = resolve(import.meta.dir, "../client.ts").replace(/\\/g, "/");
8
+ export const LINK_MODULE_PATH = resolve(import.meta.dir, "../link.tsx").replace(/\\/g, "/");
9
+
10
+ export function ensureDir(path: string): void {
11
+ if (!existsSync(path)) {
12
+ mkdirSync(path, { recursive: true });
13
+ }
14
+ }
15
+
16
+ export function toPosixPath(path: string): string {
17
+ return path.replace(/\\/g, "/");
18
+ }
19
+
20
+ export function collectFilesRecursive(dir: string): string[] {
21
+ const files: string[] = [];
22
+ const entries = readdirSync(dir, { withFileTypes: true });
23
+
24
+ for (const entry of entries) {
25
+ const absolutePath = join(dir, entry.name);
26
+ if (entry.isDirectory()) {
27
+ files.push(...collectFilesRecursive(absolutePath));
28
+ continue;
29
+ }
30
+ if (entry.isFile()) {
31
+ files.push(absolutePath);
32
+ }
33
+ }
34
+
35
+ return files.sort();
36
+ }
37
+
38
+ export function copyDirRecursive(sourceDir: string, targetDir: string): void {
39
+ rmSync(targetDir, { force: true, recursive: true });
40
+ cpSync(sourceDir, targetDir, { recursive: true });
41
+ }
42
+
43
+ export function toBuildRouteManifestEntry(
44
+ route: ResolvedRoute,
45
+ rootDir: string
46
+ ): BuildRouteManifestEntry {
47
+ return {
48
+ pattern: route.pattern,
49
+ mode: route.mode,
50
+ pagePath: toPosixPath(relative(rootDir, route.path)),
51
+ hasLayout: route.routeChain.some((entry) => !!entry.layout),
52
+ hasStaticParams: !!route.page?.staticParams,
53
+ revalidate: route.page?._route.revalidate ?? null,
54
+ };
55
+ }
56
+
57
+ export function buildTargetManifest(
58
+ rootDir: string,
59
+ buildRoot: string,
60
+ target: BuildTarget,
61
+ serverEntry: string | null
62
+ ): TargetBuildManifest {
63
+ const targetDir = join(buildRoot, target);
64
+ const manifestPath = join(targetDir, "manifest.json");
65
+
66
+ return {
67
+ generatedAt: new Date().toISOString(),
68
+ targetDir: toPosixPath(relative(rootDir, targetDir)),
69
+ clientDir: toPosixPath(relative(rootDir, join(targetDir, "client"))),
70
+ templatePath: toPosixPath(relative(rootDir, join(targetDir, "client", "index.html"))),
71
+ manifestPath: toPosixPath(relative(rootDir, manifestPath)),
72
+ serverPath: null,
73
+ serverEntry: serverEntry ? toPosixPath(relative(rootDir, serverEntry)) : null,
74
+ };
75
+ }
76
+
77
+ export function writeTargetManifest(targetDir: string, targetManifest: TargetBuildManifest): void {
78
+ const manifestPath = join(targetDir, "manifest.json");
79
+ writeFileSync(manifestPath, `${JSON.stringify(targetManifest, null, 2)}\n`);
80
+ }
@@ -0,0 +1,60 @@
1
+ import type { BuildTarget } from "../config";
2
+ import type { ResolvedRoute } from "../router";
3
+
4
+ export interface BuildClientOptions {
5
+ outDir: string;
6
+ pagesDir?: string;
7
+ plugins?: Bun.BunPlugin[];
8
+ rootLayout: string;
9
+ }
10
+
11
+ export interface BuildRouteManifestEntry {
12
+ hasLayout: boolean;
13
+ hasStaticParams: boolean;
14
+ mode: ResolvedRoute["mode"];
15
+ pagePath: string;
16
+ pattern: string;
17
+ revalidate: number | null;
18
+ }
19
+
20
+ export interface TargetBuildManifest {
21
+ clientDir: string;
22
+ generatedAt: string;
23
+ manifestPath: string;
24
+ serverEntry: string | null;
25
+ serverPath: string | null;
26
+ targetDir: string;
27
+ templatePath: string;
28
+ }
29
+
30
+ export interface BuildManifest {
31
+ generatedAt: string;
32
+ pagesDir: string;
33
+ rootDir: string;
34
+ rootPath: string;
35
+ routes: BuildRouteManifestEntry[];
36
+ serverEntry: string | null;
37
+ targets: Partial<Record<BuildTarget, TargetBuildManifest>>;
38
+ version: 1;
39
+ }
40
+
41
+ export interface BuildAppOptions {
42
+ compile?: "server" | "embed";
43
+ pagesDir?: string;
44
+ plugins?: Bun.BunPlugin[];
45
+ rootDir?: string;
46
+ serverEntry?: string;
47
+ target: BuildTarget | "all";
48
+ }
49
+
50
+ export interface BuildAppResult {
51
+ manifest: BuildManifest;
52
+ targets: Partial<Record<BuildTarget, TargetBuildManifest>>;
53
+ }
54
+
55
+ export type BunBuildAliasConfig = Bun.BuildConfig & {
56
+ alias?: Record<string, string>;
57
+ outfile?: string;
58
+ packages?: "bundle" | "external";
59
+ write?: boolean;
60
+ };
@@ -0,0 +1,68 @@
1
+ import { existsSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ import { pathToFileURL } from "node:url";
4
+ import { TypeCompiler } from "elysia/type-system";
5
+ import { configSchema, type FurinConfig } from "../config.ts";
6
+
7
+ const compiledConfigSchema = TypeCompiler.Compile(configSchema);
8
+
9
+ const DEFAULT_CONFIG_FILENAMES = [
10
+ "furin.config.ts",
11
+ "furin.config.js",
12
+ "furin.config.mjs",
13
+ ] as const;
14
+
15
+ interface ResolvedCliConfig extends FurinConfig {
16
+ configPath: string | null;
17
+ pagesDir: string;
18
+ plugins?: Bun.BunPlugin[];
19
+ rootDir: string;
20
+ }
21
+
22
+ export async function loadCliConfig(
23
+ cwd: string,
24
+ explicitConfigPath?: string
25
+ ): Promise<ResolvedCliConfig> {
26
+ const rootDir = resolve(cwd);
27
+ const configPath = explicitConfigPath
28
+ ? resolve(rootDir, explicitConfigPath)
29
+ : DEFAULT_CONFIG_FILENAMES.map((filename) => resolve(rootDir, filename)).find((path) =>
30
+ existsSync(path)
31
+ );
32
+
33
+ if (!configPath) {
34
+ return {
35
+ configPath: null,
36
+ rootDir,
37
+ pagesDir: resolve(rootDir, "src/pages"),
38
+ };
39
+ }
40
+
41
+ const imported = await import(pathToFileURL(configPath).href);
42
+ const rawConfig: FurinConfig = imported.default ?? imported;
43
+
44
+ // Extract plugins before TypeBox validation: functions cannot be JSON-schema validated
45
+ const { plugins, ...configToValidate } = rawConfig;
46
+
47
+ if (plugins !== undefined && !Array.isArray(plugins)) {
48
+ throw new Error(
49
+ `[furin] Invalid config at ${configPath}: "plugins" must be an array of BunPlugin objects`
50
+ );
51
+ }
52
+
53
+ if (!compiledConfigSchema.Check(configToValidate)) {
54
+ const [firstError] = compiledConfigSchema.Errors(configToValidate);
55
+ throw new Error(
56
+ `[furin] Invalid config at ${configPath}: ${firstError?.message ?? "unknown error"} (path: ${firstError?.path ?? "/"})`
57
+ );
58
+ }
59
+
60
+ const resolvedRootDir = resolve(rootDir, configToValidate.rootDir ?? ".");
61
+ return {
62
+ ...configToValidate,
63
+ plugins,
64
+ configPath,
65
+ rootDir: resolvedRootDir,
66
+ pagesDir: resolve(resolvedRootDir, configToValidate.pagesDir ?? "src/pages"),
67
+ };
68
+ }
@@ -0,0 +1,106 @@
1
+ import { existsSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ import { parseArgs } from "node:util";
4
+ import { buildApp } from "../build/index.ts";
5
+ import { BUILD_TARGETS, type BuildTarget } from "../config.ts";
6
+ import { loadCliConfig } from "./config.ts";
7
+
8
+ const argv = process.argv.slice(2);
9
+ const command = argv[0];
10
+
11
+ function log(msg: string): void {
12
+ console.log(`\x1b[32m◆\x1b[0m ${msg}`);
13
+ }
14
+
15
+ function bail(msg: string): never {
16
+ console.error(`\x1b[31m✗\x1b[0m ${msg}`);
17
+ process.exit(1);
18
+ }
19
+
20
+ function resolveCompileMode(
21
+ flag: string | boolean | undefined,
22
+ configCompile: "server" | "embed" | undefined
23
+ ): "server" | "embed" | undefined {
24
+ if (flag === "embed") {
25
+ return "embed";
26
+ }
27
+ if (flag === true || flag === "server") {
28
+ return "server";
29
+ }
30
+ if (flag !== undefined && flag !== false) {
31
+ bail(`Invalid compile mode "${flag}". Valid: --compile server or --compile embed`);
32
+ }
33
+ return configCompile;
34
+ }
35
+
36
+ if (command === "build") {
37
+ const { values: rawValues } = parseArgs({
38
+ args: argv.slice(1),
39
+ options: {
40
+ target: { type: "string" },
41
+ pagesDir: { type: "string" },
42
+ config: { type: "string" },
43
+ },
44
+ strict: false,
45
+ });
46
+
47
+ const values = rawValues as {
48
+ target?: string;
49
+ pagesDir?: string;
50
+ config?: string;
51
+ };
52
+
53
+ // --compile has an optional value: absent → undefined, present alone → true, present with "embed" → "embed"
54
+ const buildArgv = argv.slice(1);
55
+ const compileIdx = buildArgv.indexOf("--compile");
56
+ let compileFlag: string | boolean | undefined;
57
+ if (compileIdx < 0) {
58
+ compileFlag = undefined;
59
+ } else {
60
+ const next = buildArgv[compileIdx + 1];
61
+ compileFlag = next && !next.startsWith("-") ? next : true;
62
+ }
63
+
64
+ const target = values.target ?? "bun";
65
+
66
+ if (target !== "all" && !(BUILD_TARGETS as readonly string[]).includes(target)) {
67
+ bail(`Unsupported build target "${target}". Valid: ${BUILD_TARGETS.join(", ")}, all`);
68
+ }
69
+
70
+ const config = await loadCliConfig(process.cwd(), values.config);
71
+
72
+ const resolvedServerEntry = resolve(config.rootDir, config.serverEntry ?? "src/server.ts");
73
+ if (!existsSync(resolvedServerEntry)) {
74
+ const expected = config.serverEntry ?? "src/server.ts";
75
+ throw new Error(`[furin] Entrypoint ${expected} not found`);
76
+ }
77
+
78
+ log(`Building Furin for ${target}…`);
79
+
80
+ const result = await buildApp({
81
+ target: target as BuildTarget | "all",
82
+ compile: resolveCompileMode(compileFlag, config.bun?.compile),
83
+ rootDir: config.rootDir,
84
+ pagesDir: values.pagesDir ?? config.pagesDir,
85
+ serverEntry: resolvedServerEntry,
86
+ plugins: config.plugins,
87
+ });
88
+
89
+ const built = Object.keys(result.targets).join(", ") || "none";
90
+ log(`Done: ${built} → .furin/build`);
91
+ } else if (!command || command === "help") {
92
+ console.log(
93
+ `Furin CLI
94
+
95
+ USAGE furin build [options]
96
+
97
+ OPTIONS
98
+ --target ${BUILD_TARGETS.join(" | ")} | all (default: bun)
99
+ --pagesDir Pages directory
100
+ --config Config file path
101
+ --compile server | embed Compile to binary: "server" keeps client on disk, "embed" is self-contained
102
+ `
103
+ );
104
+ } else {
105
+ bail(`Unknown command "${command}". Run "furin help" for usage.`);
106
+ }