@codaijs/keel 0.1.0
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/dist/__tests__/cli.test.d.ts +2 -0
- package/dist/__tests__/cli.test.d.ts.map +1 -0
- package/dist/__tests__/cli.test.js +173 -0
- package/dist/__tests__/cli.test.js.map +1 -0
- package/dist/__tests__/registry.test.d.ts +2 -0
- package/dist/__tests__/registry.test.d.ts.map +1 -0
- package/dist/__tests__/registry.test.js +86 -0
- package/dist/__tests__/registry.test.js.map +1 -0
- package/dist/__tests__/sail-installer.test.d.ts +2 -0
- package/dist/__tests__/sail-installer.test.d.ts.map +1 -0
- package/dist/__tests__/sail-installer.test.js +158 -0
- package/dist/__tests__/sail-installer.test.js.map +1 -0
- package/dist/create-runner.d.ts +11 -0
- package/dist/create-runner.d.ts.map +1 -0
- package/dist/create-runner.js +63 -0
- package/dist/create-runner.js.map +1 -0
- package/dist/create.d.ts +10 -0
- package/dist/create.d.ts.map +1 -0
- package/dist/create.js +15 -0
- package/dist/create.js.map +1 -0
- package/dist/manage.d.ts +24 -0
- package/dist/manage.d.ts.map +1 -0
- package/dist/manage.js +1461 -0
- package/dist/manage.js.map +1 -0
- package/dist/prompts.d.ts +36 -0
- package/dist/prompts.d.ts.map +1 -0
- package/dist/prompts.js +208 -0
- package/dist/prompts.js.map +1 -0
- package/dist/sail-installer.d.ts +37 -0
- package/dist/sail-installer.d.ts.map +1 -0
- package/dist/sail-installer.js +935 -0
- package/dist/sail-installer.js.map +1 -0
- package/dist/scaffold.d.ts +10 -0
- package/dist/scaffold.d.ts.map +1 -0
- package/dist/scaffold.js +297 -0
- package/dist/scaffold.js.map +1 -0
- package/package.json +57 -0
- package/sails/_template/addon.json +20 -0
- package/sails/_template/install.ts +402 -0
- package/sails/admin-dashboard/README.md +117 -0
- package/sails/admin-dashboard/addon.json +28 -0
- package/sails/admin-dashboard/files/backend/middleware/admin.ts +34 -0
- package/sails/admin-dashboard/files/backend/routes/admin.ts +243 -0
- package/sails/admin-dashboard/files/frontend/components/admin/StatsCard.tsx +40 -0
- package/sails/admin-dashboard/files/frontend/components/admin/UsersTable.tsx +240 -0
- package/sails/admin-dashboard/files/frontend/hooks/useAdmin.ts +149 -0
- package/sails/admin-dashboard/files/frontend/pages/admin/Dashboard.tsx +173 -0
- package/sails/admin-dashboard/files/frontend/pages/admin/UserDetail.tsx +203 -0
- package/sails/admin-dashboard/install.ts +305 -0
- package/sails/analytics/README.md +178 -0
- package/sails/analytics/addon.json +27 -0
- package/sails/analytics/files/frontend/components/AnalyticsProvider.tsx +58 -0
- package/sails/analytics/files/frontend/hooks/useAnalytics.ts +64 -0
- package/sails/analytics/files/frontend/lib/analytics.ts +103 -0
- package/sails/analytics/install.ts +297 -0
- package/sails/file-uploads/README.md +191 -0
- package/sails/file-uploads/addon.json +30 -0
- package/sails/file-uploads/files/backend/routes/files.ts +198 -0
- package/sails/file-uploads/files/backend/schema/files.ts +36 -0
- package/sails/file-uploads/files/backend/services/file-storage.ts +128 -0
- package/sails/file-uploads/files/frontend/components/FileList.tsx +248 -0
- package/sails/file-uploads/files/frontend/components/FileUploadButton.tsx +147 -0
- package/sails/file-uploads/files/frontend/hooks/useFileUpload.ts +106 -0
- package/sails/file-uploads/files/frontend/hooks/useFiles.ts +118 -0
- package/sails/file-uploads/files/frontend/pages/Files.tsx +37 -0
- package/sails/file-uploads/install.ts +466 -0
- package/sails/gdpr/README.md +174 -0
- package/sails/gdpr/addon.json +27 -0
- package/sails/gdpr/files/backend/routes/gdpr.ts +140 -0
- package/sails/gdpr/files/backend/services/gdpr.ts +293 -0
- package/sails/gdpr/files/frontend/components/auth/ConsentCheckboxes.tsx +97 -0
- package/sails/gdpr/files/frontend/components/gdpr/AccountDeletionRequest.tsx +192 -0
- package/sails/gdpr/files/frontend/components/gdpr/DataExportButton.tsx +75 -0
- package/sails/gdpr/files/frontend/pages/PrivacyPolicy.tsx +186 -0
- package/sails/gdpr/install.ts +756 -0
- package/sails/google-oauth/README.md +121 -0
- package/sails/google-oauth/addon.json +22 -0
- package/sails/google-oauth/files/GoogleButton.tsx +50 -0
- package/sails/google-oauth/install.ts +252 -0
- package/sails/i18n/README.md +193 -0
- package/sails/i18n/addon.json +30 -0
- package/sails/i18n/files/frontend/components/LanguageSwitcher.tsx +108 -0
- package/sails/i18n/files/frontend/hooks/useLanguage.ts +31 -0
- package/sails/i18n/files/frontend/lib/i18n.ts +32 -0
- package/sails/i18n/files/frontend/locales/de/common.json +44 -0
- package/sails/i18n/files/frontend/locales/en/common.json +44 -0
- package/sails/i18n/install.ts +407 -0
- package/sails/push-notifications/README.md +163 -0
- package/sails/push-notifications/addon.json +31 -0
- package/sails/push-notifications/files/backend/routes/notifications.ts +153 -0
- package/sails/push-notifications/files/backend/schema/notifications.ts +31 -0
- package/sails/push-notifications/files/backend/services/notifications.ts +117 -0
- package/sails/push-notifications/files/frontend/components/PushNotificationInit.tsx +12 -0
- package/sails/push-notifications/files/frontend/hooks/usePushNotifications.ts +154 -0
- package/sails/push-notifications/install.ts +384 -0
- package/sails/r2-storage/README.md +101 -0
- package/sails/r2-storage/addon.json +29 -0
- package/sails/r2-storage/files/backend/services/storage.ts +71 -0
- package/sails/r2-storage/files/frontend/components/ProfilePictureUpload.tsx +167 -0
- package/sails/r2-storage/install.ts +412 -0
- package/sails/rate-limiting/README.md +145 -0
- package/sails/rate-limiting/addon.json +20 -0
- package/sails/rate-limiting/files/backend/middleware/rate-limit-store.ts +104 -0
- package/sails/rate-limiting/files/backend/middleware/rate-limit.ts +137 -0
- package/sails/rate-limiting/install.ts +300 -0
- package/sails/registry.json +107 -0
- package/sails/stripe/README.md +214 -0
- package/sails/stripe/addon.json +24 -0
- package/sails/stripe/files/backend/routes/stripe.ts +154 -0
- package/sails/stripe/files/backend/schema/stripe.ts +74 -0
- package/sails/stripe/files/backend/services/stripe.ts +224 -0
- package/sails/stripe/files/frontend/components/SubscriptionStatus.tsx +135 -0
- package/sails/stripe/files/frontend/hooks/useSubscription.ts +86 -0
- package/sails/stripe/files/frontend/pages/Checkout.tsx +116 -0
- package/sails/stripe/files/frontend/pages/Pricing.tsx +226 -0
- package/sails/stripe/install.ts +378 -0
package/dist/manage.js
ADDED
|
@@ -0,0 +1,1461 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* keel CLI — Project management & sail tool (a codai project)
|
|
4
|
+
*
|
|
5
|
+
* Used from inside a keel project to manage sails, generate code,
|
|
6
|
+
* run database operations, and perform health checks.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* npx @codaijs/keel sail add <name> — install a sail into the current project
|
|
10
|
+
* npx @codaijs/keel sail remove <name> — remove a sail from the current project
|
|
11
|
+
* npx @codaijs/keel list — list available sails with status
|
|
12
|
+
* npx @codaijs/keel info <name> — show sail details
|
|
13
|
+
* npx @codaijs/keel doctor — run project health checks
|
|
14
|
+
* npx @codaijs/keel generate route <name> — scaffold a new API route
|
|
15
|
+
* npx @codaijs/keel generate page <name> — scaffold a new React page
|
|
16
|
+
* npx @codaijs/keel generate email <name> — scaffold a new email template
|
|
17
|
+
* npx @codaijs/keel db:reset — drop and recreate database schema
|
|
18
|
+
* npx @codaijs/keel db:studio — open Drizzle Studio
|
|
19
|
+
* npx @codaijs/keel db:seed — run database seed file
|
|
20
|
+
* npx @codaijs/keel env — check environment variables
|
|
21
|
+
* npx @codaijs/keel upgrade — upgrade keel CLI to latest version
|
|
22
|
+
*/
|
|
23
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync } from "node:fs";
|
|
24
|
+
import { join, dirname, resolve } from "node:path";
|
|
25
|
+
import { fileURLToPath } from "node:url";
|
|
26
|
+
import { execFileSync, spawn } from "node:child_process";
|
|
27
|
+
import chalk from "chalk";
|
|
28
|
+
import ora from "ora";
|
|
29
|
+
import { confirm } from "@inquirer/prompts";
|
|
30
|
+
import { installSailByName } from "./sail-installer.js";
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// Paths
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
35
|
+
const __dirname = dirname(__filename);
|
|
36
|
+
/** Directory where sail definitions are bundled (shipped with the npm package). */
|
|
37
|
+
const BUNDLED_SAILS_DIR = join(__dirname, "..", "sails");
|
|
38
|
+
/** Path to the registry of all available sails. */
|
|
39
|
+
const REGISTRY_PATH = join(BUNDLED_SAILS_DIR, "registry.json");
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
// Helpers
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
function loadRegistry() {
|
|
44
|
+
try {
|
|
45
|
+
return JSON.parse(readFileSync(REGISTRY_PATH, "utf-8"));
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
throw new Error(`Failed to parse sail registry at ${REGISTRY_PATH}: ${error.message}`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
function getInstalledJsonPath() {
|
|
52
|
+
return join(process.cwd(), "sails", "installed.json");
|
|
53
|
+
}
|
|
54
|
+
function loadInstalled() {
|
|
55
|
+
const path = getInstalledJsonPath();
|
|
56
|
+
if (!existsSync(path)) {
|
|
57
|
+
return { installed: [] };
|
|
58
|
+
}
|
|
59
|
+
try {
|
|
60
|
+
return JSON.parse(readFileSync(path, "utf-8"));
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
throw new Error(`Failed to parse installed.json at ${path}: ${error.message}`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
function saveInstalled(data) {
|
|
67
|
+
const path = getInstalledJsonPath();
|
|
68
|
+
writeFileSync(path, JSON.stringify(data, null, 2) + "\n", "utf-8");
|
|
69
|
+
}
|
|
70
|
+
function isInsideKeelProject() {
|
|
71
|
+
return existsSync(getInstalledJsonPath());
|
|
72
|
+
}
|
|
73
|
+
/** Normalize installed entries — handles both legacy string[] and new object[] formats. */
|
|
74
|
+
function getInstalledNames(data) {
|
|
75
|
+
return data.installed.map((entry) => typeof entry === "string" ? entry : entry.name);
|
|
76
|
+
}
|
|
77
|
+
/** Get the installed version of a sail, or null if not tracked. */
|
|
78
|
+
function getInstalledVersion(data, sailName) {
|
|
79
|
+
for (const entry of data.installed) {
|
|
80
|
+
if (typeof entry === "object" && entry.name === sailName) {
|
|
81
|
+
return entry.version;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
/** Migrate legacy string[] format to new object[] format. */
|
|
87
|
+
function migrateInstalledJson(data) {
|
|
88
|
+
if (data.version === 2)
|
|
89
|
+
return data;
|
|
90
|
+
const migrated = {
|
|
91
|
+
version: 2,
|
|
92
|
+
installed: data.installed.map((entry) => {
|
|
93
|
+
if (typeof entry === "string") {
|
|
94
|
+
return { name: entry, version: "unknown", installedAt: new Date().toISOString() };
|
|
95
|
+
}
|
|
96
|
+
return entry;
|
|
97
|
+
}),
|
|
98
|
+
};
|
|
99
|
+
saveInstalled(migrated);
|
|
100
|
+
return migrated;
|
|
101
|
+
}
|
|
102
|
+
function getSailDir(sailName) {
|
|
103
|
+
if (/[./\\]/.test(sailName)) {
|
|
104
|
+
throw new Error(`Invalid sail name: "${sailName}" — must not contain ".", "/", or "\\".`);
|
|
105
|
+
}
|
|
106
|
+
const resolved = resolve(BUNDLED_SAILS_DIR, sailName);
|
|
107
|
+
if (!resolved.startsWith(BUNDLED_SAILS_DIR + "/") && resolved !== BUNDLED_SAILS_DIR) {
|
|
108
|
+
throw new Error(`Invalid sail name: "${sailName}" — resolved path escapes sails directory.`);
|
|
109
|
+
}
|
|
110
|
+
return resolved;
|
|
111
|
+
}
|
|
112
|
+
function requireKeelProject() {
|
|
113
|
+
if (!isInsideKeelProject()) {
|
|
114
|
+
console.error(chalk.red("\n Error: Not inside a keel project."));
|
|
115
|
+
console.error(chalk.gray(" Run this command from the root of a keel project"));
|
|
116
|
+
console.error(chalk.gray(" (a directory containing sails/installed.json).\n"));
|
|
117
|
+
process.exit(1);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
function loadSailManifest(sailName) {
|
|
121
|
+
const manifestPath = join(getSailDir(sailName), "addon.json");
|
|
122
|
+
if (!existsSync(manifestPath))
|
|
123
|
+
return null;
|
|
124
|
+
try {
|
|
125
|
+
return JSON.parse(readFileSync(manifestPath, "utf-8"));
|
|
126
|
+
}
|
|
127
|
+
catch (error) {
|
|
128
|
+
throw new Error(`Failed to parse sail manifest at ${manifestPath}: ${error.message}`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Try to find and parse the project .env file.
|
|
133
|
+
* Checks project root and packages/backend/.env.
|
|
134
|
+
*/
|
|
135
|
+
function loadEnvFile() {
|
|
136
|
+
const candidates = [
|
|
137
|
+
join(process.cwd(), ".env"),
|
|
138
|
+
join(process.cwd(), "packages", "backend", ".env"),
|
|
139
|
+
];
|
|
140
|
+
for (const candidate of candidates) {
|
|
141
|
+
if (existsSync(candidate)) {
|
|
142
|
+
const content = readFileSync(candidate, "utf-8");
|
|
143
|
+
const vars = {};
|
|
144
|
+
for (const line of content.split("\n")) {
|
|
145
|
+
const trimmed = line.trim();
|
|
146
|
+
if (!trimmed || trimmed.startsWith("#"))
|
|
147
|
+
continue;
|
|
148
|
+
const eqIndex = trimmed.indexOf("=");
|
|
149
|
+
if (eqIndex === -1)
|
|
150
|
+
continue;
|
|
151
|
+
const key = trimmed.slice(0, eqIndex).trim();
|
|
152
|
+
let value = trimmed.slice(eqIndex + 1).trim();
|
|
153
|
+
// Remove surrounding quotes
|
|
154
|
+
if ((value.startsWith('"') && value.endsWith('"')) ||
|
|
155
|
+
(value.startsWith("'") && value.endsWith("'"))) {
|
|
156
|
+
value = value.slice(1, -1);
|
|
157
|
+
}
|
|
158
|
+
vars[key] = value;
|
|
159
|
+
}
|
|
160
|
+
return vars;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return {};
|
|
164
|
+
}
|
|
165
|
+
function getEnvFilePath() {
|
|
166
|
+
const candidates = [
|
|
167
|
+
join(process.cwd(), ".env"),
|
|
168
|
+
join(process.cwd(), "packages", "backend", ".env"),
|
|
169
|
+
];
|
|
170
|
+
for (const candidate of candidates) {
|
|
171
|
+
if (existsSync(candidate))
|
|
172
|
+
return candidate;
|
|
173
|
+
}
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
function capitalize(str) {
|
|
177
|
+
return str.charAt(0).toUpperCase() + str.slice(1);
|
|
178
|
+
}
|
|
179
|
+
function toPascalCase(str) {
|
|
180
|
+
return str
|
|
181
|
+
.split(/[-_]/)
|
|
182
|
+
.map((s) => capitalize(s))
|
|
183
|
+
.join("");
|
|
184
|
+
}
|
|
185
|
+
function validateGeneratorName(name) {
|
|
186
|
+
if (!/^[a-zA-Z][a-zA-Z0-9_-]*$/.test(name)) {
|
|
187
|
+
console.error(chalk.red(" Error: Name must start with a letter and contain only letters, numbers, hyphens, and underscores."));
|
|
188
|
+
process.exit(1);
|
|
189
|
+
}
|
|
190
|
+
const pascal = toPascalCase(name);
|
|
191
|
+
if (!/^[A-Z][a-zA-Z0-9]*$/.test(pascal)) {
|
|
192
|
+
console.error(chalk.red(" Error: Name produces an invalid identifier after transformation."));
|
|
193
|
+
process.exit(1);
|
|
194
|
+
}
|
|
195
|
+
return name;
|
|
196
|
+
}
|
|
197
|
+
// ---------------------------------------------------------------------------
|
|
198
|
+
// Commands — Sail Management
|
|
199
|
+
// ---------------------------------------------------------------------------
|
|
200
|
+
async function commandAdd(sailName) {
|
|
201
|
+
requireKeelProject();
|
|
202
|
+
// Check sail exists in registry
|
|
203
|
+
const registry = loadRegistry();
|
|
204
|
+
const registryEntry = registry.sails.find((a) => a.name === sailName);
|
|
205
|
+
if (!registryEntry) {
|
|
206
|
+
console.error(chalk.red(`\n Error: Unknown sail "${sailName}".`));
|
|
207
|
+
console.error(chalk.gray(" Run 'keel list' to see available sails.\n"));
|
|
208
|
+
process.exit(1);
|
|
209
|
+
}
|
|
210
|
+
if (registryEntry.status === "planned") {
|
|
211
|
+
console.error(chalk.yellow(`\n Sail "${sailName}" is planned but not yet available.`));
|
|
212
|
+
console.error(chalk.gray(" Check back in a future release.\n"));
|
|
213
|
+
process.exit(1);
|
|
214
|
+
}
|
|
215
|
+
// Check sail definition exists in bundled sails
|
|
216
|
+
const sailDir = getSailDir(sailName);
|
|
217
|
+
if (!existsSync(join(sailDir, "addon.json"))) {
|
|
218
|
+
console.error(chalk.red(`\n Error: Sail definition not found for "${sailName}".`));
|
|
219
|
+
console.error(chalk.gray(" The sail may not be bundled in this version of keel.\n"));
|
|
220
|
+
process.exit(1);
|
|
221
|
+
}
|
|
222
|
+
// Check if already installed
|
|
223
|
+
const installed = migrateInstalledJson(loadInstalled());
|
|
224
|
+
const installedNames = getInstalledNames(installed);
|
|
225
|
+
if (installedNames.includes(sailName)) {
|
|
226
|
+
console.log(chalk.yellow(`\n Sail "${sailName}" is already installed.\n`));
|
|
227
|
+
process.exit(0);
|
|
228
|
+
}
|
|
229
|
+
// Check sail compatibility — warn about conflicts with installed sails
|
|
230
|
+
if (registryEntry.conflicts && registryEntry.conflicts.length > 0) {
|
|
231
|
+
const conflicting = registryEntry.conflicts.filter((c) => installedNames.includes(c));
|
|
232
|
+
if (conflicting.length > 0) {
|
|
233
|
+
const conflictNames = conflicting
|
|
234
|
+
.map((c) => {
|
|
235
|
+
const entry = registry.sails.find((s) => s.name === c);
|
|
236
|
+
return entry ? entry.displayName : c;
|
|
237
|
+
})
|
|
238
|
+
.join(", ");
|
|
239
|
+
console.error(chalk.red(`\n Error: "${sailName}" conflicts with installed sail(s): ${conflictNames}`));
|
|
240
|
+
console.error(chalk.gray(` These sails modify the same areas and cannot be used together.`));
|
|
241
|
+
console.error(chalk.gray(` Remove the conflicting sail first with: keel sail remove <name>\n`));
|
|
242
|
+
process.exit(1);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
// Check for route collisions with installed sails
|
|
246
|
+
if (registryEntry.routes && registryEntry.routes.length > 0) {
|
|
247
|
+
for (const installedSailName of installedNames) {
|
|
248
|
+
const installedEntry = registry.sails.find((s) => s.name === installedSailName);
|
|
249
|
+
if (installedEntry?.routes) {
|
|
250
|
+
const overlapping = registryEntry.routes.filter((r) => installedEntry.routes.includes(r));
|
|
251
|
+
if (overlapping.length > 0) {
|
|
252
|
+
console.log(chalk.yellow(`\n Warning: "${sailName}" adds route(s) ${overlapping.join(", ")} which overlap with "${installedSailName}".`));
|
|
253
|
+
console.log(chalk.gray(` You may need to resolve route conflicts manually.\n`));
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
// Check for env var collisions with installed sails
|
|
259
|
+
if (registryEntry.envVars && registryEntry.envVars.length > 0) {
|
|
260
|
+
for (const installedSailName of installedNames) {
|
|
261
|
+
const installedEntry = registry.sails.find((s) => s.name === installedSailName);
|
|
262
|
+
if (installedEntry?.envVars) {
|
|
263
|
+
const overlapping = registryEntry.envVars.filter((e) => installedEntry.envVars.includes(e));
|
|
264
|
+
if (overlapping.length > 0) {
|
|
265
|
+
console.log(chalk.yellow(`\n Warning: "${sailName}" uses env var(s) ${overlapping.join(", ")} which are also used by "${installedSailName}".`));
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
// Install the sail
|
|
271
|
+
const projectDir = process.cwd();
|
|
272
|
+
const spinner = ora(` Installing ${registryEntry.displayName}...`).start();
|
|
273
|
+
try {
|
|
274
|
+
await installSailByName(sailName, sailDir, projectDir);
|
|
275
|
+
spinner.succeed(` ${registryEntry.displayName} installed successfully`);
|
|
276
|
+
// Update installed.json with version tracking
|
|
277
|
+
installed.installed.push({
|
|
278
|
+
name: sailName,
|
|
279
|
+
version: registryEntry.version,
|
|
280
|
+
installedAt: new Date().toISOString(),
|
|
281
|
+
});
|
|
282
|
+
saveInstalled(installed);
|
|
283
|
+
console.log();
|
|
284
|
+
console.log(chalk.gray(` Updated sails/installed.json (${sailName}@${registryEntry.version})`));
|
|
285
|
+
console.log();
|
|
286
|
+
}
|
|
287
|
+
catch (error) {
|
|
288
|
+
spinner.fail(` Failed to install ${sailName}`);
|
|
289
|
+
console.error(chalk.red(` ${error}`));
|
|
290
|
+
process.exit(1);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
async function commandRemove(sailName) {
|
|
294
|
+
requireKeelProject();
|
|
295
|
+
const installed = migrateInstalledJson(loadInstalled());
|
|
296
|
+
const installedNames = getInstalledNames(installed);
|
|
297
|
+
if (!installedNames.includes(sailName)) {
|
|
298
|
+
console.error(chalk.red(`\n Error: Sail "${sailName}" is not installed.`));
|
|
299
|
+
console.error(chalk.gray(" Run 'keel list' to see installed sails.\n"));
|
|
300
|
+
process.exit(1);
|
|
301
|
+
}
|
|
302
|
+
const manifest = loadSailManifest(sailName);
|
|
303
|
+
console.log();
|
|
304
|
+
console.log(chalk.bold(` Removing sail: ${sailName}`));
|
|
305
|
+
console.log();
|
|
306
|
+
// Show files that were added
|
|
307
|
+
const addedFiles = [];
|
|
308
|
+
if (manifest) {
|
|
309
|
+
if (manifest.adds.backend) {
|
|
310
|
+
for (const f of manifest.adds.backend) {
|
|
311
|
+
addedFiles.push(join("packages", "backend", f));
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
if (manifest.adds.frontend) {
|
|
315
|
+
for (const f of manifest.adds.frontend) {
|
|
316
|
+
addedFiles.push(join("packages", "frontend", f));
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
if (addedFiles.length > 0) {
|
|
321
|
+
console.log(chalk.bold(" Files that will be removed:"));
|
|
322
|
+
for (const f of addedFiles) {
|
|
323
|
+
console.log(` ${chalk.red("-")} ${f}`);
|
|
324
|
+
}
|
|
325
|
+
console.log();
|
|
326
|
+
}
|
|
327
|
+
// Show modified files
|
|
328
|
+
const modifiedFiles = [];
|
|
329
|
+
if (manifest) {
|
|
330
|
+
if (manifest.modifies.backend) {
|
|
331
|
+
for (const f of manifest.modifies.backend) {
|
|
332
|
+
modifiedFiles.push(join("packages", "backend", f));
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
if (manifest.modifies.frontend) {
|
|
336
|
+
for (const f of manifest.modifies.frontend) {
|
|
337
|
+
modifiedFiles.push(join("packages", "frontend", f));
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
if (modifiedFiles.length > 0) {
|
|
342
|
+
console.log(chalk.bold(" Files that were modified (need manual cleanup):"));
|
|
343
|
+
for (const f of modifiedFiles) {
|
|
344
|
+
console.log(` ${chalk.yellow("~")} ${f}`);
|
|
345
|
+
}
|
|
346
|
+
console.log();
|
|
347
|
+
}
|
|
348
|
+
// Confirm removal
|
|
349
|
+
const shouldRemove = await confirm({
|
|
350
|
+
message: " Proceed with removal?",
|
|
351
|
+
default: false,
|
|
352
|
+
});
|
|
353
|
+
if (!shouldRemove) {
|
|
354
|
+
console.log(chalk.gray("\n Removal cancelled.\n"));
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
// Remove added files
|
|
358
|
+
let removedCount = 0;
|
|
359
|
+
for (const f of addedFiles) {
|
|
360
|
+
const fullPath = join(process.cwd(), f);
|
|
361
|
+
if (existsSync(fullPath)) {
|
|
362
|
+
unlinkSync(fullPath);
|
|
363
|
+
removedCount++;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
if (removedCount > 0) {
|
|
367
|
+
console.log(chalk.green(`\n Removed ${removedCount} file(s).`));
|
|
368
|
+
}
|
|
369
|
+
// Print manual cleanup instructions
|
|
370
|
+
if (modifiedFiles.length > 0) {
|
|
371
|
+
console.log();
|
|
372
|
+
console.log(chalk.yellow(" Manual cleanup required:"));
|
|
373
|
+
console.log(chalk.gray(" The following files had code injected by this sail."));
|
|
374
|
+
console.log(chalk.gray(" You need to manually remove the sail-related code:\n"));
|
|
375
|
+
for (const f of modifiedFiles) {
|
|
376
|
+
console.log(` ${chalk.cyan(f)}`);
|
|
377
|
+
}
|
|
378
|
+
console.log();
|
|
379
|
+
console.log(chalk.gray(" Look for code between sail marker comments and remove"));
|
|
380
|
+
console.log(chalk.gray(" the lines that were added by this sail."));
|
|
381
|
+
}
|
|
382
|
+
// Update installed.json
|
|
383
|
+
installed.installed = installed.installed.filter((entry) => typeof entry === "string" ? entry !== sailName : entry.name !== sailName);
|
|
384
|
+
saveInstalled(installed);
|
|
385
|
+
console.log();
|
|
386
|
+
console.log(chalk.green(` Sail "${sailName}" removed from installed.json.`));
|
|
387
|
+
console.log();
|
|
388
|
+
}
|
|
389
|
+
async function commandSailUpdate(sailName) {
|
|
390
|
+
requireKeelProject();
|
|
391
|
+
const registry = loadRegistry();
|
|
392
|
+
const installed = migrateInstalledJson(loadInstalled());
|
|
393
|
+
const installedNames = getInstalledNames(installed);
|
|
394
|
+
console.log();
|
|
395
|
+
console.log(chalk.bold.blue(" ⛵ Sail Update Check"));
|
|
396
|
+
console.log();
|
|
397
|
+
// Find sails with available updates
|
|
398
|
+
const updatable = [];
|
|
399
|
+
const sailsToCheck = sailName ? [sailName] : installedNames;
|
|
400
|
+
for (const name of sailsToCheck) {
|
|
401
|
+
if (!installedNames.includes(name)) {
|
|
402
|
+
console.log(chalk.red(` Sail "${name}" is not installed.`));
|
|
403
|
+
console.log();
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
const registryEntry = registry.sails.find((s) => s.name === name);
|
|
407
|
+
if (!registryEntry)
|
|
408
|
+
continue;
|
|
409
|
+
const currentVersion = getInstalledVersion(installed, name);
|
|
410
|
+
if (!currentVersion || currentVersion === "unknown") {
|
|
411
|
+
updatable.push({ name, current: "unknown", latest: registryEntry.version, displayName: registryEntry.displayName });
|
|
412
|
+
}
|
|
413
|
+
else if (currentVersion !== registryEntry.version) {
|
|
414
|
+
updatable.push({ name, current: currentVersion, latest: registryEntry.version, displayName: registryEntry.displayName });
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
if (updatable.length === 0) {
|
|
418
|
+
console.log(chalk.green(" All sails are up to date."));
|
|
419
|
+
console.log();
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
console.log(chalk.bold(" Updates available:"));
|
|
423
|
+
console.log();
|
|
424
|
+
for (const sail of updatable) {
|
|
425
|
+
console.log(` ${sail.displayName.padEnd(24)} ${chalk.yellow(sail.current)} → ${chalk.green(sail.latest)}`);
|
|
426
|
+
}
|
|
427
|
+
console.log();
|
|
428
|
+
console.log(chalk.bold(" How to update sails:"));
|
|
429
|
+
console.log();
|
|
430
|
+
console.log(chalk.gray(" Sails inject code directly into your project files. To update:"));
|
|
431
|
+
console.log();
|
|
432
|
+
console.log(` 1. Review the changelog for the sail in the keel repository`);
|
|
433
|
+
console.log(` 2. ${chalk.cyan("keel sail remove <name>")} — remove the old version`);
|
|
434
|
+
console.log(` 3. ${chalk.cyan("keel sail add <name>")} — reinstall the latest version`);
|
|
435
|
+
console.log(` 4. Resolve any conflicts with your custom changes`);
|
|
436
|
+
console.log();
|
|
437
|
+
console.log(chalk.gray(" Tip: Commit your changes before updating so you can compare diffs."));
|
|
438
|
+
console.log();
|
|
439
|
+
// Do not mutate installed versions here — the actual code hasn't been
|
|
440
|
+
// updated yet. Versions only change when the user reinstalls the sail.
|
|
441
|
+
}
|
|
442
|
+
function commandList() {
|
|
443
|
+
const registry = loadRegistry();
|
|
444
|
+
const installed = isInsideKeelProject() ? migrateInstalledJson(loadInstalled()) : { version: 2, installed: [] };
|
|
445
|
+
const installedNames = getInstalledNames(installed);
|
|
446
|
+
console.log();
|
|
447
|
+
console.log(chalk.bold(" Available sails:"));
|
|
448
|
+
console.log();
|
|
449
|
+
// Column formatting
|
|
450
|
+
const maxName = Math.max(...registry.sails.map((a) => a.displayName.length));
|
|
451
|
+
for (const sail of registry.sails) {
|
|
452
|
+
const isInstalled = installedNames.includes(sail.name);
|
|
453
|
+
const isPlanned = sail.status === "planned";
|
|
454
|
+
const installedVersion = isInstalled ? getInstalledVersion(installed, sail.name) : null;
|
|
455
|
+
const hasUpdate = isInstalled && installedVersion && installedVersion !== "unknown" && installedVersion !== sail.version;
|
|
456
|
+
let status;
|
|
457
|
+
if (isInstalled && hasUpdate) {
|
|
458
|
+
status = chalk.yellow(`installed (${installedVersion} → ${sail.version})`);
|
|
459
|
+
}
|
|
460
|
+
else if (isInstalled) {
|
|
461
|
+
status = chalk.green(`installed${installedVersion && installedVersion !== "unknown" ? ` (${installedVersion})` : ""}`);
|
|
462
|
+
}
|
|
463
|
+
else if (isPlanned) {
|
|
464
|
+
status = chalk.gray("planned");
|
|
465
|
+
}
|
|
466
|
+
else {
|
|
467
|
+
status = chalk.blue("available");
|
|
468
|
+
}
|
|
469
|
+
const name = sail.displayName.padEnd(maxName + 2);
|
|
470
|
+
console.log(` ${name} ${status} ${chalk.gray(sail.description)}`);
|
|
471
|
+
}
|
|
472
|
+
console.log();
|
|
473
|
+
if (!isInsideKeelProject()) {
|
|
474
|
+
console.log(chalk.gray(" Note: Not inside a keel project. Install status not shown.\n"));
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
function commandInfo(sailName) {
|
|
478
|
+
const registry = loadRegistry();
|
|
479
|
+
const registryEntry = registry.sails.find((a) => a.name === sailName);
|
|
480
|
+
if (!registryEntry) {
|
|
481
|
+
console.error(chalk.red(`\n Error: Unknown sail "${sailName}".`));
|
|
482
|
+
console.error(chalk.gray(" Run 'keel list' to see available sails.\n"));
|
|
483
|
+
process.exit(1);
|
|
484
|
+
}
|
|
485
|
+
console.log();
|
|
486
|
+
console.log(chalk.bold(` ${registryEntry.displayName}`));
|
|
487
|
+
console.log(chalk.gray(` ${registryEntry.description}`));
|
|
488
|
+
console.log();
|
|
489
|
+
console.log(` Category: ${registryEntry.category}`);
|
|
490
|
+
console.log(` Version: ${registryEntry.version}`);
|
|
491
|
+
if (registryEntry.status === "planned") {
|
|
492
|
+
console.log(` Status: ${chalk.yellow("planned (not yet available)")}`);
|
|
493
|
+
console.log();
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
// Load full manifest if available
|
|
497
|
+
const manifest = loadSailManifest(sailName);
|
|
498
|
+
if (manifest) {
|
|
499
|
+
if (manifest.requiredEnvVars.length > 0) {
|
|
500
|
+
console.log();
|
|
501
|
+
console.log(chalk.bold(" Required environment variables:"));
|
|
502
|
+
for (const envVar of manifest.requiredEnvVars) {
|
|
503
|
+
console.log(` ${envVar.key} ${chalk.gray(envVar.description)}`);
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
const allAdds = [
|
|
507
|
+
...(manifest.adds.backend || []).map((f) => `packages/backend/${f}`),
|
|
508
|
+
...(manifest.adds.frontend || []).map((f) => `packages/frontend/${f}`),
|
|
509
|
+
];
|
|
510
|
+
if (allAdds.length > 0) {
|
|
511
|
+
console.log();
|
|
512
|
+
console.log(chalk.bold(" Files added:"));
|
|
513
|
+
for (const f of allAdds) {
|
|
514
|
+
console.log(` + ${f}`);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
const allModifies = [
|
|
518
|
+
...(manifest.modifies.backend || []).map((f) => `packages/backend/${f}`),
|
|
519
|
+
...(manifest.modifies.frontend || []).map((f) => `packages/frontend/${f}`),
|
|
520
|
+
];
|
|
521
|
+
if (allModifies.length > 0) {
|
|
522
|
+
console.log();
|
|
523
|
+
console.log(chalk.bold(" Files modified:"));
|
|
524
|
+
for (const f of allModifies) {
|
|
525
|
+
console.log(` ~ ${f}`);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
const backendDeps = Object.entries(manifest.dependencies.backend || {});
|
|
529
|
+
const frontendDeps = Object.entries(manifest.dependencies.frontend || {});
|
|
530
|
+
if (backendDeps.length > 0 || frontendDeps.length > 0) {
|
|
531
|
+
console.log();
|
|
532
|
+
console.log(chalk.bold(" Dependencies:"));
|
|
533
|
+
for (const [name, version] of backendDeps) {
|
|
534
|
+
console.log(` backend: ${name}@${version}`);
|
|
535
|
+
}
|
|
536
|
+
for (const [name, version] of frontendDeps) {
|
|
537
|
+
console.log(` frontend: ${name}@${version}`);
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
// Check installation status
|
|
542
|
+
if (isInsideKeelProject()) {
|
|
543
|
+
const installed = migrateInstalledJson(loadInstalled());
|
|
544
|
+
const isInstalled = getInstalledNames(installed).includes(sailName);
|
|
545
|
+
console.log();
|
|
546
|
+
console.log(` Status: ${isInstalled ? chalk.green("installed") : chalk.blue("not installed")}`);
|
|
547
|
+
if (!isInstalled) {
|
|
548
|
+
console.log(chalk.gray(` Install with: keel sail add ${sailName}`));
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
console.log();
|
|
552
|
+
}
|
|
553
|
+
// ---------------------------------------------------------------------------
|
|
554
|
+
// Commands — Dev & Start
|
|
555
|
+
// ---------------------------------------------------------------------------
|
|
556
|
+
function runSync(cmd, label) {
|
|
557
|
+
const spinner = ora(` ${label}...`).start();
|
|
558
|
+
try {
|
|
559
|
+
const [bin, ...args] = cmd.split(" ");
|
|
560
|
+
execFileSync(bin, args, { cwd: process.cwd(), stdio: "pipe" });
|
|
561
|
+
spinner.succeed(` ${label}`);
|
|
562
|
+
return true;
|
|
563
|
+
}
|
|
564
|
+
catch {
|
|
565
|
+
spinner.fail(` ${label} — failed`);
|
|
566
|
+
return false;
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
function hasDockerCompose() {
|
|
570
|
+
return existsSync(join(process.cwd(), "docker-compose.yml"));
|
|
571
|
+
}
|
|
572
|
+
function hasDocker() {
|
|
573
|
+
try {
|
|
574
|
+
execFileSync("docker", ["info"], { stdio: "pipe" });
|
|
575
|
+
return true;
|
|
576
|
+
}
|
|
577
|
+
catch {
|
|
578
|
+
return false;
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
/**
|
|
582
|
+
* Start database via docker compose. Treats "already running" as success.
|
|
583
|
+
*/
|
|
584
|
+
function startDatabase() {
|
|
585
|
+
const spinner = ora(" Starting database...").start();
|
|
586
|
+
try {
|
|
587
|
+
const output = execFileSync("docker", ["compose", "up", "-d"], {
|
|
588
|
+
cwd: process.cwd(),
|
|
589
|
+
stdio: "pipe",
|
|
590
|
+
}).toString();
|
|
591
|
+
// Check if output mentions "running" or "Started" — both mean success
|
|
592
|
+
if (output.includes("Running") || output.includes("Started") || output.includes("running")) {
|
|
593
|
+
spinner.succeed(" Database running");
|
|
594
|
+
}
|
|
595
|
+
else {
|
|
596
|
+
spinner.succeed(" Database running");
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
catch (error) {
|
|
600
|
+
const stderr = error.stderr?.toString() ?? "";
|
|
601
|
+
// Port already allocated = DB is already running from a previous session
|
|
602
|
+
if (stderr.includes("port is already allocated") || stderr.includes("already in use")) {
|
|
603
|
+
spinner.succeed(" Database already running");
|
|
604
|
+
}
|
|
605
|
+
else {
|
|
606
|
+
spinner.fail(" Failed to start database");
|
|
607
|
+
if (stderr)
|
|
608
|
+
console.log(chalk.gray(` ${stderr.trim()}`));
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
/**
|
|
613
|
+
* Replace the current process with the given command.
|
|
614
|
+
* Uses spawn with inherited stdio so Ctrl+C / SIGINT propagates correctly.
|
|
615
|
+
* The child is spawned in a detached process group so that SIGTERM can
|
|
616
|
+
* reliably kill the entire tree (e.g. concurrently-managed dev servers).
|
|
617
|
+
*/
|
|
618
|
+
function replaceProcess(cmd, args) {
|
|
619
|
+
const child = spawn(cmd, args, {
|
|
620
|
+
cwd: process.cwd(),
|
|
621
|
+
stdio: "inherit",
|
|
622
|
+
detached: true,
|
|
623
|
+
});
|
|
624
|
+
// Prevent the parent ref from keeping Node alive after child exits
|
|
625
|
+
child.unref();
|
|
626
|
+
// Re-ref so we can wait for the close event
|
|
627
|
+
child.ref();
|
|
628
|
+
/**
|
|
629
|
+
* Kill the entire process group. Using -pid sends the signal to every
|
|
630
|
+
* process in the group, which covers forked dev servers, Vite, tsx, etc.
|
|
631
|
+
*/
|
|
632
|
+
function killTree(signal) {
|
|
633
|
+
if (child.pid) {
|
|
634
|
+
try {
|
|
635
|
+
process.kill(-child.pid, signal);
|
|
636
|
+
}
|
|
637
|
+
catch {
|
|
638
|
+
// Process group may already be gone
|
|
639
|
+
child.kill(signal);
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
// Strip terminal escape sequences from child output is handled by
|
|
644
|
+
// inheriting stdio directly — no extra processing needed.
|
|
645
|
+
process.on("SIGINT", () => {
|
|
646
|
+
killTree("SIGTERM");
|
|
647
|
+
// Give child processes a moment to clean up, then force exit
|
|
648
|
+
setTimeout(() => process.exit(0), 1000);
|
|
649
|
+
});
|
|
650
|
+
process.on("SIGTERM", () => {
|
|
651
|
+
killTree("SIGTERM");
|
|
652
|
+
setTimeout(() => process.exit(0), 1000);
|
|
653
|
+
});
|
|
654
|
+
child.on("close", (code) => {
|
|
655
|
+
process.exit(code ?? 0);
|
|
656
|
+
});
|
|
657
|
+
}
|
|
658
|
+
async function commandDev() {
|
|
659
|
+
requireKeelProject();
|
|
660
|
+
console.log();
|
|
661
|
+
console.log(chalk.bold.blue(" ⛵ keel dev"));
|
|
662
|
+
console.log();
|
|
663
|
+
// Check if using PGlite (no Docker needed)
|
|
664
|
+
const envVars = loadEnvFile();
|
|
665
|
+
const usingPGlite = envVars["DATABASE_URL"]?.startsWith("pglite://");
|
|
666
|
+
// Step 1: Start Docker database if docker-compose exists (skip for PGlite)
|
|
667
|
+
if (usingPGlite) {
|
|
668
|
+
console.log(chalk.green(" ✔ Using PGlite (embedded PostgreSQL) — no Docker needed"));
|
|
669
|
+
}
|
|
670
|
+
else if (hasDockerCompose()) {
|
|
671
|
+
if (hasDocker()) {
|
|
672
|
+
startDatabase();
|
|
673
|
+
}
|
|
674
|
+
else {
|
|
675
|
+
console.log(chalk.yellow(" ⚠ docker-compose.yml found but Docker is not running"));
|
|
676
|
+
console.log(chalk.gray(" Make sure your database is accessible via DATABASE_URL\n"));
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
// Step 2: Run migrations
|
|
680
|
+
runSync("npm run db:migrate", "Running migrations");
|
|
681
|
+
// Step 3: Start dev servers
|
|
682
|
+
console.log();
|
|
683
|
+
console.log(chalk.bold(" Starting dev servers...\n"));
|
|
684
|
+
replaceProcess("npm", ["run", "dev"]);
|
|
685
|
+
}
|
|
686
|
+
async function commandStart() {
|
|
687
|
+
requireKeelProject();
|
|
688
|
+
console.log();
|
|
689
|
+
console.log(chalk.bold.blue(" ⛵ keel start"));
|
|
690
|
+
console.log();
|
|
691
|
+
// Step 1: Start Docker database if docker-compose exists
|
|
692
|
+
if (hasDockerCompose()) {
|
|
693
|
+
if (hasDocker()) {
|
|
694
|
+
startDatabase();
|
|
695
|
+
}
|
|
696
|
+
else {
|
|
697
|
+
console.log(chalk.yellow(" ⚠ docker-compose.yml found but Docker is not running"));
|
|
698
|
+
console.log(chalk.gray(" Make sure your database is accessible via DATABASE_URL\n"));
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
// Step 2: Run migrations
|
|
702
|
+
runSync("npm run db:migrate", "Running migrations");
|
|
703
|
+
// Step 3: Build all packages
|
|
704
|
+
if (!runSync("npm run build", "Building packages")) {
|
|
705
|
+
console.error(chalk.red("\n Build failed. Fix errors and try again.\n"));
|
|
706
|
+
process.exit(1);
|
|
707
|
+
}
|
|
708
|
+
// Step 4: Start production server
|
|
709
|
+
console.log();
|
|
710
|
+
console.log(chalk.bold(" Starting production server...\n"));
|
|
711
|
+
replaceProcess("node", ["packages/backend/dist/index.js"]);
|
|
712
|
+
}
|
|
713
|
+
// ---------------------------------------------------------------------------
|
|
714
|
+
// Commands — Doctor (Health Check)
|
|
715
|
+
// ---------------------------------------------------------------------------
|
|
716
|
+
async function commandDoctor() {
|
|
717
|
+
console.log();
|
|
718
|
+
console.log(chalk.bold.blue(" ⛵ keel doctor"));
|
|
719
|
+
console.log();
|
|
720
|
+
const pass = (msg) => console.log(` ${chalk.green("✔")} ${msg}`);
|
|
721
|
+
const fail = (msg) => console.log(` ${chalk.red("✘")} ${msg}`);
|
|
722
|
+
const warn = (msg) => console.log(` ${chalk.yellow("⚠")} ${msg}`);
|
|
723
|
+
// Node.js version
|
|
724
|
+
const nodeVersion = process.version;
|
|
725
|
+
const nodeMajor = parseInt(nodeVersion.slice(1).split(".")[0], 10);
|
|
726
|
+
if (nodeMajor >= 22) {
|
|
727
|
+
pass(`Node.js ${nodeVersion}`);
|
|
728
|
+
}
|
|
729
|
+
else {
|
|
730
|
+
fail(`Node.js ${nodeVersion} — version 22+ required`);
|
|
731
|
+
}
|
|
732
|
+
// npm version
|
|
733
|
+
try {
|
|
734
|
+
const npmVersion = execFileSync("npm", ["--version"], { stdio: "pipe" }).toString().trim();
|
|
735
|
+
pass(`npm ${npmVersion}`);
|
|
736
|
+
}
|
|
737
|
+
catch {
|
|
738
|
+
fail("npm — not found");
|
|
739
|
+
}
|
|
740
|
+
// Docker available and running
|
|
741
|
+
try {
|
|
742
|
+
execFileSync("docker", ["info"], { stdio: "pipe" });
|
|
743
|
+
pass("Docker is running");
|
|
744
|
+
}
|
|
745
|
+
catch {
|
|
746
|
+
try {
|
|
747
|
+
execFileSync("docker", ["--version"], { stdio: "pipe" });
|
|
748
|
+
fail("Docker is installed but not running");
|
|
749
|
+
}
|
|
750
|
+
catch {
|
|
751
|
+
fail("Docker is not installed");
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
// PostgreSQL reachable via DATABASE_URL
|
|
755
|
+
const envVars = loadEnvFile();
|
|
756
|
+
const dbUrl = envVars["DATABASE_URL"];
|
|
757
|
+
if (dbUrl) {
|
|
758
|
+
try {
|
|
759
|
+
execFileSync("node", [
|
|
760
|
+
"-e",
|
|
761
|
+
`const { Client } = require('pg'); const c = new Client(process.env.DATABASE_URL); c.connect().then(() => { c.end(); process.exit(0); }).catch(() => process.exit(1))`,
|
|
762
|
+
], { stdio: "pipe", timeout: 5000, env: { ...process.env, DATABASE_URL: dbUrl } });
|
|
763
|
+
pass("PostgreSQL is reachable");
|
|
764
|
+
}
|
|
765
|
+
catch {
|
|
766
|
+
// Try a simpler check — see if psql is available via Docker
|
|
767
|
+
try {
|
|
768
|
+
execFileSync("docker", [
|
|
769
|
+
"exec", "-i",
|
|
770
|
+
execFileSync("docker", ["ps", "-q", "--filter", "ancestor=postgres"], { stdio: "pipe", timeout: 5000 }).toString().trim().split("\n")[0],
|
|
771
|
+
"pg_isready",
|
|
772
|
+
], { stdio: "pipe", timeout: 5000 });
|
|
773
|
+
pass("PostgreSQL is reachable (via Docker)");
|
|
774
|
+
}
|
|
775
|
+
catch {
|
|
776
|
+
warn("PostgreSQL — could not verify connection (DATABASE_URL is set)");
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
else {
|
|
781
|
+
fail("PostgreSQL — DATABASE_URL not set in .env");
|
|
782
|
+
}
|
|
783
|
+
// .env file exists with required vars
|
|
784
|
+
const envPath = getEnvFilePath();
|
|
785
|
+
if (envPath) {
|
|
786
|
+
pass(`.env file found (${envPath})`);
|
|
787
|
+
}
|
|
788
|
+
else {
|
|
789
|
+
fail(".env file not found");
|
|
790
|
+
}
|
|
791
|
+
if (envVars["DATABASE_URL"]) {
|
|
792
|
+
pass("DATABASE_URL is set");
|
|
793
|
+
}
|
|
794
|
+
else {
|
|
795
|
+
fail("DATABASE_URL is missing");
|
|
796
|
+
}
|
|
797
|
+
if (envVars["BETTER_AUTH_SECRET"]) {
|
|
798
|
+
pass("BETTER_AUTH_SECRET is set");
|
|
799
|
+
}
|
|
800
|
+
else {
|
|
801
|
+
fail("BETTER_AUTH_SECRET is missing");
|
|
802
|
+
}
|
|
803
|
+
// node_modules exists
|
|
804
|
+
if (existsSync(join(process.cwd(), "node_modules"))) {
|
|
805
|
+
pass("node_modules exists (dependencies installed)");
|
|
806
|
+
}
|
|
807
|
+
else {
|
|
808
|
+
fail("node_modules not found — run 'npm install'");
|
|
809
|
+
}
|
|
810
|
+
// TypeScript (optional)
|
|
811
|
+
try {
|
|
812
|
+
const tscVersion = execFileSync("npx", ["tsc", "--version"], { stdio: "pipe", timeout: 10000 }).toString().trim();
|
|
813
|
+
pass(`TypeScript ${tscVersion}`);
|
|
814
|
+
}
|
|
815
|
+
catch {
|
|
816
|
+
warn("TypeScript — tsc not found (optional)");
|
|
817
|
+
}
|
|
818
|
+
console.log();
|
|
819
|
+
}
|
|
820
|
+
// ---------------------------------------------------------------------------
|
|
821
|
+
// Commands — Code Generators
|
|
822
|
+
// ---------------------------------------------------------------------------
|
|
823
|
+
function commandGenerateRoute(name) {
|
|
824
|
+
requireKeelProject();
|
|
825
|
+
validateGeneratorName(name);
|
|
826
|
+
const filePath = join(process.cwd(), "packages", "backend", "src", "routes", `${name}.ts`);
|
|
827
|
+
if (existsSync(filePath)) {
|
|
828
|
+
console.error(chalk.red(`\n Error: Route file already exists at ${filePath}\n`));
|
|
829
|
+
process.exit(1);
|
|
830
|
+
}
|
|
831
|
+
// Ensure directory exists
|
|
832
|
+
const dir = dirname(filePath);
|
|
833
|
+
if (!existsSync(dir)) {
|
|
834
|
+
mkdirSync(dir, { recursive: true });
|
|
835
|
+
}
|
|
836
|
+
const content = `import { Router } from "express";
|
|
837
|
+
import type { Request, Response } from "express";
|
|
838
|
+
|
|
839
|
+
const router = Router();
|
|
840
|
+
|
|
841
|
+
router.get("/", (_req: Request, res: Response) => {
|
|
842
|
+
res.json({ message: "${name} route" });
|
|
843
|
+
});
|
|
844
|
+
|
|
845
|
+
export default router;
|
|
846
|
+
`;
|
|
847
|
+
writeFileSync(filePath, content, "utf-8");
|
|
848
|
+
// Auto-mount the route in index.ts
|
|
849
|
+
const indexPath = join(process.cwd(), "packages", "backend", "src", "index.ts");
|
|
850
|
+
const importLine = `import ${name}Router from "./routes/${name}.js";`;
|
|
851
|
+
const mountLine = `app.use("/api/${name}", ${name}Router);`;
|
|
852
|
+
let autoMounted = false;
|
|
853
|
+
if (existsSync(indexPath)) {
|
|
854
|
+
let indexContent = readFileSync(indexPath, "utf-8");
|
|
855
|
+
// Add import after SAIL_IMPORTS marker
|
|
856
|
+
if (indexContent.includes("// [SAIL_IMPORTS]") && !indexContent.includes(importLine)) {
|
|
857
|
+
indexContent = indexContent.replace("// [SAIL_IMPORTS]", `// [SAIL_IMPORTS]\n${importLine}`);
|
|
858
|
+
}
|
|
859
|
+
// Add mount before SAIL_ROUTES marker
|
|
860
|
+
if (indexContent.includes("// [SAIL_ROUTES]") && !indexContent.includes(mountLine)) {
|
|
861
|
+
indexContent = indexContent.replace("// [SAIL_ROUTES]", `${mountLine}\n// [SAIL_ROUTES]`);
|
|
862
|
+
}
|
|
863
|
+
writeFileSync(indexPath, indexContent, "utf-8");
|
|
864
|
+
autoMounted = true;
|
|
865
|
+
}
|
|
866
|
+
console.log();
|
|
867
|
+
console.log(chalk.green(` Created: packages/backend/src/routes/${name}.ts`));
|
|
868
|
+
if (autoMounted) {
|
|
869
|
+
console.log(chalk.green(` Mounted: app.use("/api/${name}", ${name}Router) in index.ts`));
|
|
870
|
+
console.log();
|
|
871
|
+
console.log(chalk.gray(` Ready at: /api/${name}`));
|
|
872
|
+
}
|
|
873
|
+
else {
|
|
874
|
+
console.log();
|
|
875
|
+
console.log(chalk.yellow(" Could not auto-mount. Manually add to packages/backend/src/index.ts:"));
|
|
876
|
+
console.log(chalk.cyan(` ${importLine}`));
|
|
877
|
+
console.log(chalk.cyan(` ${mountLine}`));
|
|
878
|
+
}
|
|
879
|
+
console.log();
|
|
880
|
+
}
|
|
881
|
+
function commandGeneratePage(name) {
|
|
882
|
+
requireKeelProject();
|
|
883
|
+
validateGeneratorName(name);
|
|
884
|
+
const pascalName = toPascalCase(name);
|
|
885
|
+
const filePath = join(process.cwd(), "packages", "frontend", "src", "pages", `${pascalName}.tsx`);
|
|
886
|
+
if (existsSync(filePath)) {
|
|
887
|
+
console.error(chalk.red(`\n Error: Page file already exists at ${filePath}\n`));
|
|
888
|
+
process.exit(1);
|
|
889
|
+
}
|
|
890
|
+
// Ensure directory exists
|
|
891
|
+
const dir = dirname(filePath);
|
|
892
|
+
if (!existsSync(dir)) {
|
|
893
|
+
mkdirSync(dir, { recursive: true });
|
|
894
|
+
}
|
|
895
|
+
const content = `export default function ${pascalName}() {
|
|
896
|
+
return (
|
|
897
|
+
<div className="container mx-auto px-4 py-8">
|
|
898
|
+
<h1 className="text-2xl font-bold">${pascalName}</h1>
|
|
899
|
+
<p className="mt-4 text-gray-600">This is the ${pascalName} page.</p>
|
|
900
|
+
</div>
|
|
901
|
+
);
|
|
902
|
+
}
|
|
903
|
+
`;
|
|
904
|
+
writeFileSync(filePath, content, "utf-8");
|
|
905
|
+
// Auto-add route to router.tsx
|
|
906
|
+
const routerPath = join(process.cwd(), "packages", "frontend", "src", "router.tsx");
|
|
907
|
+
const importLine = `import ${pascalName} from "./pages/${pascalName}";`;
|
|
908
|
+
const routeLine = ` <Route path="/${name}" element={<${pascalName} />} />`;
|
|
909
|
+
let autoRouted = false;
|
|
910
|
+
if (existsSync(routerPath)) {
|
|
911
|
+
let routerContent = readFileSync(routerPath, "utf-8");
|
|
912
|
+
// Add import if not already present
|
|
913
|
+
if (!routerContent.includes(importLine)) {
|
|
914
|
+
// Insert after the SAIL_IMPORTS marker (or after the last import if marker missing)
|
|
915
|
+
if (routerContent.includes("// [SAIL_IMPORTS]")) {
|
|
916
|
+
routerContent = routerContent.replace("// [SAIL_IMPORTS]", `// [SAIL_IMPORTS]\n${importLine}`);
|
|
917
|
+
}
|
|
918
|
+
else {
|
|
919
|
+
const lastImportIdx = routerContent.lastIndexOf("import ");
|
|
920
|
+
const nextNewline = routerContent.indexOf("\n", lastImportIdx);
|
|
921
|
+
routerContent = routerContent.slice(0, nextNewline + 1) + importLine + "\n" + routerContent.slice(nextNewline + 1);
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
// Add route before SAIL_ROUTES marker
|
|
925
|
+
if (!routerContent.includes(`path="/${name}"`)) {
|
|
926
|
+
if (routerContent.includes("{/* [SAIL_ROUTES] */}")) {
|
|
927
|
+
routerContent = routerContent.replace("{/* [SAIL_ROUTES] */}", `${routeLine}\n {/* [SAIL_ROUTES] */}`);
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
writeFileSync(routerPath, routerContent, "utf-8");
|
|
931
|
+
autoRouted = true;
|
|
932
|
+
}
|
|
933
|
+
console.log();
|
|
934
|
+
console.log(chalk.green(` Created: packages/frontend/src/pages/${pascalName}.tsx`));
|
|
935
|
+
if (autoRouted) {
|
|
936
|
+
console.log(chalk.green(` Routed: <Route path="/${name}" /> added to router.tsx`));
|
|
937
|
+
console.log();
|
|
938
|
+
console.log(chalk.gray(` Ready at: /${name}`));
|
|
939
|
+
}
|
|
940
|
+
else {
|
|
941
|
+
console.log();
|
|
942
|
+
console.log(chalk.yellow(" Could not auto-route. Manually add to packages/frontend/src/router.tsx:"));
|
|
943
|
+
console.log(chalk.cyan(` ${importLine}`));
|
|
944
|
+
console.log(chalk.cyan(` <Route path="/${name}" element={<${pascalName} />} />`));
|
|
945
|
+
}
|
|
946
|
+
console.log();
|
|
947
|
+
}
|
|
948
|
+
function commandGenerateEmail(name) {
|
|
949
|
+
requireKeelProject();
|
|
950
|
+
validateGeneratorName(name);
|
|
951
|
+
const pascalName = toPascalCase(name);
|
|
952
|
+
const filePath = join(process.cwd(), "packages", "email", "src", `${name}.tsx`);
|
|
953
|
+
if (existsSync(filePath)) {
|
|
954
|
+
console.error(chalk.red(`\n Error: Email template already exists at ${filePath}\n`));
|
|
955
|
+
process.exit(1);
|
|
956
|
+
}
|
|
957
|
+
// Ensure directory exists
|
|
958
|
+
const dir = dirname(filePath);
|
|
959
|
+
if (!existsSync(dir)) {
|
|
960
|
+
mkdirSync(dir, { recursive: true });
|
|
961
|
+
}
|
|
962
|
+
const content = `import {
|
|
963
|
+
Body,
|
|
964
|
+
Container,
|
|
965
|
+
Head,
|
|
966
|
+
Heading,
|
|
967
|
+
Html,
|
|
968
|
+
Preview,
|
|
969
|
+
Section,
|
|
970
|
+
Text,
|
|
971
|
+
} from "@react-email/components";
|
|
972
|
+
import * as React from "react";
|
|
973
|
+
|
|
974
|
+
interface ${pascalName}EmailProps {
|
|
975
|
+
name: string;
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
export default function ${pascalName}Email({ name }: ${pascalName}EmailProps) {
|
|
979
|
+
return (
|
|
980
|
+
<Html>
|
|
981
|
+
<Head />
|
|
982
|
+
<Preview>${pascalName}</Preview>
|
|
983
|
+
<Body style={{ backgroundColor: "#f6f9fc", fontFamily: "sans-serif" }}>
|
|
984
|
+
<Container style={{ margin: "0 auto", padding: "40px 20px", maxWidth: "560px" }}>
|
|
985
|
+
<Section style={{ backgroundColor: "#ffffff", borderRadius: "8px", padding: "32px" }}>
|
|
986
|
+
<Heading style={{ fontSize: "24px", marginBottom: "16px" }}>
|
|
987
|
+
${pascalName}
|
|
988
|
+
</Heading>
|
|
989
|
+
<Text style={{ fontSize: "16px", color: "#333" }}>
|
|
990
|
+
Hello {name},
|
|
991
|
+
</Text>
|
|
992
|
+
<Text style={{ fontSize: "16px", color: "#555" }}>
|
|
993
|
+
This is the ${name} email template.
|
|
994
|
+
</Text>
|
|
995
|
+
</Section>
|
|
996
|
+
</Container>
|
|
997
|
+
</Body>
|
|
998
|
+
</Html>
|
|
999
|
+
);
|
|
1000
|
+
}
|
|
1001
|
+
`;
|
|
1002
|
+
writeFileSync(filePath, content, "utf-8");
|
|
1003
|
+
// Auto-export from email index
|
|
1004
|
+
const emailIndexPath = join(process.cwd(), "packages", "email", "src", "index.ts");
|
|
1005
|
+
const exportLine = `export { default as ${pascalName}Email } from "./${name}.js";`;
|
|
1006
|
+
let autoExported = false;
|
|
1007
|
+
if (existsSync(emailIndexPath)) {
|
|
1008
|
+
let emailIndex = readFileSync(emailIndexPath, "utf-8");
|
|
1009
|
+
if (!emailIndex.includes(exportLine)) {
|
|
1010
|
+
// Ensure file ends with a newline before appending
|
|
1011
|
+
if (!emailIndex.endsWith("\n")) {
|
|
1012
|
+
emailIndex += "\n";
|
|
1013
|
+
}
|
|
1014
|
+
emailIndex += `${exportLine}\n`;
|
|
1015
|
+
writeFileSync(emailIndexPath, emailIndex, "utf-8");
|
|
1016
|
+
autoExported = true;
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
console.log();
|
|
1020
|
+
console.log(chalk.green(` Created: packages/email/src/${name}.tsx`));
|
|
1021
|
+
if (autoExported) {
|
|
1022
|
+
console.log(chalk.green(` Exported: ${pascalName}Email added to email/src/index.ts`));
|
|
1023
|
+
}
|
|
1024
|
+
else if (!existsSync(emailIndexPath)) {
|
|
1025
|
+
console.log();
|
|
1026
|
+
console.log(chalk.yellow(" Could not auto-export. Manually add to packages/email/src/index.ts:"));
|
|
1027
|
+
console.log(chalk.cyan(` ${exportLine}`));
|
|
1028
|
+
}
|
|
1029
|
+
console.log();
|
|
1030
|
+
}
|
|
1031
|
+
// ---------------------------------------------------------------------------
|
|
1032
|
+
// Commands — Database
|
|
1033
|
+
// ---------------------------------------------------------------------------
|
|
1034
|
+
async function commandDbReset() {
|
|
1035
|
+
requireKeelProject();
|
|
1036
|
+
console.log();
|
|
1037
|
+
console.log(chalk.bold.red(" ⚠ Database Reset"));
|
|
1038
|
+
console.log(chalk.gray(" This will DROP all tables and recreate the schema."));
|
|
1039
|
+
console.log(chalk.gray(" All data will be permanently lost."));
|
|
1040
|
+
console.log();
|
|
1041
|
+
const shouldReset = await confirm({
|
|
1042
|
+
message: " Are you sure you want to reset the database?",
|
|
1043
|
+
default: false,
|
|
1044
|
+
});
|
|
1045
|
+
if (!shouldReset) {
|
|
1046
|
+
console.log(chalk.gray("\n Cancelled.\n"));
|
|
1047
|
+
return;
|
|
1048
|
+
}
|
|
1049
|
+
console.log();
|
|
1050
|
+
// Find the postgres container
|
|
1051
|
+
const spinner = ora(" Resetting database schema...").start();
|
|
1052
|
+
try {
|
|
1053
|
+
// Try to find a running postgres container
|
|
1054
|
+
const containerId = execFileSync("docker", ["ps", "-q", "--filter", "ancestor=postgres"], { stdio: "pipe" }).toString().trim().split("\n")[0];
|
|
1055
|
+
if (!containerId) {
|
|
1056
|
+
spinner.fail(" No running PostgreSQL Docker container found");
|
|
1057
|
+
console.log(chalk.gray(" Make sure your database container is running (docker compose up -d)\n"));
|
|
1058
|
+
return;
|
|
1059
|
+
}
|
|
1060
|
+
// Parse database name from DATABASE_URL in .env
|
|
1061
|
+
let dbName = "keel";
|
|
1062
|
+
try {
|
|
1063
|
+
const envPath = join(process.cwd(), "packages", "backend", ".env");
|
|
1064
|
+
if (existsSync(envPath)) {
|
|
1065
|
+
const envContent = readFileSync(envPath, "utf-8");
|
|
1066
|
+
const dbUrlMatch = envContent.match(/^DATABASE_URL\s*=\s*(.+)$/m);
|
|
1067
|
+
if (dbUrlMatch) {
|
|
1068
|
+
const dbUrl = dbUrlMatch[1].replace(/^["']|["']$/g, "");
|
|
1069
|
+
const parsed = new URL(dbUrl);
|
|
1070
|
+
const pathName = parsed.pathname.replace(/^\//, "");
|
|
1071
|
+
if (pathName)
|
|
1072
|
+
dbName = pathName;
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
catch {
|
|
1077
|
+
// Fall back to default "keel" if parsing fails
|
|
1078
|
+
}
|
|
1079
|
+
execFileSync("docker", [
|
|
1080
|
+
"exec", containerId, "psql", "-U", "postgres", "-d", dbName,
|
|
1081
|
+
"-c", "DROP SCHEMA public CASCADE; CREATE SCHEMA public;"
|
|
1082
|
+
], { stdio: "pipe" });
|
|
1083
|
+
spinner.succeed(" Database schema reset");
|
|
1084
|
+
}
|
|
1085
|
+
catch (error) {
|
|
1086
|
+
spinner.fail(" Failed to reset database schema");
|
|
1087
|
+
console.error(chalk.gray(` ${error}`));
|
|
1088
|
+
console.log();
|
|
1089
|
+
return;
|
|
1090
|
+
}
|
|
1091
|
+
// Run migrations
|
|
1092
|
+
runSync("npm run db:migrate", "Running migrations");
|
|
1093
|
+
console.log();
|
|
1094
|
+
console.log(chalk.green(" Database reset complete.\n"));
|
|
1095
|
+
}
|
|
1096
|
+
function commandDbStudio() {
|
|
1097
|
+
requireKeelProject();
|
|
1098
|
+
console.log();
|
|
1099
|
+
console.log(chalk.bold.blue(" ⛵ Opening Drizzle Studio..."));
|
|
1100
|
+
console.log();
|
|
1101
|
+
replaceProcess("npm", ["run", "db:studio"]);
|
|
1102
|
+
}
|
|
1103
|
+
function commandDbSeed() {
|
|
1104
|
+
requireKeelProject();
|
|
1105
|
+
const seedPath = join(process.cwd(), "packages", "backend", "src", "db", "seed.ts");
|
|
1106
|
+
if (!existsSync(seedPath)) {
|
|
1107
|
+
console.log();
|
|
1108
|
+
console.log(chalk.yellow(" No seed file found."));
|
|
1109
|
+
console.log(chalk.gray(" Create packages/backend/src/db/seed.ts to use this command."));
|
|
1110
|
+
console.log();
|
|
1111
|
+
return;
|
|
1112
|
+
}
|
|
1113
|
+
console.log();
|
|
1114
|
+
console.log(chalk.bold.blue(" ⛵ Running database seed..."));
|
|
1115
|
+
console.log();
|
|
1116
|
+
replaceProcess("npx", ["tsx", "packages/backend/src/db/seed.ts"]);
|
|
1117
|
+
}
|
|
1118
|
+
// ---------------------------------------------------------------------------
|
|
1119
|
+
// Commands — Deploy
|
|
1120
|
+
// ---------------------------------------------------------------------------
|
|
1121
|
+
function commandDeploy() {
|
|
1122
|
+
requireKeelProject();
|
|
1123
|
+
console.log();
|
|
1124
|
+
console.log(chalk.bold.blue(" ⛵ keel deploy"));
|
|
1125
|
+
console.log();
|
|
1126
|
+
console.log(chalk.bold(" Choose a deployment target:\n"));
|
|
1127
|
+
console.log(chalk.bold(" 1. Docker (self-hosted)"));
|
|
1128
|
+
console.log(chalk.gray(" Deploy anywhere that runs Docker — VPS, AWS ECS, DigitalOcean, etc."));
|
|
1129
|
+
console.log();
|
|
1130
|
+
console.log(` ${chalk.cyan("docker compose -f docker-compose.prod.yml up -d")}`);
|
|
1131
|
+
console.log();
|
|
1132
|
+
console.log(chalk.bold(" 2. Fly.io"));
|
|
1133
|
+
console.log(chalk.gray(" Global edge deployment with automatic SSL and scaling."));
|
|
1134
|
+
console.log();
|
|
1135
|
+
console.log(` ${chalk.cyan("fly launch --copy-config")} ${chalk.gray("# First time")}`);
|
|
1136
|
+
console.log(` ${chalk.cyan("fly deploy")} ${chalk.gray("# Subsequent deploys")}`);
|
|
1137
|
+
console.log(` ${chalk.cyan("fly secrets set DATABASE_URL=\"postgres://...\"")} ${chalk.gray("# Set env vars")}`);
|
|
1138
|
+
console.log();
|
|
1139
|
+
console.log(chalk.bold(" 3. Railway"));
|
|
1140
|
+
console.log(chalk.gray(" One-click deploy from GitHub with built-in PostgreSQL."));
|
|
1141
|
+
console.log();
|
|
1142
|
+
console.log(` ${chalk.cyan("railway up")} ${chalk.gray("# Deploy from CLI")}`);
|
|
1143
|
+
console.log(chalk.gray(" Or connect your GitHub repo at railway.com"));
|
|
1144
|
+
console.log();
|
|
1145
|
+
console.log(chalk.bold(" 4. Vercel (frontend only)"));
|
|
1146
|
+
console.log(chalk.gray(" Static frontend deployment with edge CDN."));
|
|
1147
|
+
console.log();
|
|
1148
|
+
console.log(` ${chalk.cyan("vercel --cwd packages/frontend")}`);
|
|
1149
|
+
console.log();
|
|
1150
|
+
console.log(chalk.bold(" 5. Manual / AWS / GCP"));
|
|
1151
|
+
console.log(chalk.gray(" Use the Dockerfile at packages/backend/Dockerfile"));
|
|
1152
|
+
console.log(chalk.gray(" to build a container image for any platform:"));
|
|
1153
|
+
console.log();
|
|
1154
|
+
console.log(` ${chalk.cyan("docker build -f packages/backend/Dockerfile -t my-app .")}`);
|
|
1155
|
+
console.log(` ${chalk.cyan("docker run -p 3005:3005 --env-file .env my-app")}`);
|
|
1156
|
+
console.log();
|
|
1157
|
+
console.log(chalk.bold(" Configuration files included:"));
|
|
1158
|
+
console.log(` ${chalk.green("✔")} packages/backend/Dockerfile ${chalk.gray("Multi-stage production build")}`);
|
|
1159
|
+
console.log(` ${chalk.green("✔")} docker-compose.prod.yml ${chalk.gray("Full-stack self-hosted")}`);
|
|
1160
|
+
console.log(` ${chalk.green("✔")} fly.toml ${chalk.gray("Fly.io config")}`);
|
|
1161
|
+
console.log(` ${chalk.green("✔")} packages/backend/railway.json ${chalk.gray("Railway config")}`);
|
|
1162
|
+
console.log(` ${chalk.green("✔")} packages/frontend/vercel.json ${chalk.gray("Vercel frontend config")}`);
|
|
1163
|
+
console.log();
|
|
1164
|
+
console.log(chalk.bold(" Required environment variables for production:"));
|
|
1165
|
+
console.log(chalk.gray(" DATABASE_URL, BETTER_AUTH_SECRET (32+ chars),"));
|
|
1166
|
+
console.log(chalk.gray(" FRONTEND_URL, BACKEND_URL, RESEND_API_KEY, EMAIL_FROM"));
|
|
1167
|
+
console.log();
|
|
1168
|
+
console.log(chalk.gray(" Run 'keel env' to check your current configuration."));
|
|
1169
|
+
console.log();
|
|
1170
|
+
}
|
|
1171
|
+
// ---------------------------------------------------------------------------
|
|
1172
|
+
// Commands — Upgrade
|
|
1173
|
+
// ---------------------------------------------------------------------------
|
|
1174
|
+
async function commandUpgrade() {
|
|
1175
|
+
console.log();
|
|
1176
|
+
console.log(chalk.bold.blue(" ⛵ keel upgrade"));
|
|
1177
|
+
console.log();
|
|
1178
|
+
// Show current version
|
|
1179
|
+
try {
|
|
1180
|
+
const pkgPath = join(__dirname, "..", "package.json");
|
|
1181
|
+
if (existsSync(pkgPath)) {
|
|
1182
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
|
|
1183
|
+
console.log(` Current version: ${chalk.cyan(pkg.version)}`);
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
catch {
|
|
1187
|
+
// Ignore
|
|
1188
|
+
}
|
|
1189
|
+
// Check latest version
|
|
1190
|
+
try {
|
|
1191
|
+
const latest = execFileSync("npm", ["view", "keel", "version"], { stdio: "pipe" }).toString().trim();
|
|
1192
|
+
console.log(` Latest version: ${chalk.cyan(latest)}`);
|
|
1193
|
+
}
|
|
1194
|
+
catch {
|
|
1195
|
+
console.log(chalk.gray(" Could not check latest version."));
|
|
1196
|
+
}
|
|
1197
|
+
console.log();
|
|
1198
|
+
console.log(chalk.bold(" To upgrade, run:"));
|
|
1199
|
+
console.log(chalk.cyan(" npm install -g keel@latest"));
|
|
1200
|
+
console.log();
|
|
1201
|
+
console.log(chalk.gray(" Or use npx to always run the latest:"));
|
|
1202
|
+
console.log(chalk.cyan(" npx keel@latest <command>"));
|
|
1203
|
+
console.log();
|
|
1204
|
+
}
|
|
1205
|
+
// ---------------------------------------------------------------------------
|
|
1206
|
+
// Commands — Env Check
|
|
1207
|
+
// ---------------------------------------------------------------------------
|
|
1208
|
+
function commandEnv() {
|
|
1209
|
+
requireKeelProject();
|
|
1210
|
+
console.log();
|
|
1211
|
+
console.log(chalk.bold.blue(" ⛵ Environment Variables"));
|
|
1212
|
+
console.log();
|
|
1213
|
+
const envPath = getEnvFilePath();
|
|
1214
|
+
if (!envPath) {
|
|
1215
|
+
console.log(chalk.red(" No .env file found."));
|
|
1216
|
+
console.log(chalk.gray(" Create a .env file in the project root or packages/backend/.\n"));
|
|
1217
|
+
return;
|
|
1218
|
+
}
|
|
1219
|
+
console.log(chalk.gray(` Reading from: ${envPath}`));
|
|
1220
|
+
console.log();
|
|
1221
|
+
const envVars = loadEnvFile();
|
|
1222
|
+
const installed = migrateInstalledJson(loadInstalled());
|
|
1223
|
+
const installedNames = getInstalledNames(installed);
|
|
1224
|
+
// Define required and optional vars
|
|
1225
|
+
const required = ["DATABASE_URL", "BETTER_AUTH_SECRET"];
|
|
1226
|
+
const optional = [
|
|
1227
|
+
"RESEND_API_KEY", "EMAIL_FROM", "PORT", "NODE_ENV",
|
|
1228
|
+
"FRONTEND_URL", "BACKEND_URL",
|
|
1229
|
+
];
|
|
1230
|
+
// Add sail-specific required vars
|
|
1231
|
+
if (installedNames.includes("gdpr")) {
|
|
1232
|
+
required.push("DELETION_CRON_SECRET");
|
|
1233
|
+
}
|
|
1234
|
+
if (installedNames.includes("r2-storage")) {
|
|
1235
|
+
required.push("R2_ACCOUNT_ID", "R2_ACCESS_KEY_ID", "R2_SECRET_ACCESS_KEY", "R2_BUCKET_NAME", "R2_PUBLIC_URL");
|
|
1236
|
+
}
|
|
1237
|
+
// Add env vars from installed sails
|
|
1238
|
+
for (const sailName of installedNames) {
|
|
1239
|
+
const manifest = loadSailManifest(sailName);
|
|
1240
|
+
if (manifest) {
|
|
1241
|
+
for (const envVar of manifest.requiredEnvVars) {
|
|
1242
|
+
if (!required.includes(envVar.key) && !optional.includes(envVar.key)) {
|
|
1243
|
+
required.push(envVar.key);
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
// Calculate column width
|
|
1249
|
+
const allVars = [...required, ...optional];
|
|
1250
|
+
const maxLen = Math.max(...allVars.map((v) => v.length));
|
|
1251
|
+
// Print required vars
|
|
1252
|
+
console.log(chalk.bold(" Required:"));
|
|
1253
|
+
for (const key of required) {
|
|
1254
|
+
const value = envVars[key];
|
|
1255
|
+
const paddedKey = key.padEnd(maxLen + 2);
|
|
1256
|
+
if (value) {
|
|
1257
|
+
const preview = value.length > 10 ? value.slice(0, 10) + "..." : value;
|
|
1258
|
+
console.log(` ${paddedKey} ${chalk.green("set")} ${chalk.gray(preview)}`);
|
|
1259
|
+
}
|
|
1260
|
+
else {
|
|
1261
|
+
console.log(` ${paddedKey} ${chalk.red("missing")}`);
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
console.log();
|
|
1265
|
+
// Print optional vars
|
|
1266
|
+
console.log(chalk.bold(" Optional:"));
|
|
1267
|
+
for (const key of optional) {
|
|
1268
|
+
const value = envVars[key];
|
|
1269
|
+
const paddedKey = key.padEnd(maxLen + 2);
|
|
1270
|
+
if (value) {
|
|
1271
|
+
const preview = value.length > 10 ? value.slice(0, 10) + "..." : value;
|
|
1272
|
+
console.log(` ${paddedKey} ${chalk.green("set")} ${chalk.gray(preview)}`);
|
|
1273
|
+
}
|
|
1274
|
+
else {
|
|
1275
|
+
console.log(` ${paddedKey} ${chalk.yellow("not set")}`);
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
1278
|
+
console.log();
|
|
1279
|
+
}
|
|
1280
|
+
// ---------------------------------------------------------------------------
|
|
1281
|
+
// Usage
|
|
1282
|
+
// ---------------------------------------------------------------------------
|
|
1283
|
+
function printUsage() {
|
|
1284
|
+
console.log();
|
|
1285
|
+
console.log(chalk.blue(" KEEL"));
|
|
1286
|
+
console.log(chalk.bold(" keel") + chalk.gray(" — project management & sail tool (a codai project)"));
|
|
1287
|
+
console.log();
|
|
1288
|
+
console.log(chalk.bold(" Project:"));
|
|
1289
|
+
console.log(` ${chalk.cyan("keel create <name>")} Create a new keel project`);
|
|
1290
|
+
console.log(` ${chalk.cyan("keel dev")} Start dev (database + migrations + servers)`);
|
|
1291
|
+
console.log(` ${chalk.cyan("keel start")} Start production (build + serve)`);
|
|
1292
|
+
console.log(` ${chalk.cyan("keel doctor")} Run project health checks`);
|
|
1293
|
+
console.log(` ${chalk.cyan("keel env")} Check environment variables`);
|
|
1294
|
+
console.log(` ${chalk.cyan("keel deploy")} Show deployment guides`);
|
|
1295
|
+
console.log(` ${chalk.cyan("keel upgrade")} Check for CLI updates`);
|
|
1296
|
+
console.log();
|
|
1297
|
+
console.log(chalk.bold(" Sails:"));
|
|
1298
|
+
console.log(` ${chalk.cyan("keel sail add <name>")} Install a sail`);
|
|
1299
|
+
console.log(` ${chalk.cyan("keel sail remove <name>")} Remove a sail`);
|
|
1300
|
+
console.log(` ${chalk.cyan("keel sail update [name]")} Check for sail updates`);
|
|
1301
|
+
console.log(` ${chalk.cyan("keel list")} List available sails`);
|
|
1302
|
+
console.log(` ${chalk.cyan("keel info <name>")} Show sail details`);
|
|
1303
|
+
console.log();
|
|
1304
|
+
console.log(chalk.bold(" Generators:"));
|
|
1305
|
+
console.log(` ${chalk.cyan("keel generate route <name>")} Scaffold an API route`);
|
|
1306
|
+
console.log(` ${chalk.cyan("keel generate page <name>")} Scaffold a React page`);
|
|
1307
|
+
console.log(` ${chalk.cyan("keel generate email <name>")} Scaffold an email template`);
|
|
1308
|
+
console.log();
|
|
1309
|
+
console.log(chalk.bold(" Database:"));
|
|
1310
|
+
console.log(` ${chalk.cyan("keel db:reset")} Drop & recreate schema + migrate`);
|
|
1311
|
+
console.log(` ${chalk.cyan("keel db:studio")} Open Drizzle Studio`);
|
|
1312
|
+
console.log(` ${chalk.cyan("keel db:seed")} Run seed file`);
|
|
1313
|
+
console.log();
|
|
1314
|
+
console.log(" Examples:");
|
|
1315
|
+
console.log(chalk.gray(" npx @codaijs/keel create my-app"));
|
|
1316
|
+
console.log(chalk.gray(" npx @codaijs/keel dev"));
|
|
1317
|
+
console.log(chalk.gray(" npx @codaijs/keel sail add google-oauth"));
|
|
1318
|
+
console.log(chalk.gray(" npx @codaijs/keel generate route users"));
|
|
1319
|
+
console.log(chalk.gray(" npx @codaijs/keel doctor"));
|
|
1320
|
+
console.log();
|
|
1321
|
+
}
|
|
1322
|
+
// ---------------------------------------------------------------------------
|
|
1323
|
+
// Main
|
|
1324
|
+
// ---------------------------------------------------------------------------
|
|
1325
|
+
async function main() {
|
|
1326
|
+
const args = process.argv.slice(2);
|
|
1327
|
+
const command = args[0];
|
|
1328
|
+
const target = args[1];
|
|
1329
|
+
switch (command) {
|
|
1330
|
+
// -- Sail commands --
|
|
1331
|
+
case "sail": {
|
|
1332
|
+
const subcommand = args[1];
|
|
1333
|
+
const sailTarget = args[2];
|
|
1334
|
+
if (subcommand === "add") {
|
|
1335
|
+
if (!sailTarget) {
|
|
1336
|
+
console.error(chalk.red("\n Error: Please specify a sail name."));
|
|
1337
|
+
console.error(chalk.gray(" Usage: keel sail add <sail-name>\n"));
|
|
1338
|
+
process.exit(1);
|
|
1339
|
+
}
|
|
1340
|
+
await commandAdd(sailTarget);
|
|
1341
|
+
}
|
|
1342
|
+
else if (subcommand === "remove") {
|
|
1343
|
+
if (!sailTarget) {
|
|
1344
|
+
console.error(chalk.red("\n Error: Please specify a sail name."));
|
|
1345
|
+
console.error(chalk.gray(" Usage: keel sail remove <sail-name>\n"));
|
|
1346
|
+
process.exit(1);
|
|
1347
|
+
}
|
|
1348
|
+
await commandRemove(sailTarget);
|
|
1349
|
+
}
|
|
1350
|
+
else if (subcommand === "update") {
|
|
1351
|
+
await commandSailUpdate(sailTarget);
|
|
1352
|
+
}
|
|
1353
|
+
else {
|
|
1354
|
+
printUsage();
|
|
1355
|
+
}
|
|
1356
|
+
break;
|
|
1357
|
+
}
|
|
1358
|
+
case "add":
|
|
1359
|
+
if (!target) {
|
|
1360
|
+
console.error(chalk.red("\n Error: Please specify a sail name."));
|
|
1361
|
+
console.error(chalk.gray(" Usage: keel sail add <sail-name>\n"));
|
|
1362
|
+
process.exit(1);
|
|
1363
|
+
}
|
|
1364
|
+
await commandAdd(target);
|
|
1365
|
+
break;
|
|
1366
|
+
case "remove":
|
|
1367
|
+
if (!target) {
|
|
1368
|
+
console.error(chalk.red("\n Error: Please specify a sail name."));
|
|
1369
|
+
console.error(chalk.gray(" Usage: keel sail remove <sail-name>\n"));
|
|
1370
|
+
process.exit(1);
|
|
1371
|
+
}
|
|
1372
|
+
await commandRemove(target);
|
|
1373
|
+
break;
|
|
1374
|
+
case "list":
|
|
1375
|
+
commandList();
|
|
1376
|
+
break;
|
|
1377
|
+
case "info":
|
|
1378
|
+
if (!target) {
|
|
1379
|
+
console.error(chalk.red("\n Error: Please specify a sail name."));
|
|
1380
|
+
console.error(chalk.gray(" Usage: keel info <sail-name>\n"));
|
|
1381
|
+
process.exit(1);
|
|
1382
|
+
}
|
|
1383
|
+
commandInfo(target);
|
|
1384
|
+
break;
|
|
1385
|
+
// -- Dev & Start --
|
|
1386
|
+
case "dev":
|
|
1387
|
+
await commandDev();
|
|
1388
|
+
break;
|
|
1389
|
+
case "start":
|
|
1390
|
+
await commandStart();
|
|
1391
|
+
break;
|
|
1392
|
+
// -- Create --
|
|
1393
|
+
case "create": {
|
|
1394
|
+
const { main: createMain } = await import("./create-runner.js");
|
|
1395
|
+
await createMain(args.slice(1));
|
|
1396
|
+
break;
|
|
1397
|
+
}
|
|
1398
|
+
// -- Doctor --
|
|
1399
|
+
case "doctor":
|
|
1400
|
+
await commandDoctor();
|
|
1401
|
+
break;
|
|
1402
|
+
// -- Generators --
|
|
1403
|
+
case "generate":
|
|
1404
|
+
case "g": {
|
|
1405
|
+
const genType = args[1];
|
|
1406
|
+
const genName = args[2];
|
|
1407
|
+
if (!genType || !genName) {
|
|
1408
|
+
console.error(chalk.red("\n Error: Please specify a type and name."));
|
|
1409
|
+
console.error(chalk.gray(" Usage: keel generate <route|page|email> <name>\n"));
|
|
1410
|
+
process.exit(1);
|
|
1411
|
+
}
|
|
1412
|
+
switch (genType) {
|
|
1413
|
+
case "route":
|
|
1414
|
+
commandGenerateRoute(genName);
|
|
1415
|
+
break;
|
|
1416
|
+
case "page":
|
|
1417
|
+
commandGeneratePage(genName);
|
|
1418
|
+
break;
|
|
1419
|
+
case "email":
|
|
1420
|
+
commandGenerateEmail(genName);
|
|
1421
|
+
break;
|
|
1422
|
+
default:
|
|
1423
|
+
console.error(chalk.red(`\n Error: Unknown generator "${genType}".`));
|
|
1424
|
+
console.error(chalk.gray(" Available: route, page, email\n"));
|
|
1425
|
+
process.exit(1);
|
|
1426
|
+
}
|
|
1427
|
+
break;
|
|
1428
|
+
}
|
|
1429
|
+
// -- Database --
|
|
1430
|
+
case "db:reset":
|
|
1431
|
+
await commandDbReset();
|
|
1432
|
+
break;
|
|
1433
|
+
case "db:studio":
|
|
1434
|
+
commandDbStudio();
|
|
1435
|
+
break;
|
|
1436
|
+
case "db:seed":
|
|
1437
|
+
commandDbSeed();
|
|
1438
|
+
break;
|
|
1439
|
+
// -- Env --
|
|
1440
|
+
case "env":
|
|
1441
|
+
commandEnv();
|
|
1442
|
+
break;
|
|
1443
|
+
// -- Deploy --
|
|
1444
|
+
case "deploy":
|
|
1445
|
+
commandDeploy();
|
|
1446
|
+
break;
|
|
1447
|
+
// -- Upgrade --
|
|
1448
|
+
case "upgrade":
|
|
1449
|
+
await commandUpgrade();
|
|
1450
|
+
break;
|
|
1451
|
+
// -- Help / Default --
|
|
1452
|
+
default:
|
|
1453
|
+
printUsage();
|
|
1454
|
+
break;
|
|
1455
|
+
}
|
|
1456
|
+
}
|
|
1457
|
+
main().catch((err) => {
|
|
1458
|
+
console.error(chalk.red("Unexpected error:"), err);
|
|
1459
|
+
process.exit(1);
|
|
1460
|
+
});
|
|
1461
|
+
//# sourceMappingURL=manage.js.map
|