@enderworld/onlyapi 1.5.1

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 (160) hide show
  1. package/CHANGELOG.md +201 -0
  2. package/LICENSE +21 -0
  3. package/README.md +338 -0
  4. package/dist/cli.js +14 -0
  5. package/package.json +69 -0
  6. package/src/application/dtos/admin.dto.ts +25 -0
  7. package/src/application/dtos/auth.dto.ts +97 -0
  8. package/src/application/dtos/index.ts +40 -0
  9. package/src/application/index.ts +2 -0
  10. package/src/application/services/admin.service.ts +150 -0
  11. package/src/application/services/api-key.service.ts +65 -0
  12. package/src/application/services/auth.service.ts +606 -0
  13. package/src/application/services/health.service.ts +97 -0
  14. package/src/application/services/index.ts +10 -0
  15. package/src/application/services/user.service.ts +95 -0
  16. package/src/cli/commands/help.ts +86 -0
  17. package/src/cli/commands/init.ts +301 -0
  18. package/src/cli/commands/upgrade.ts +471 -0
  19. package/src/cli/index.ts +76 -0
  20. package/src/cli/ui.ts +189 -0
  21. package/src/cluster.ts +62 -0
  22. package/src/core/entities/index.ts +1 -0
  23. package/src/core/entities/user.entity.ts +24 -0
  24. package/src/core/errors/app-error.ts +81 -0
  25. package/src/core/errors/index.ts +15 -0
  26. package/src/core/index.ts +7 -0
  27. package/src/core/ports/account-lockout.ts +15 -0
  28. package/src/core/ports/alert-sink.ts +27 -0
  29. package/src/core/ports/api-key.ts +37 -0
  30. package/src/core/ports/audit-log.ts +46 -0
  31. package/src/core/ports/cache.ts +24 -0
  32. package/src/core/ports/circuit-breaker.ts +42 -0
  33. package/src/core/ports/event-bus.ts +78 -0
  34. package/src/core/ports/index.ts +62 -0
  35. package/src/core/ports/job-queue.ts +73 -0
  36. package/src/core/ports/logger.ts +21 -0
  37. package/src/core/ports/metrics.ts +49 -0
  38. package/src/core/ports/oauth.ts +55 -0
  39. package/src/core/ports/password-hasher.ts +10 -0
  40. package/src/core/ports/password-history.ts +23 -0
  41. package/src/core/ports/password-policy.ts +43 -0
  42. package/src/core/ports/refresh-token-store.ts +37 -0
  43. package/src/core/ports/retry.ts +23 -0
  44. package/src/core/ports/token-blacklist.ts +16 -0
  45. package/src/core/ports/token-service.ts +23 -0
  46. package/src/core/ports/totp-service.ts +16 -0
  47. package/src/core/ports/user.repository.ts +40 -0
  48. package/src/core/ports/verification-token.ts +41 -0
  49. package/src/core/ports/webhook.ts +58 -0
  50. package/src/core/types/brand.ts +19 -0
  51. package/src/core/types/index.ts +19 -0
  52. package/src/core/types/pagination.ts +28 -0
  53. package/src/core/types/result.ts +52 -0
  54. package/src/infrastructure/alerting/index.ts +1 -0
  55. package/src/infrastructure/alerting/webhook.ts +100 -0
  56. package/src/infrastructure/cache/in-memory-cache.ts +111 -0
  57. package/src/infrastructure/cache/index.ts +6 -0
  58. package/src/infrastructure/cache/redis-cache.ts +204 -0
  59. package/src/infrastructure/config/config.ts +185 -0
  60. package/src/infrastructure/config/index.ts +1 -0
  61. package/src/infrastructure/database/in-memory-user.repository.ts +134 -0
  62. package/src/infrastructure/database/index.ts +37 -0
  63. package/src/infrastructure/database/migrations/001_create_users.ts +26 -0
  64. package/src/infrastructure/database/migrations/002_create_token_blacklist.ts +21 -0
  65. package/src/infrastructure/database/migrations/003_create_audit_log.ts +31 -0
  66. package/src/infrastructure/database/migrations/004_auth_platform.ts +112 -0
  67. package/src/infrastructure/database/migrations/runner.ts +120 -0
  68. package/src/infrastructure/database/mssql/index.ts +14 -0
  69. package/src/infrastructure/database/mssql/migrations.ts +299 -0
  70. package/src/infrastructure/database/mssql/mssql-account-lockout.ts +95 -0
  71. package/src/infrastructure/database/mssql/mssql-api-keys.ts +146 -0
  72. package/src/infrastructure/database/mssql/mssql-audit-log.ts +86 -0
  73. package/src/infrastructure/database/mssql/mssql-oauth-accounts.ts +118 -0
  74. package/src/infrastructure/database/mssql/mssql-password-history.ts +71 -0
  75. package/src/infrastructure/database/mssql/mssql-refresh-token-store.ts +144 -0
  76. package/src/infrastructure/database/mssql/mssql-token-blacklist.ts +54 -0
  77. package/src/infrastructure/database/mssql/mssql-user.repository.ts +263 -0
  78. package/src/infrastructure/database/mssql/mssql-verification-tokens.ts +120 -0
  79. package/src/infrastructure/database/postgres/index.ts +14 -0
  80. package/src/infrastructure/database/postgres/migrations.ts +235 -0
  81. package/src/infrastructure/database/postgres/pg-account-lockout.ts +75 -0
  82. package/src/infrastructure/database/postgres/pg-api-keys.ts +126 -0
  83. package/src/infrastructure/database/postgres/pg-audit-log.ts +74 -0
  84. package/src/infrastructure/database/postgres/pg-oauth-accounts.ts +101 -0
  85. package/src/infrastructure/database/postgres/pg-password-history.ts +61 -0
  86. package/src/infrastructure/database/postgres/pg-refresh-token-store.ts +117 -0
  87. package/src/infrastructure/database/postgres/pg-token-blacklist.ts +48 -0
  88. package/src/infrastructure/database/postgres/pg-user.repository.ts +237 -0
  89. package/src/infrastructure/database/postgres/pg-verification-tokens.ts +97 -0
  90. package/src/infrastructure/database/sqlite-account-lockout.ts +97 -0
  91. package/src/infrastructure/database/sqlite-api-keys.ts +155 -0
  92. package/src/infrastructure/database/sqlite-audit-log.ts +90 -0
  93. package/src/infrastructure/database/sqlite-oauth-accounts.ts +105 -0
  94. package/src/infrastructure/database/sqlite-password-history.ts +54 -0
  95. package/src/infrastructure/database/sqlite-refresh-token-store.ts +122 -0
  96. package/src/infrastructure/database/sqlite-token-blacklist.ts +47 -0
  97. package/src/infrastructure/database/sqlite-user.repository.ts +260 -0
  98. package/src/infrastructure/database/sqlite-verification-tokens.ts +112 -0
  99. package/src/infrastructure/events/event-bus.ts +105 -0
  100. package/src/infrastructure/events/event-factory.ts +31 -0
  101. package/src/infrastructure/events/in-memory-webhook-registry.ts +67 -0
  102. package/src/infrastructure/events/index.ts +4 -0
  103. package/src/infrastructure/events/webhook-dispatcher.ts +114 -0
  104. package/src/infrastructure/index.ts +58 -0
  105. package/src/infrastructure/jobs/index.ts +1 -0
  106. package/src/infrastructure/jobs/job-queue.ts +185 -0
  107. package/src/infrastructure/logging/index.ts +1 -0
  108. package/src/infrastructure/logging/logger.ts +63 -0
  109. package/src/infrastructure/metrics/index.ts +1 -0
  110. package/src/infrastructure/metrics/prometheus.ts +231 -0
  111. package/src/infrastructure/oauth/github.ts +116 -0
  112. package/src/infrastructure/oauth/google.ts +83 -0
  113. package/src/infrastructure/oauth/index.ts +2 -0
  114. package/src/infrastructure/resilience/circuit-breaker.ts +133 -0
  115. package/src/infrastructure/resilience/index.ts +2 -0
  116. package/src/infrastructure/resilience/retry.ts +50 -0
  117. package/src/infrastructure/security/account-lockout.ts +73 -0
  118. package/src/infrastructure/security/index.ts +6 -0
  119. package/src/infrastructure/security/password-hasher.ts +31 -0
  120. package/src/infrastructure/security/password-policy.ts +77 -0
  121. package/src/infrastructure/security/token-blacklist.ts +45 -0
  122. package/src/infrastructure/security/token-service.ts +144 -0
  123. package/src/infrastructure/security/totp-service.ts +142 -0
  124. package/src/infrastructure/tracing/index.ts +7 -0
  125. package/src/infrastructure/tracing/trace-context.ts +93 -0
  126. package/src/main.ts +479 -0
  127. package/src/presentation/context.ts +26 -0
  128. package/src/presentation/handlers/admin.handler.ts +114 -0
  129. package/src/presentation/handlers/api-key.handler.ts +68 -0
  130. package/src/presentation/handlers/auth.handler.ts +218 -0
  131. package/src/presentation/handlers/health.handler.ts +27 -0
  132. package/src/presentation/handlers/index.ts +15 -0
  133. package/src/presentation/handlers/metrics.handler.ts +21 -0
  134. package/src/presentation/handlers/oauth.handler.ts +61 -0
  135. package/src/presentation/handlers/openapi.handler.ts +543 -0
  136. package/src/presentation/handlers/response.ts +29 -0
  137. package/src/presentation/handlers/sse.handler.ts +165 -0
  138. package/src/presentation/handlers/user.handler.ts +81 -0
  139. package/src/presentation/handlers/webhook.handler.ts +92 -0
  140. package/src/presentation/handlers/websocket.handler.ts +226 -0
  141. package/src/presentation/i18n/index.ts +254 -0
  142. package/src/presentation/index.ts +5 -0
  143. package/src/presentation/middleware/api-key.ts +18 -0
  144. package/src/presentation/middleware/auth.ts +39 -0
  145. package/src/presentation/middleware/cors.ts +41 -0
  146. package/src/presentation/middleware/index.ts +12 -0
  147. package/src/presentation/middleware/rate-limit.ts +65 -0
  148. package/src/presentation/middleware/security-headers.ts +18 -0
  149. package/src/presentation/middleware/validate.ts +16 -0
  150. package/src/presentation/middleware/versioning.ts +69 -0
  151. package/src/presentation/routes/index.ts +1 -0
  152. package/src/presentation/routes/router.ts +272 -0
  153. package/src/presentation/server.ts +381 -0
  154. package/src/shared/cli.ts +294 -0
  155. package/src/shared/container.ts +65 -0
  156. package/src/shared/index.ts +2 -0
  157. package/src/shared/log-format.ts +148 -0
  158. package/src/shared/utils/id.ts +5 -0
  159. package/src/shared/utils/index.ts +2 -0
  160. package/src/shared/utils/timing-safe.ts +20 -0
@@ -0,0 +1,10 @@
1
+ export {
2
+ type AuthService,
3
+ type LoginResponse,
4
+ type MfaSetupResponse,
5
+ createAuthService,
6
+ } from "./auth.service.js";
7
+ export { type UserService, type UserView, createUserService } from "./user.service.js";
8
+ export { type HealthService, type HealthStatus, createHealthService } from "./health.service.js";
9
+ export { type AdminService, createAdminService } from "./admin.service.js";
10
+ export { type ApiKeyService, createApiKeyService } from "./api-key.service.js";
@@ -0,0 +1,95 @@
1
+ import type { User } from "../../core/entities/user.entity.js";
2
+ import type { AppError } from "../../core/errors/app-error.js";
3
+ import type { Logger } from "../../core/ports/logger.js";
4
+ import type { PasswordHasher } from "../../core/ports/password-hasher.js";
5
+ import type { UserRepository } from "../../core/ports/user.repository.js";
6
+ import type { UserId } from "../../core/types/brand.js";
7
+ import type { Result } from "../../core/types/result.js";
8
+ import { ok } from "../../core/types/result.js";
9
+ import type { UpdateUserDto } from "../dtos/auth.dto.js";
10
+
11
+ /** Safe user projection — strips sensitive fields */
12
+ export interface UserView {
13
+ readonly id: string;
14
+ readonly email: string;
15
+ readonly role: string;
16
+ readonly createdAt: number;
17
+ readonly updatedAt: number;
18
+ }
19
+
20
+ const toView = (u: User): UserView => ({
21
+ id: u.id,
22
+ email: u.email,
23
+ role: u.role,
24
+ createdAt: u.createdAt,
25
+ updatedAt: u.updatedAt,
26
+ });
27
+
28
+ export interface UserService {
29
+ getById(id: UserId): Promise<Result<UserView, AppError>>;
30
+ update(id: UserId, dto: UpdateUserDto): Promise<Result<UserView, AppError>>;
31
+ remove(id: UserId): Promise<Result<void, AppError>>;
32
+ }
33
+
34
+ interface Deps {
35
+ readonly userRepo: UserRepository;
36
+ readonly passwordHasher: PasswordHasher;
37
+ readonly logger: Logger;
38
+ }
39
+
40
+ export const createUserService = (deps: Deps): UserService => {
41
+ const { userRepo, passwordHasher, logger } = deps;
42
+
43
+ return {
44
+ async getById(id: UserId): Promise<Result<UserView, AppError>> {
45
+ logger.debug("Fetching user profile", { userId: id });
46
+
47
+ const result = await userRepo.findById(id);
48
+ if (!result.ok) {
49
+ logger.warn("User not found", { userId: id });
50
+ return result;
51
+ }
52
+ return ok(toView(result.value));
53
+ },
54
+
55
+ async update(id: UserId, dto: UpdateUserDto): Promise<Result<UserView, AppError>> {
56
+ const fields = Object.keys(dto).filter((k) => dto[k as keyof UpdateUserDto] !== undefined);
57
+ logger.info("Updating user", { userId: id, fields });
58
+
59
+ let passwordHash: string | undefined;
60
+ if (dto.password !== undefined) {
61
+ const hashResult = await passwordHasher.hash(dto.password);
62
+ if (!hashResult.ok) {
63
+ logger.error("Password hashing failed during update", { userId: id });
64
+ return hashResult;
65
+ }
66
+ passwordHash = hashResult.value;
67
+ }
68
+
69
+ const result = await userRepo.update(id, {
70
+ email: dto.email,
71
+ passwordHash,
72
+ });
73
+ if (!result.ok) {
74
+ logger.warn("User update failed", { userId: id, code: result.error.code });
75
+ return result;
76
+ }
77
+
78
+ logger.info("User updated", { userId: id, fields });
79
+ return ok(toView(result.value));
80
+ },
81
+
82
+ async remove(id: UserId): Promise<Result<void, AppError>> {
83
+ logger.info("Deleting user", { userId: id });
84
+
85
+ const result = await userRepo.delete(id);
86
+ if (!result.ok) {
87
+ logger.warn("User deletion failed", { userId: id, code: result.error.code });
88
+ return result;
89
+ }
90
+
91
+ logger.info("User deleted", { userId: id });
92
+ return result;
93
+ },
94
+ };
95
+ };
@@ -0,0 +1,86 @@
1
+ /**
2
+ * `onlyapi help` — display help information.
3
+ */
4
+
5
+ import { blank, bold, cyan, dim, gray, green, log, logo, white, yellow } from "../ui.js";
6
+
7
+ // ── Main ────────────────────────────────────────────────────────────────
8
+
9
+ export const helpCommand = (version: string): void => {
10
+ blank();
11
+ log(logo(version));
12
+ blank();
13
+
14
+ log(` ${bold(white("USAGE"))}`);
15
+ log(` ${gray("─".repeat(50))}`);
16
+ log(` ${dim("$")} ${cyan("onlyapi")} ${green("<command>")} ${dim("[options]")}`);
17
+ blank();
18
+
19
+ log(` ${bold(white("COMMANDS"))}`);
20
+ log(` ${gray("─".repeat(50))}`);
21
+
22
+ const commands = [
23
+ ["init <name>", "Create a new onlyApi project"],
24
+ ["upgrade", "Upgrade current project to latest version"],
25
+ ["version", "Show CLI version"],
26
+ ["help", "Show this help message"],
27
+ ] as const;
28
+
29
+ const maxCmd = Math.max(...commands.map(([c]) => c.length));
30
+
31
+ for (const [cmd, desc] of commands) {
32
+ log(` ${green(cmd.padEnd(maxCmd + 2))} ${dim(desc)}`);
33
+ }
34
+
35
+ blank();
36
+ log(` ${bold(white("INIT OPTIONS"))}`);
37
+ log(` ${gray("─".repeat(50))}`);
38
+
39
+ const initOpts = [
40
+ [".", "Initialize in the current directory"],
41
+ ["--cwd", "Same as '.' — use current directory"],
42
+ ] as const;
43
+
44
+ const maxOpt = Math.max(...initOpts.map(([o]) => o.length));
45
+
46
+ for (const [opt, desc] of initOpts) {
47
+ log(` ${yellow(opt.padEnd(maxOpt + 2))} ${dim(desc)}`);
48
+ }
49
+
50
+ blank();
51
+ log(` ${bold(white("UPGRADE OPTIONS"))}`);
52
+ log(` ${gray("─".repeat(50))}`);
53
+
54
+ const upgradeOpts = [
55
+ ["--force, -f", "Force upgrade even if on latest version"],
56
+ ["--dry-run", "Preview changes without applying them"],
57
+ ] as const;
58
+
59
+ const maxUpOpt = Math.max(...upgradeOpts.map(([o]) => o.length));
60
+
61
+ for (const [opt, desc] of upgradeOpts) {
62
+ log(` ${yellow(opt.padEnd(maxUpOpt + 2))} ${dim(desc)}`);
63
+ }
64
+
65
+ blank();
66
+ log(` ${bold(white("EXAMPLES"))}`);
67
+ log(` ${gray("─".repeat(50))}`);
68
+
69
+ const examples = [
70
+ ["onlyapi init my-api", "Create project in ./my-api"],
71
+ ["onlyapi init .", "Initialize in current directory"],
72
+ ["onlyapi upgrade", "Upgrade to latest version"],
73
+ ["onlyapi upgrade --dry-run", "Preview upgrade without changes"],
74
+ ["onlyapi upgrade --force", "Force re-apply latest version"],
75
+ ] as const;
76
+
77
+ for (const [cmd, desc] of examples) {
78
+ log(` ${dim("$")} ${cyan(cmd)}`);
79
+ log(` ${dim(desc)}`);
80
+ }
81
+
82
+ blank();
83
+ log(` ${dim("Docs:")} ${cyan("https://github.com/lysari/onlyapi#readme")}`);
84
+ log(` ${dim("Issues:")} ${cyan("https://github.com/lysari/onlyapi/issues")}`);
85
+ blank();
86
+ };
@@ -0,0 +1,301 @@
1
+ /**
2
+ * `onlyapi init <project-name>` — scaffold a new onlyApi project.
3
+ *
4
+ * Steps:
5
+ * 1. Validate project name
6
+ * 2. Create project directory
7
+ * 3. Clone from GitHub (or download tarball as fallback)
8
+ * 4. Clean up git history (.git removed, fresh git init)
9
+ * 5. Install dependencies via `bun install`
10
+ * 6. Generate secure JWT_SECRET
11
+ * 7. Create .env from .env.example
12
+ * 8. Print success banner with next steps
13
+ */
14
+
15
+ import { existsSync, mkdirSync, rmSync } from "node:fs";
16
+ import { join, resolve } from "node:path";
17
+ import {
18
+ blank,
19
+ bold,
20
+ confirm,
21
+ createSpinner,
22
+ cyan,
23
+ dim,
24
+ error,
25
+ formatDuration,
26
+ green,
27
+ icons,
28
+ info,
29
+ log,
30
+ logo,
31
+ prompt,
32
+ randomSecret,
33
+ section,
34
+ step,
35
+ warn,
36
+ white,
37
+ } from "../ui.js";
38
+
39
+ // ── Constants ───────────────────────────────────────────────────────────
40
+
41
+ const REPO_URL = "https://github.com/lysari/onlyapi.git";
42
+ const TARBALL_URL = "https://github.com/lysari/onlyapi/archive/refs/heads/main.tar.gz";
43
+ const VALID_NAME_RE = /^[a-zA-Z0-9_-]+$/;
44
+
45
+ // ── Helpers ─────────────────────────────────────────────────────────────
46
+
47
+ const validateProjectName = (name: string): string | null => {
48
+ if (!name) return "Project name is required.";
49
+ if (!VALID_NAME_RE.test(name))
50
+ return "Project name can only contain letters, numbers, hyphens, and underscores.";
51
+ if (name.length > 214) return "Project name is too long (max 214 chars).";
52
+ return null;
53
+ };
54
+
55
+ const exec = async (
56
+ cmd: string[],
57
+ cwd: string = process.cwd(),
58
+ ): Promise<{ stdout: string; stderr: string; exitCode: number }> => {
59
+ const proc = Bun.spawn(cmd, {
60
+ cwd,
61
+ stdout: "pipe",
62
+ stderr: "pipe",
63
+ });
64
+
65
+ const [stdout, stderr] = await Promise.all([
66
+ new Response(proc.stdout).text(),
67
+ new Response(proc.stderr).text(),
68
+ ]);
69
+
70
+ const exitCode = await proc.exited;
71
+ return { stdout: stdout.trim(), stderr: stderr.trim(), exitCode };
72
+ };
73
+
74
+ const hasCommand = async (cmd: string): Promise<boolean> => {
75
+ try {
76
+ const { exitCode } = await exec(["which", cmd]);
77
+ return exitCode === 0;
78
+ } catch {
79
+ return false;
80
+ }
81
+ };
82
+
83
+ // ── Main ────────────────────────────────────────────────────────────────
84
+
85
+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: CLI wizard is inherently branchy
86
+ export const initCommand = async (args: string[], version: string): Promise<void> => {
87
+ const startTime = performance.now();
88
+
89
+ blank();
90
+ log(logo(version));
91
+ blank();
92
+
93
+ // ── Parse args ──
94
+ let projectName = args[0] ?? "";
95
+ const useCurrentDir = args.includes("--cwd") || args.includes(".");
96
+
97
+ // Interactive prompt if no name given
98
+ if (!projectName && !useCurrentDir) {
99
+ projectName = await prompt("Project name", "my-api");
100
+ }
101
+
102
+ if (useCurrentDir) {
103
+ projectName = ".";
104
+ }
105
+
106
+ // Validate name
107
+ if (projectName !== ".") {
108
+ const nameError = validateProjectName(projectName);
109
+ if (nameError) {
110
+ error(nameError);
111
+ process.exit(1);
112
+ }
113
+ }
114
+
115
+ const targetDir = projectName === "." ? process.cwd() : resolve(process.cwd(), projectName);
116
+
117
+ // ── Check existing directory ──
118
+ if (projectName !== "." && existsSync(targetDir)) {
119
+ const files = await Array.fromAsync(new Bun.Glob("*").scan({ cwd: targetDir }));
120
+ if (files.length > 0) {
121
+ const shouldContinue = await confirm(
122
+ `Directory ${bold(white(projectName))} already exists and is not empty. Continue?`,
123
+ false,
124
+ );
125
+ if (!shouldContinue) {
126
+ info("Aborted.");
127
+ process.exit(0);
128
+ }
129
+ }
130
+ }
131
+
132
+ section("Creating project");
133
+
134
+ // ── Step 1: Create directory ──
135
+ if (projectName !== ".") {
136
+ mkdirSync(targetDir, { recursive: true });
137
+ step(`Created directory ${bold(cyan(projectName))}`);
138
+ }
139
+
140
+ // ── Step 2: Clone or download ──
141
+ const hasGit = await hasCommand("git");
142
+ const spinner = createSpinner("Downloading template...");
143
+ spinner.start();
144
+
145
+ let cloneSuccess = false;
146
+
147
+ if (hasGit) {
148
+ spinner.update("Cloning from GitHub...");
149
+ const { exitCode } = await exec(
150
+ [
151
+ "git",
152
+ "clone",
153
+ "--depth=1",
154
+ "--single-branch",
155
+ REPO_URL,
156
+ projectName === "." ? "." : projectName,
157
+ ],
158
+ projectName === "." ? targetDir : process.cwd(),
159
+ );
160
+ cloneSuccess = exitCode === 0;
161
+ }
162
+
163
+ if (!cloneSuccess) {
164
+ // Fallback: download tarball
165
+ spinner.update("Downloading release archive...");
166
+ try {
167
+ const response = await fetch(TARBALL_URL);
168
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
169
+
170
+ const tarPath = join(targetDir, "__onlyapi.tar.gz");
171
+ await Bun.write(tarPath, response);
172
+
173
+ // Extract
174
+ spinner.update("Extracting...");
175
+ await exec(["tar", "xzf", tarPath, "--strip-components=1"], targetDir);
176
+ rmSync(tarPath, { force: true });
177
+ cloneSuccess = true;
178
+ } catch (e) {
179
+ spinner.stop();
180
+ error(`Failed to download template: ${e instanceof Error ? e.message : String(e)}`);
181
+ error("Please check your network connection and try again.");
182
+ blank();
183
+ info(`You can also clone manually: ${dim(`git clone ${REPO_URL} ${projectName}`)}`);
184
+ process.exit(1);
185
+ }
186
+ }
187
+
188
+ spinner.stop("Template downloaded");
189
+
190
+ // ── Step 3: Clean up git history ──
191
+ const gitDir = join(targetDir, ".git");
192
+ if (existsSync(gitDir)) {
193
+ rmSync(gitDir, { recursive: true, force: true });
194
+ }
195
+
196
+ // Initialize fresh git repo
197
+ if (hasGit) {
198
+ await exec(["git", "init"], targetDir);
199
+ step("Initialized fresh git repository");
200
+ }
201
+
202
+ // ── Step 4: Update package.json with project name ──
203
+ const pkgPath = join(targetDir, "package.json");
204
+ if (existsSync(pkgPath)) {
205
+ try {
206
+ const pkgContent = await Bun.file(pkgPath).text();
207
+ const pkg = JSON.parse(pkgContent);
208
+
209
+ if (projectName !== ".") {
210
+ pkg.name = projectName;
211
+ }
212
+ pkg.version = "0.1.0";
213
+ pkg.description = "";
214
+ pkg.author = "";
215
+ pkg.repository = undefined;
216
+ pkg.bugs = undefined;
217
+ pkg.homepage = undefined;
218
+
219
+ await Bun.write(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`);
220
+ step(`Updated ${bold(cyan("package.json"))}`);
221
+ } catch {
222
+ warn("Could not update package.json — you can edit it manually");
223
+ }
224
+ }
225
+
226
+ // ── Step 5: Generate .env ──
227
+ const envExamplePath = join(targetDir, ".env.example");
228
+ const envPath = join(targetDir, ".env");
229
+
230
+ if (existsSync(envExamplePath) && !existsSync(envPath)) {
231
+ try {
232
+ let envContent = await Bun.file(envExamplePath).text();
233
+ const secret = randomSecret(64);
234
+ envContent = envContent.replace("change-me-to-a-64-char-random-string", secret);
235
+ await Bun.write(envPath, envContent);
236
+ step(`Generated ${bold(cyan(".env"))} with secure JWT_SECRET`);
237
+ } catch {
238
+ warn("Could not generate .env — copy .env.example manually");
239
+ }
240
+ }
241
+
242
+ // ── Step 6: Install dependencies ──
243
+ section("Installing dependencies");
244
+
245
+ const installSpinner = createSpinner("Running bun install...");
246
+ installSpinner.start();
247
+
248
+ const { exitCode: installExit, stderr: installErr } = await exec(["bun", "install"], targetDir);
249
+
250
+ if (installExit !== 0) {
251
+ installSpinner.stop();
252
+ error("Failed to install dependencies:");
253
+ log(` ${dim(installErr)}`);
254
+ blank();
255
+ info(`Run ${bold(cyan("bun install"))} manually in the project directory.`);
256
+ } else {
257
+ installSpinner.stop("Dependencies installed");
258
+ }
259
+
260
+ // ── Step 7: Initial git commit ──
261
+ if (hasGit) {
262
+ await exec(["git", "add", "-A"], targetDir);
263
+ await exec(
264
+ ["git", "commit", "-m", "Initial commit from onlyApi CLI", "--no-verify"],
265
+ targetDir,
266
+ );
267
+ step("Created initial commit");
268
+ }
269
+
270
+ // ── Success banner ──
271
+ const elapsed = performance.now() - startTime;
272
+
273
+ blank();
274
+ log(
275
+ ` ${icons.rocket} ${bold(green("Project created successfully!"))} ${dim(`(${formatDuration(elapsed)})`)}`,
276
+ );
277
+ blank();
278
+
279
+ // Next steps
280
+ section("Next steps");
281
+ blank();
282
+
283
+ const cdCmd = projectName !== "." ? `cd ${projectName}` : null;
284
+ const steps = [
285
+ ...(cdCmd ? [cdCmd] : []),
286
+ "bun run dev # Start dev server (hot-reload)",
287
+ "bun test # Run tests",
288
+ "bun run check # Type-check",
289
+ ];
290
+
291
+ for (const s of steps) {
292
+ log(` ${dim("$")} ${bold(cyan(s))}`);
293
+ }
294
+
295
+ blank();
296
+ log(` ${dim("Docs:")} ${cyan("https://github.com/lysari/onlyapi#readme")}`);
297
+ log(` ${dim("Issues:")} ${cyan("https://github.com/lysari/onlyapi/issues")}`);
298
+ blank();
299
+ log(` ${dim("Happy hacking!")} ${icons.bolt}`);
300
+ blank();
301
+ };