@farm.js/cli 0.1.0-beta.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/LICENSE +22 -0
- package/README.md +11 -0
- package/bin/farm.js +394 -0
- package/dist/add-integration-CdVfiPjG.mjs +2528 -0
- package/dist/add-integration-CdVfiPjG.mjs.map +1 -0
- package/dist/add-integration-pp16Zr0U.js +2568 -0
- package/dist/add-integration-pp16Zr0U.js.map +1 -0
- package/dist/add-integration.js +4 -0
- package/dist/add-integration.mjs +2 -0
- package/dist/build.js +47 -0
- package/dist/build.js.map +1 -0
- package/dist/build.mjs +45 -0
- package/dist/build.mjs.map +1 -0
- package/dist/dev.js +10 -0
- package/dist/dev.js.map +1 -0
- package/dist/dev.mjs +9 -0
- package/dist/dev.mjs.map +1 -0
- package/dist/index.js +2469 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +2434 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +49 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,2469 @@
|
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
const require_add_integration = require("./add-integration-pp16Zr0U.js");
|
|
3
|
+
const require_build = require("./build.js");
|
|
4
|
+
let _farm_js_core_server = require("@farm.js/core/server");
|
|
5
|
+
let node_fs = require("node:fs");
|
|
6
|
+
let node_fs_promises = require("node:fs/promises");
|
|
7
|
+
let node_path = require("node:path");
|
|
8
|
+
node_path = require_add_integration.__toESM(node_path);
|
|
9
|
+
let child_process = require("child_process");
|
|
10
|
+
let fs = require("fs");
|
|
11
|
+
let path = require("path");
|
|
12
|
+
path = require_add_integration.__toESM(path);
|
|
13
|
+
let _farm_js_core = require("@farm.js/core");
|
|
14
|
+
let node_child_process = require("node:child_process");
|
|
15
|
+
let node_timers_promises = require("node:timers/promises");
|
|
16
|
+
let picocolors = require("picocolors");
|
|
17
|
+
picocolors = require_add_integration.__toESM(picocolors);
|
|
18
|
+
let croner = require("croner");
|
|
19
|
+
//#region src/deploy.ts
|
|
20
|
+
/**
|
|
21
|
+
* Deploy Farm.js application
|
|
22
|
+
*/
|
|
23
|
+
async function deployFarm(options = {}) {
|
|
24
|
+
const root = options.root || process.cwd();
|
|
25
|
+
const mode = "production";
|
|
26
|
+
const userConfig = await (0, _farm_js_core.loadConfig)(root, void 0, mode);
|
|
27
|
+
const config = userConfig ? await (0, _farm_js_core.resolveConfig)(userConfig, mode) : void 0;
|
|
28
|
+
const cliTarget = options.vercel ? "vercel" : options.cloudflare ? "cloudflare" : options.netlify ? "netlify" : void 0;
|
|
29
|
+
const platform = (0, _farm_js_core.normalizeDeployTarget)(cliTarget || config?.deploy.target);
|
|
30
|
+
if (platform !== "vercel" && platform !== "cloudflare" && platform !== "netlify") {
|
|
31
|
+
_farm_js_core.logger.error("Please specify a deployment target with --vercel, --cloudflare, --netlify, or farm.config deploy.target.");
|
|
32
|
+
process.exit(1);
|
|
33
|
+
}
|
|
34
|
+
const deployConfig = (0, _farm_js_core.resolveDeployConfig)(userConfig || {}, {
|
|
35
|
+
target: platform,
|
|
36
|
+
preset: cliTarget ? userConfig?.deploy?.preset || userConfig?.preset || (0, _farm_js_core.getPresetForDeployTarget)(platform) : void 0
|
|
37
|
+
});
|
|
38
|
+
const preset = deployConfig.preset || (0, _farm_js_core.getPresetForDeployTarget)(platform) || "node-server";
|
|
39
|
+
_farm_js_core.logger.info(`🚀 Building with ${preset} preset...`);
|
|
40
|
+
await require_build.buildFarm({
|
|
41
|
+
root,
|
|
42
|
+
preset
|
|
43
|
+
});
|
|
44
|
+
const nitroOutput = (0, _farm_js_core.resolveDeployOutputPath)(root, deployConfig.outputDir);
|
|
45
|
+
if (!(0, fs.existsSync)(nitroOutput)) {
|
|
46
|
+
_farm_js_core.logger.error(`Build output not found at ${nitroOutput}. Please run 'farm build' first.`);
|
|
47
|
+
process.exit(1);
|
|
48
|
+
}
|
|
49
|
+
await deployPlatform(platform, root, nitroOutput, deployConfig, options.prod);
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Deploy using platform's native CLI (user credentials)
|
|
53
|
+
*/
|
|
54
|
+
async function deployPlatform(platform, root, outputDir, deployConfig, prod) {
|
|
55
|
+
switch (platform) {
|
|
56
|
+
case "vercel":
|
|
57
|
+
await deployVercel(root, outputDir, prod);
|
|
58
|
+
break;
|
|
59
|
+
case "cloudflare":
|
|
60
|
+
await deployCloudflare(root, outputDir, deployConfig.cloudflare?.projectName || deployConfig.projectName);
|
|
61
|
+
break;
|
|
62
|
+
case "netlify":
|
|
63
|
+
await deployNetlify(root, outputDir, deployConfig.netlify?.site);
|
|
64
|
+
break;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Deploy to Vercel using Vercel CLI
|
|
69
|
+
*/
|
|
70
|
+
async function deployVercel(root, outputDir, prod) {
|
|
71
|
+
_farm_js_core.logger.info("🚀 Deploying to Vercel...");
|
|
72
|
+
try {
|
|
73
|
+
(0, child_process.execSync)("vercel --version", { stdio: "ignore" });
|
|
74
|
+
} catch {
|
|
75
|
+
_farm_js_core.logger.error("❌ Vercel CLI is not installed.");
|
|
76
|
+
_farm_js_core.logger.info("💡 Install it with: npm i -g vercel");
|
|
77
|
+
process.exit(1);
|
|
78
|
+
}
|
|
79
|
+
try {
|
|
80
|
+
(0, child_process.execSync)("vercel whoami", { stdio: "ignore" });
|
|
81
|
+
} catch {
|
|
82
|
+
_farm_js_core.logger.warn("⚠️ Not logged in to Vercel.");
|
|
83
|
+
_farm_js_core.logger.info("💡 Please run: vercel login");
|
|
84
|
+
_farm_js_core.logger.info(" Then run: farm deploy --vercel");
|
|
85
|
+
process.exit(1);
|
|
86
|
+
}
|
|
87
|
+
try {
|
|
88
|
+
const { existsSync, statSync, readdirSync } = await import("fs");
|
|
89
|
+
const functionsDir = path.default.join(outputDir, "functions", "__nitro.func");
|
|
90
|
+
const staticDir = path.default.join(outputDir, "static");
|
|
91
|
+
const configFile = path.default.join(outputDir, "config.json");
|
|
92
|
+
const serverIndex = path.default.join(functionsDir, "index.mjs");
|
|
93
|
+
_farm_js_core.logger.info("🔍 Verifying deployment structure...");
|
|
94
|
+
if (!existsSync(functionsDir)) {
|
|
95
|
+
_farm_js_core.logger.error(`❌ Functions directory not found at ${functionsDir}`);
|
|
96
|
+
process.exit(1);
|
|
97
|
+
}
|
|
98
|
+
if (!existsSync(serverIndex)) {
|
|
99
|
+
_farm_js_core.logger.error(`❌ Server entry point not found at ${serverIndex}`);
|
|
100
|
+
process.exit(1);
|
|
101
|
+
}
|
|
102
|
+
_farm_js_core.logger.info(`✅ Functions directory: ${functionsDir}`);
|
|
103
|
+
if (!existsSync(staticDir)) _farm_js_core.logger.warn(`⚠️ Static directory not found at ${staticDir}`);
|
|
104
|
+
else {
|
|
105
|
+
const countFiles = (dir) => {
|
|
106
|
+
let count = 0;
|
|
107
|
+
try {
|
|
108
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
109
|
+
for (const entry of entries) {
|
|
110
|
+
const fullPath = path.default.join(dir, entry.name);
|
|
111
|
+
if (entry.isDirectory()) count += countFiles(fullPath);
|
|
112
|
+
else count++;
|
|
113
|
+
}
|
|
114
|
+
} catch {}
|
|
115
|
+
return count;
|
|
116
|
+
};
|
|
117
|
+
const fileCount = countFiles(staticDir);
|
|
118
|
+
_farm_js_core.logger.info(`✅ Static directory: ${staticDir} (${fileCount} files)`);
|
|
119
|
+
for (const file of [
|
|
120
|
+
"farm-client-manifest.json",
|
|
121
|
+
"farm-client.js",
|
|
122
|
+
"assets"
|
|
123
|
+
]) if (existsSync(path.default.join(staticDir, file))) _farm_js_core.logger.info(` ✓ ${file}`);
|
|
124
|
+
else _farm_js_core.logger.warn(` ✗ ${file} (missing)`);
|
|
125
|
+
if (fileCount === 0) _farm_js_core.logger.warn("⚠️ Static directory is empty - static assets may not be served");
|
|
126
|
+
}
|
|
127
|
+
if (!existsSync(configFile)) _farm_js_core.logger.warn(`⚠️ Config file not found at ${configFile}`);
|
|
128
|
+
else {
|
|
129
|
+
_farm_js_core.logger.info(`✅ Config file: ${configFile}`);
|
|
130
|
+
try {
|
|
131
|
+
const config = JSON.parse(require("fs").readFileSync(configFile, "utf-8"));
|
|
132
|
+
if (config.routes && config.routes.length > 0) _farm_js_core.logger.info(` ✓ Routing rules: ${config.routes.length} rules configured`);
|
|
133
|
+
} catch {
|
|
134
|
+
_farm_js_core.logger.warn(" ⚠️ Could not parse config.json");
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
const calculateDirSize = (dirPath) => {
|
|
138
|
+
if (!existsSync(dirPath)) return 0;
|
|
139
|
+
let totalSize = 0;
|
|
140
|
+
try {
|
|
141
|
+
const entries = readdirSync(dirPath, { withFileTypes: true });
|
|
142
|
+
for (const entry of entries) {
|
|
143
|
+
const fullPath = path.default.join(dirPath, entry.name);
|
|
144
|
+
if (entry.isFile()) totalSize += statSync(fullPath).size;
|
|
145
|
+
else if (entry.isDirectory()) totalSize += calculateDirSize(fullPath);
|
|
146
|
+
}
|
|
147
|
+
} catch {}
|
|
148
|
+
return totalSize;
|
|
149
|
+
};
|
|
150
|
+
const functionsSize = calculateDirSize(functionsDir);
|
|
151
|
+
const staticSize = calculateDirSize(staticDir);
|
|
152
|
+
_farm_js_core.logger.info(`📦 Deployment size: Functions ${(functionsSize / 1024 / 1024).toFixed(2)}MB, Static ${(staticSize / 1024).toFixed(1)}KB`);
|
|
153
|
+
_farm_js_core.logger.info("🔍 Verifying critical files:");
|
|
154
|
+
const criticalFiles = [
|
|
155
|
+
{
|
|
156
|
+
path: serverIndex,
|
|
157
|
+
name: "Server entry point"
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
path: path.default.join(functionsDir, "package.json"),
|
|
161
|
+
name: "Function package.json"
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
path: path.default.join(functionsDir, ".vc-config.json"),
|
|
165
|
+
name: "Vercel config"
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
path: path.default.join(staticDir, "farm-client-manifest.json"),
|
|
169
|
+
name: "Client manifest"
|
|
170
|
+
}
|
|
171
|
+
];
|
|
172
|
+
for (const file of criticalFiles) if (existsSync(file.path)) _farm_js_core.logger.info(` ✅ ${file.name}`);
|
|
173
|
+
else _farm_js_core.logger.warn(` ⚠️ ${file.name} (missing)`);
|
|
174
|
+
if (existsSync(path.default.join(staticDir, "farm-client.js")) || existsSync(path.default.join(staticDir, "assets"))) _farm_js_core.logger.info(" ✅ Client assets (farm-client.js or assets/)");
|
|
175
|
+
else _farm_js_core.logger.warn(" ⚠️ Client assets directory (missing)");
|
|
176
|
+
_farm_js_core.logger.info("🚀 Deploying to Vercel...");
|
|
177
|
+
_farm_js_core.logger.info(` Deploying from: ${root}`);
|
|
178
|
+
_farm_js_core.logger.info(` Functions: ${functionsDir}`);
|
|
179
|
+
_farm_js_core.logger.info(` Static: ${staticDir}`);
|
|
180
|
+
_farm_js_core.logger.info(` Config: ${configFile}`);
|
|
181
|
+
_farm_js_core.logger.info("📤 Uploading to Vercel...");
|
|
182
|
+
(0, child_process.execSync)(`vercel deploy --prebuilt --yes${prod ? " --prod" : ""}`, {
|
|
183
|
+
stdio: "inherit",
|
|
184
|
+
cwd: root
|
|
185
|
+
});
|
|
186
|
+
_farm_js_core.logger.success("✅ Deployed to Vercel successfully!");
|
|
187
|
+
} catch (error) {
|
|
188
|
+
_farm_js_core.logger.error(`❌ Failed to deploy to Vercel: ${error.message}`);
|
|
189
|
+
process.exit(1);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
/** Deploy to a composed Worker or fall back to Cloudflare Pages. */
|
|
193
|
+
async function deployCloudflare(root, outputDir, projectName) {
|
|
194
|
+
const agentPlan = resolveCloudflareAgentDeployPlan(root);
|
|
195
|
+
if (agentPlan) {
|
|
196
|
+
_farm_js_core.logger.info("🚀 Deploying Farm and Cloudflare Agents as one Worker...");
|
|
197
|
+
assertWranglerInstalled(root);
|
|
198
|
+
try {
|
|
199
|
+
(0, child_process.execFileSync)("wrangler", [
|
|
200
|
+
"deploy",
|
|
201
|
+
"--config",
|
|
202
|
+
agentPlan.configPath,
|
|
203
|
+
...agentPlan.environment ? ["--env", agentPlan.environment] : []
|
|
204
|
+
], {
|
|
205
|
+
stdio: "inherit",
|
|
206
|
+
cwd: root
|
|
207
|
+
});
|
|
208
|
+
_farm_js_core.logger.success("✅ Deployed Farm and Cloudflare Agents successfully!");
|
|
209
|
+
} catch (error) {
|
|
210
|
+
_farm_js_core.logger.error(`❌ Failed to deploy to Cloudflare: ${error.message}`);
|
|
211
|
+
process.exit(1);
|
|
212
|
+
}
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
_farm_js_core.logger.info("🚀 Deploying to Cloudflare Pages...");
|
|
216
|
+
assertWranglerInstalled(root);
|
|
217
|
+
try {
|
|
218
|
+
(0, child_process.execFileSync)("wrangler", [
|
|
219
|
+
"pages",
|
|
220
|
+
"deploy",
|
|
221
|
+
".",
|
|
222
|
+
`--project-name=${projectName || "farm-app"}`
|
|
223
|
+
], {
|
|
224
|
+
stdio: "inherit",
|
|
225
|
+
cwd: outputDir
|
|
226
|
+
});
|
|
227
|
+
_farm_js_core.logger.success("✅ Deployed to Cloudflare Pages successfully!");
|
|
228
|
+
} catch (error) {
|
|
229
|
+
_farm_js_core.logger.error(`❌ Failed to deploy to Cloudflare: ${error.message}`);
|
|
230
|
+
process.exit(1);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
/** Read the trusted Workers deployment handoff emitted by @farm.js/cf-agent. */
|
|
234
|
+
function resolveCloudflareAgentDeployPlan(root) {
|
|
235
|
+
const projectRoot = path.default.resolve(root);
|
|
236
|
+
const metadataPath = path.default.join(projectRoot, ".farm", "cf-agent", "deploy.json");
|
|
237
|
+
if (!(0, fs.existsSync)(metadataPath)) return void 0;
|
|
238
|
+
let metadata;
|
|
239
|
+
try {
|
|
240
|
+
metadata = JSON.parse((0, fs.readFileSync)(metadataPath, "utf8"));
|
|
241
|
+
} catch {
|
|
242
|
+
throw new Error(`Invalid Cloudflare Agents deployment metadata at ${metadataPath}.`);
|
|
243
|
+
}
|
|
244
|
+
if (!isRecord(metadata) || metadata.version !== 1 || metadata.provider !== "cloudflare-agents" || typeof metadata.config !== "string" || !metadata.config.trim()) throw new Error(`Invalid Cloudflare Agents deployment metadata at ${metadataPath}.`);
|
|
245
|
+
const configPath = path.default.resolve(projectRoot, metadata.config);
|
|
246
|
+
const relativeConfigPath = path.default.relative(projectRoot, configPath);
|
|
247
|
+
if (relativeConfigPath === ".." || relativeConfigPath.startsWith(`..${path.default.sep}`) || path.default.isAbsolute(relativeConfigPath)) throw new Error("Cloudflare Agents deployment config must stay inside the Farm project root.");
|
|
248
|
+
if (!(0, fs.existsSync)(configPath)) throw new Error(`Cloudflare Agents deployment config was not found at ${configPath}.`);
|
|
249
|
+
const environment = metadata.environment;
|
|
250
|
+
if (environment !== void 0 && (typeof environment !== "string" || !environment.trim())) throw new Error("Cloudflare Agents deployment environment must be a non-empty string.");
|
|
251
|
+
return {
|
|
252
|
+
configPath,
|
|
253
|
+
...typeof environment === "string" ? { environment: environment.trim() } : {}
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
function assertWranglerInstalled(root) {
|
|
257
|
+
try {
|
|
258
|
+
(0, child_process.execFileSync)("wrangler", ["--version"], {
|
|
259
|
+
stdio: "ignore",
|
|
260
|
+
cwd: root
|
|
261
|
+
});
|
|
262
|
+
} catch {
|
|
263
|
+
_farm_js_core.logger.error("❌ Wrangler CLI is not installed.");
|
|
264
|
+
_farm_js_core.logger.info("💡 Install it in this project with: npm i -D wrangler");
|
|
265
|
+
process.exit(1);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
function isRecord(value) {
|
|
269
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* Deploy to Netlify using Netlify CLI
|
|
273
|
+
*/
|
|
274
|
+
async function deployNetlify(root, outputDir, site) {
|
|
275
|
+
_farm_js_core.logger.info("🚀 Deploying to Netlify...");
|
|
276
|
+
try {
|
|
277
|
+
(0, child_process.execSync)("netlify --version", { stdio: "ignore" });
|
|
278
|
+
} catch {
|
|
279
|
+
_farm_js_core.logger.error("❌ Netlify CLI is not installed.");
|
|
280
|
+
_farm_js_core.logger.info("💡 Install it with: npm i -g netlify-cli");
|
|
281
|
+
process.exit(1);
|
|
282
|
+
}
|
|
283
|
+
try {
|
|
284
|
+
process.chdir(outputDir);
|
|
285
|
+
(0, child_process.execSync)(`netlify deploy --prod --dir=.${site ? ` --site=${site}` : ""}`, { stdio: "inherit" });
|
|
286
|
+
_farm_js_core.logger.success("✅ Deployed to Netlify successfully!");
|
|
287
|
+
} catch (error) {
|
|
288
|
+
_farm_js_core.logger.error(`❌ Failed to deploy to Netlify: ${error.message}`);
|
|
289
|
+
process.exit(1);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
//#endregion
|
|
293
|
+
//#region src/preview-gateway.ts
|
|
294
|
+
const DEFAULT_GATEWAY_URL = "https://preview.farming-labs.dev";
|
|
295
|
+
const DEFAULT_PREVIEW_DOMAIN = "preview.farming-labs.dev";
|
|
296
|
+
const DEFAULT_POLL_TIMEOUT_MS = 15e3;
|
|
297
|
+
const DEFAULT_LOCAL_PROBE_INTERVAL_MS = 2e3;
|
|
298
|
+
const DEFAULT_LOCAL_PROBE_TIMEOUT_MS = 1e3;
|
|
299
|
+
const DEFAULT_MAX_CONCURRENT_REQUESTS = 25;
|
|
300
|
+
const HOP_BY_HOP_HEADERS = /* @__PURE__ */ new Set([
|
|
301
|
+
"connection",
|
|
302
|
+
"content-length",
|
|
303
|
+
"keep-alive",
|
|
304
|
+
"proxy-authenticate",
|
|
305
|
+
"proxy-authorization",
|
|
306
|
+
"te",
|
|
307
|
+
"trailer",
|
|
308
|
+
"transfer-encoding",
|
|
309
|
+
"upgrade"
|
|
310
|
+
]);
|
|
311
|
+
function createPreviewGatewayPlan(target, options = {}) {
|
|
312
|
+
const requestedName = sanitizePreviewName$1(options.name || process.env.FARM_PREVIEW_NAME) || randomPreviewName$1();
|
|
313
|
+
const gatewayUrl = normalizeGatewayUrl(options.gatewayUrl || process.env.FARM_PREVIEW_GATEWAY_URL || DEFAULT_GATEWAY_URL);
|
|
314
|
+
const requestedHostname = `${requestedName}.${normalizePreviewDomain$1(process.env.FARM_PREVIEW_DOMAIN || DEFAULT_PREVIEW_DOMAIN)}`;
|
|
315
|
+
return {
|
|
316
|
+
provider: "farm-gateway",
|
|
317
|
+
gatewayUrl,
|
|
318
|
+
target,
|
|
319
|
+
requestedName,
|
|
320
|
+
requestedHostname,
|
|
321
|
+
requestedPublicUrl: `https://${requestedHostname}`
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
async function runPreviewGateway(plan, options = {}) {
|
|
325
|
+
const session = await createGatewaySession(plan, options.timeoutMs ?? 3e4);
|
|
326
|
+
const controller = new AbortController();
|
|
327
|
+
let handledRequests = 0;
|
|
328
|
+
const inFlightRequests = /* @__PURE__ */ new Set();
|
|
329
|
+
const maxConcurrentRequests = Math.max(1, options.maxConcurrentRequests ?? DEFAULT_MAX_CONCURRENT_REQUESTS);
|
|
330
|
+
const localTargetWatch = watchLocalPreviewTarget(plan.target, controller, {
|
|
331
|
+
intervalMs: options.localProbeIntervalMs ?? DEFAULT_LOCAL_PROBE_INTERVAL_MS,
|
|
332
|
+
timeoutMs: options.localProbeTimeoutMs ?? DEFAULT_LOCAL_PROBE_TIMEOUT_MS
|
|
333
|
+
});
|
|
334
|
+
const cleanup = () => controller.abort();
|
|
335
|
+
process.once("SIGINT", cleanup);
|
|
336
|
+
process.once("SIGTERM", cleanup);
|
|
337
|
+
_farm_js_core.logger.success("Preview URL ready.");
|
|
338
|
+
_farm_js_core.logger.info(`Public: ${session.publicUrl}`);
|
|
339
|
+
_farm_js_core.logger.info("Forwarding requests until Ctrl+C. Remote traffic will be logged below.");
|
|
340
|
+
const waitForAvailableRequestSlot = async () => {
|
|
341
|
+
while (!controller.signal.aborted && inFlightRequests.size >= maxConcurrentRequests) await Promise.race(inFlightRequests);
|
|
342
|
+
};
|
|
343
|
+
const handleRequest = async (request) => {
|
|
344
|
+
const startedAt = Date.now();
|
|
345
|
+
try {
|
|
346
|
+
const response = await forwardGatewayRequest(plan.target, request);
|
|
347
|
+
await sendGatewayResponse(plan, session, request.id, response, controller.signal);
|
|
348
|
+
handledRequests += 1;
|
|
349
|
+
_farm_js_core.logger.info(`${request.method.toUpperCase()} ${formatRequestPath(request.path)} -> ${response.status} ${Date.now() - startedAt}ms`);
|
|
350
|
+
} catch (error) {
|
|
351
|
+
if (controller.signal.aborted) return;
|
|
352
|
+
_farm_js_core.logger.warn(`Local preview target ${plan.target.localUrl} is no longer reachable. Closing preview session.`);
|
|
353
|
+
controller.abort(error);
|
|
354
|
+
}
|
|
355
|
+
};
|
|
356
|
+
try {
|
|
357
|
+
while (!controller.signal.aborted) {
|
|
358
|
+
await waitForAvailableRequestSlot();
|
|
359
|
+
if (controller.signal.aborted) break;
|
|
360
|
+
const requests = await pollGatewayRequests(plan, session, {
|
|
361
|
+
signal: controller.signal,
|
|
362
|
+
pollTimeoutMs: options.pollTimeoutMs ?? DEFAULT_POLL_TIMEOUT_MS
|
|
363
|
+
});
|
|
364
|
+
for (const request of requests) {
|
|
365
|
+
await waitForAvailableRequestSlot();
|
|
366
|
+
if (controller.signal.aborted) break;
|
|
367
|
+
const promise = handleRequest(request);
|
|
368
|
+
inFlightRequests.add(promise);
|
|
369
|
+
promise.finally(() => inFlightRequests.delete(promise));
|
|
370
|
+
}
|
|
371
|
+
if (options.maxRequests && handledRequests >= options.maxRequests) break;
|
|
372
|
+
}
|
|
373
|
+
} finally {
|
|
374
|
+
process.removeListener("SIGINT", cleanup);
|
|
375
|
+
process.removeListener("SIGTERM", cleanup);
|
|
376
|
+
controller.abort();
|
|
377
|
+
await Promise.allSettled(inFlightRequests);
|
|
378
|
+
await localTargetWatch.catch(() => void 0);
|
|
379
|
+
await closeGatewaySession(plan, session).catch(() => void 0);
|
|
380
|
+
}
|
|
381
|
+
return session;
|
|
382
|
+
}
|
|
383
|
+
async function watchLocalPreviewTarget(target, controller, options) {
|
|
384
|
+
while (!controller.signal.aborted) {
|
|
385
|
+
try {
|
|
386
|
+
await (0, node_timers_promises.setTimeout)(options.intervalMs, void 0, { signal: controller.signal });
|
|
387
|
+
} catch {
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
if (controller.signal.aborted) return;
|
|
391
|
+
if (!await isLocalPreviewTargetReachable(target.localUrl, options.timeoutMs) && !controller.signal.aborted) {
|
|
392
|
+
_farm_js_core.logger.warn(`Local preview target ${target.localUrl} is no longer reachable. Closing preview session.`);
|
|
393
|
+
controller.abort(/* @__PURE__ */ new Error("Local preview target is no longer reachable."));
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
async function isLocalPreviewTargetReachable(url, timeoutMs) {
|
|
399
|
+
const signal = AbortSignal.timeout(timeoutMs);
|
|
400
|
+
try {
|
|
401
|
+
await fetch(url, {
|
|
402
|
+
method: "GET",
|
|
403
|
+
signal
|
|
404
|
+
});
|
|
405
|
+
return true;
|
|
406
|
+
} catch {
|
|
407
|
+
return false;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
async function createGatewaySession(plan, timeoutMs) {
|
|
411
|
+
const response = await fetch(`${plan.gatewayUrl}/api/sessions`, {
|
|
412
|
+
method: "POST",
|
|
413
|
+
headers: { "content-type": "application/json" },
|
|
414
|
+
body: JSON.stringify({
|
|
415
|
+
name: plan.requestedName,
|
|
416
|
+
hostname: plan.requestedHostname,
|
|
417
|
+
localUrl: plan.target.localUrl
|
|
418
|
+
}),
|
|
419
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
420
|
+
});
|
|
421
|
+
if (!response.ok) throw new Error(`Preview gateway rejected the session (${response.status}): ${await response.text()}`);
|
|
422
|
+
const session = await response.json();
|
|
423
|
+
if (!session.id || !session.token || !session.publicUrl) throw new Error("Preview gateway returned an invalid session.");
|
|
424
|
+
return session;
|
|
425
|
+
}
|
|
426
|
+
async function forwardGatewayRequest(target, request) {
|
|
427
|
+
const headers = new Headers();
|
|
428
|
+
for (const [key, value] of Object.entries(request.headers || {})) {
|
|
429
|
+
const normalized = key.toLowerCase();
|
|
430
|
+
if (HOP_BY_HOP_HEADERS.has(normalized) || normalized.startsWith("sec-websocket-")) continue;
|
|
431
|
+
headers.set(key, value);
|
|
432
|
+
}
|
|
433
|
+
headers.set("x-farm-preview", "1");
|
|
434
|
+
const method = request.method.toUpperCase();
|
|
435
|
+
const hasBody = method !== "GET" && method !== "HEAD" && Boolean(request.body);
|
|
436
|
+
const response = await fetch(`${target.localUrl}${request.path}`, {
|
|
437
|
+
method,
|
|
438
|
+
headers,
|
|
439
|
+
body: hasBody ? Buffer.from(request.body || "", request.encoding === "base64" ? "base64" : "utf8") : void 0
|
|
440
|
+
});
|
|
441
|
+
const responseHeaders = {};
|
|
442
|
+
response.headers.forEach((value, key) => {
|
|
443
|
+
const normalized = key.toLowerCase();
|
|
444
|
+
if (!HOP_BY_HOP_HEADERS.has(normalized)) responseHeaders[key] = value;
|
|
445
|
+
});
|
|
446
|
+
return {
|
|
447
|
+
status: response.status,
|
|
448
|
+
headers: responseHeaders,
|
|
449
|
+
body: Buffer.from(await response.arrayBuffer()).toString("base64"),
|
|
450
|
+
encoding: "base64"
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
async function pollGatewayRequests(plan, session, options) {
|
|
454
|
+
try {
|
|
455
|
+
const response = await fetch(`${plan.gatewayUrl}/api/sessions/${session.id}/requests?token=${encodeURIComponent(session.token)}&wait=${options.pollTimeoutMs}`, {
|
|
456
|
+
headers: { accept: "application/json" },
|
|
457
|
+
signal: options.signal
|
|
458
|
+
});
|
|
459
|
+
if (response.status === 204) return [];
|
|
460
|
+
if (!response.ok) throw new Error(`Preview gateway poll failed (${response.status}): ${await response.text()}`);
|
|
461
|
+
return (await response.json()).requests || [];
|
|
462
|
+
} catch (error) {
|
|
463
|
+
if (options.signal.aborted) return [];
|
|
464
|
+
throw error;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
async function sendGatewayResponse(plan, session, requestId, responseBody, signal) {
|
|
468
|
+
const response = await fetch(`${plan.gatewayUrl}/api/sessions/${session.id}/responses/${requestId}?token=${encodeURIComponent(session.token)}`, {
|
|
469
|
+
method: "POST",
|
|
470
|
+
headers: { "content-type": "application/json" },
|
|
471
|
+
body: JSON.stringify(responseBody),
|
|
472
|
+
signal
|
|
473
|
+
});
|
|
474
|
+
if (!response.ok) throw new Error(`Preview gateway response upload failed (${response.status}): ${await response.text()}`);
|
|
475
|
+
}
|
|
476
|
+
async function closeGatewaySession(plan, session) {
|
|
477
|
+
await fetch(`${plan.gatewayUrl}/api/sessions/${session.id}?token=${encodeURIComponent(session.token)}`, { method: "DELETE" });
|
|
478
|
+
}
|
|
479
|
+
function formatGatewayPlan(plan) {
|
|
480
|
+
return [
|
|
481
|
+
`Gateway: ${plan.gatewayUrl}`,
|
|
482
|
+
`Local: ${plan.target.localUrl}`,
|
|
483
|
+
`Public: ${plan.requestedPublicUrl}`
|
|
484
|
+
].join("\n");
|
|
485
|
+
}
|
|
486
|
+
function formatRequestPath(path) {
|
|
487
|
+
if (path.length <= 96) return path;
|
|
488
|
+
return `${path.slice(0, 93)}...`;
|
|
489
|
+
}
|
|
490
|
+
function normalizeGatewayUrl(value) {
|
|
491
|
+
return value.replace(/\/+$/, "");
|
|
492
|
+
}
|
|
493
|
+
function normalizePreviewDomain$1(value) {
|
|
494
|
+
return value.replace(/^https?:\/\//, "").replace(/^\.*/, "").replace(/\/*$/, "");
|
|
495
|
+
}
|
|
496
|
+
function sanitizePreviewName$1(value) {
|
|
497
|
+
if (!value) return void 0;
|
|
498
|
+
return value.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 48);
|
|
499
|
+
}
|
|
500
|
+
function randomPreviewName$1() {
|
|
501
|
+
return `farm-${Math.random().toString(36).slice(2, 8)}`;
|
|
502
|
+
}
|
|
503
|
+
//#endregion
|
|
504
|
+
//#region src/preview.ts
|
|
505
|
+
const DEFAULT_PREVIEW_PORTS = [
|
|
506
|
+
3e3,
|
|
507
|
+
4319,
|
|
508
|
+
5173,
|
|
509
|
+
4173,
|
|
510
|
+
8080
|
|
511
|
+
];
|
|
512
|
+
const PREVIEW_URL_PATTERN = /https:\/\/[^\s"')\]}]+/g;
|
|
513
|
+
async function previewFarm(options = {}) {
|
|
514
|
+
const target = await resolvePreviewTarget(options);
|
|
515
|
+
_farm_js_core.logger.info("Creating public preview for the running app...");
|
|
516
|
+
_farm_js_core.logger.info(`Local: ${target.localUrl}`);
|
|
517
|
+
if (shouldUseManagedGateway(options)) {
|
|
518
|
+
const plan = createPreviewGatewayPlan(target, options);
|
|
519
|
+
if (options.dryRun) {
|
|
520
|
+
_farm_js_core.logger.info(formatGatewayPlan(plan));
|
|
521
|
+
_farm_js_core.logger.success("Preview gateway dry run completed.");
|
|
522
|
+
return {
|
|
523
|
+
target,
|
|
524
|
+
plan
|
|
525
|
+
};
|
|
526
|
+
}
|
|
527
|
+
_farm_js_core.logger.info(`Gateway: ${plan.gatewayUrl}`);
|
|
528
|
+
_farm_js_core.logger.info("Opening Farm preview gateway session...");
|
|
529
|
+
const session = await runPreviewGateway(plan, { timeoutMs: options.timeoutMs });
|
|
530
|
+
return {
|
|
531
|
+
target,
|
|
532
|
+
plan,
|
|
533
|
+
publicUrl: session.publicUrl,
|
|
534
|
+
session
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
const plan = createPreviewTunnelPlan(target, options);
|
|
538
|
+
if (options.dryRun) {
|
|
539
|
+
_farm_js_core.logger.info(`Command: ${formatCommand(plan)}`);
|
|
540
|
+
_farm_js_core.logger.success("Preview tunnel dry run completed.");
|
|
541
|
+
return {
|
|
542
|
+
target,
|
|
543
|
+
plan
|
|
544
|
+
};
|
|
545
|
+
}
|
|
546
|
+
_farm_js_core.logger.info("Opening public tunnel...");
|
|
547
|
+
return {
|
|
548
|
+
target,
|
|
549
|
+
plan,
|
|
550
|
+
publicUrl: await runPreviewTunnel(plan, options.timeoutMs ?? 3e4)
|
|
551
|
+
};
|
|
552
|
+
}
|
|
553
|
+
function shouldUseManagedGateway(options) {
|
|
554
|
+
if (options.provider === "farm" || process.env.FARM_PREVIEW_PROVIDER === "farm") return true;
|
|
555
|
+
if (options.provider === "local" || process.env.FARM_PREVIEW_PROVIDER === "local") return false;
|
|
556
|
+
return !process.env.FARM_PREVIEW_TUNNEL_COMMAND;
|
|
557
|
+
}
|
|
558
|
+
async function resolvePreviewTarget(options = {}) {
|
|
559
|
+
if (options.url) {
|
|
560
|
+
const parsed = new URL(options.url);
|
|
561
|
+
const port = Number(parsed.port || (parsed.protocol === "https:" ? 443 : 80));
|
|
562
|
+
if (!Number.isFinite(port)) throw new Error(`Could not resolve a port from ${options.url}.`);
|
|
563
|
+
return {
|
|
564
|
+
localUrl: normalizeLocalUrl(options.url),
|
|
565
|
+
host: parsed.hostname,
|
|
566
|
+
port,
|
|
567
|
+
source: "url"
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
const root = options.root || process.cwd();
|
|
571
|
+
const host = options.host || process.env.FARM_PREVIEW_HOST || "localhost";
|
|
572
|
+
const explicitPort = normalizePort(options.port || process.env.FARM_PREVIEW_PORT);
|
|
573
|
+
const configPort = explicitPort ? void 0 : await readConfigPort(root, options.configPath);
|
|
574
|
+
const candidates = uniqueNumbers([
|
|
575
|
+
explicitPort,
|
|
576
|
+
normalizePort(process.env.FARM_PORT),
|
|
577
|
+
normalizePort(process.env.PORT),
|
|
578
|
+
configPort,
|
|
579
|
+
...DEFAULT_PREVIEW_PORTS
|
|
580
|
+
]);
|
|
581
|
+
if (!candidates.length) throw new Error("No preview port candidates were available.");
|
|
582
|
+
if (options.noProbe && explicitPort) return {
|
|
583
|
+
localUrl: createLocalUrl(host, explicitPort),
|
|
584
|
+
host,
|
|
585
|
+
port: explicitPort,
|
|
586
|
+
source: "port"
|
|
587
|
+
};
|
|
588
|
+
const timeoutMs = options.timeoutMs ?? 1500;
|
|
589
|
+
for (const port of candidates) {
|
|
590
|
+
const localUrl = createLocalUrl(host, port);
|
|
591
|
+
if (await isReachable(localUrl, timeoutMs)) return {
|
|
592
|
+
localUrl,
|
|
593
|
+
host,
|
|
594
|
+
port,
|
|
595
|
+
source: explicitPort ? "port" : configPort === port ? "config" : "detected"
|
|
596
|
+
};
|
|
597
|
+
}
|
|
598
|
+
const hint = explicitPort ? createLocalUrl(host, explicitPort) : candidates.map((port) => port).join(", ");
|
|
599
|
+
throw new Error(`No running Farm app was found (${hint}). Start "farm dev" first, or pass --port/--url to the running app.`);
|
|
600
|
+
}
|
|
601
|
+
function createPreviewTunnelPlan(target, options = {}) {
|
|
602
|
+
const requestedName = sanitizePreviewName(options.name || process.env.FARM_PREVIEW_NAME) || randomPreviewName();
|
|
603
|
+
const requestedHostname = `${requestedName}.${normalizePreviewDomain(process.env.FARM_PREVIEW_DOMAIN || "preview.farming-labs.dev")}`;
|
|
604
|
+
const template = process.env.FARM_PREVIEW_TUNNEL_COMMAND;
|
|
605
|
+
if (template) return {
|
|
606
|
+
command: expandTunnelTemplate(template, target, requestedName, requestedHostname),
|
|
607
|
+
args: [],
|
|
608
|
+
shell: true,
|
|
609
|
+
target,
|
|
610
|
+
requestedName,
|
|
611
|
+
requestedHostname
|
|
612
|
+
};
|
|
613
|
+
if (commandExists("cloudflared")) return {
|
|
614
|
+
command: "cloudflared",
|
|
615
|
+
args: [
|
|
616
|
+
"tunnel",
|
|
617
|
+
"--url",
|
|
618
|
+
target.localUrl
|
|
619
|
+
],
|
|
620
|
+
target,
|
|
621
|
+
requestedName,
|
|
622
|
+
requestedHostname
|
|
623
|
+
};
|
|
624
|
+
if (commandExists("npx")) return {
|
|
625
|
+
command: "npx",
|
|
626
|
+
args: [
|
|
627
|
+
"--yes",
|
|
628
|
+
"localtunnel",
|
|
629
|
+
"--port",
|
|
630
|
+
String(target.port),
|
|
631
|
+
"--local-host",
|
|
632
|
+
target.host,
|
|
633
|
+
"--subdomain",
|
|
634
|
+
requestedName
|
|
635
|
+
],
|
|
636
|
+
target,
|
|
637
|
+
requestedName,
|
|
638
|
+
requestedHostname
|
|
639
|
+
};
|
|
640
|
+
throw new Error("No preview tunnel command is available. Install cloudflared, ensure npx is available, or set FARM_PREVIEW_TUNNEL_COMMAND.");
|
|
641
|
+
}
|
|
642
|
+
function parsePreviewPublicUrl(output, preferredHostname) {
|
|
643
|
+
const urls = (output.match(PREVIEW_URL_PATTERN) || []).filter((value) => {
|
|
644
|
+
try {
|
|
645
|
+
const url = new URL(value);
|
|
646
|
+
return Boolean(url.hostname);
|
|
647
|
+
} catch {
|
|
648
|
+
return false;
|
|
649
|
+
}
|
|
650
|
+
});
|
|
651
|
+
if (preferredHostname) {
|
|
652
|
+
const preferred = urls.find((value) => new URL(value).hostname === preferredHostname);
|
|
653
|
+
if (preferred) return preferred;
|
|
654
|
+
}
|
|
655
|
+
return urls.find((value) => {
|
|
656
|
+
const host = new URL(value).hostname;
|
|
657
|
+
return host.endsWith(".trycloudflare.com") || host.endsWith(".loca.lt") || host.endsWith(".localtunnel.me") || host.endsWith(".ngrok.app") || host.endsWith(".ngrok-free.app") || host.endsWith(".ngrok.dev") || host.endsWith(".ngrok.io") || host.endsWith(".preview.farming-labs.dev");
|
|
658
|
+
}) || urls[0];
|
|
659
|
+
}
|
|
660
|
+
async function runPreviewTunnel(plan, timeoutMs) {
|
|
661
|
+
const child = (0, node_child_process.spawn)(plan.command, plan.args, {
|
|
662
|
+
env: {
|
|
663
|
+
...process.env,
|
|
664
|
+
FARM_PREVIEW_LOCAL_URL: plan.target.localUrl,
|
|
665
|
+
FARM_PREVIEW_NAME: plan.requestedName,
|
|
666
|
+
FARM_PREVIEW_HOSTNAME: plan.requestedHostname
|
|
667
|
+
},
|
|
668
|
+
shell: plan.shell,
|
|
669
|
+
stdio: [
|
|
670
|
+
"ignore",
|
|
671
|
+
"pipe",
|
|
672
|
+
"pipe"
|
|
673
|
+
]
|
|
674
|
+
});
|
|
675
|
+
let output = "";
|
|
676
|
+
let publicUrl;
|
|
677
|
+
let settled = false;
|
|
678
|
+
const cleanup = () => {
|
|
679
|
+
if (!child.killed) child.kill("SIGTERM");
|
|
680
|
+
};
|
|
681
|
+
process.once("SIGINT", cleanup);
|
|
682
|
+
process.once("SIGTERM", cleanup);
|
|
683
|
+
try {
|
|
684
|
+
publicUrl = await new Promise((resolve, reject) => {
|
|
685
|
+
const timer = setTimeout(() => {
|
|
686
|
+
reject(/* @__PURE__ */ new Error("Timed out waiting for the preview URL."));
|
|
687
|
+
}, timeoutMs);
|
|
688
|
+
const handleChunk = (chunk) => {
|
|
689
|
+
const text = chunk.toString();
|
|
690
|
+
output += text;
|
|
691
|
+
process.stdout.write(text);
|
|
692
|
+
const nextUrl = parsePreviewPublicUrl(output, plan.requestedHostname);
|
|
693
|
+
if (nextUrl && !settled) {
|
|
694
|
+
settled = true;
|
|
695
|
+
clearTimeout(timer);
|
|
696
|
+
_farm_js_core.logger.success("Preview URL ready.");
|
|
697
|
+
_farm_js_core.logger.info(`Public: ${nextUrl}`);
|
|
698
|
+
_farm_js_core.logger.info("Forwarding requests until Ctrl+C.");
|
|
699
|
+
resolve(nextUrl);
|
|
700
|
+
}
|
|
701
|
+
};
|
|
702
|
+
child.stdout?.on("data", handleChunk);
|
|
703
|
+
child.stderr?.on("data", handleChunk);
|
|
704
|
+
child.on("error", (error) => {
|
|
705
|
+
if (!settled) {
|
|
706
|
+
settled = true;
|
|
707
|
+
clearTimeout(timer);
|
|
708
|
+
reject(error);
|
|
709
|
+
}
|
|
710
|
+
});
|
|
711
|
+
child.on("exit", (code, signal) => {
|
|
712
|
+
if (!settled) {
|
|
713
|
+
settled = true;
|
|
714
|
+
clearTimeout(timer);
|
|
715
|
+
reject(/* @__PURE__ */ new Error(`Preview tunnel exited before returning a URL (${signal || `exit code ${code ?? 0}`}).`));
|
|
716
|
+
}
|
|
717
|
+
});
|
|
718
|
+
});
|
|
719
|
+
await waitForTunnelExit(child);
|
|
720
|
+
return publicUrl;
|
|
721
|
+
} finally {
|
|
722
|
+
process.removeListener("SIGINT", cleanup);
|
|
723
|
+
process.removeListener("SIGTERM", cleanup);
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
function waitForTunnelExit(child) {
|
|
727
|
+
return new Promise((resolve) => {
|
|
728
|
+
if (child.exitCode !== null || child.signalCode !== null) {
|
|
729
|
+
resolve();
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
732
|
+
child.once("exit", () => resolve());
|
|
733
|
+
});
|
|
734
|
+
}
|
|
735
|
+
async function isReachable(url, timeoutMs) {
|
|
736
|
+
const controller = new AbortController();
|
|
737
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
738
|
+
try {
|
|
739
|
+
await fetch(url, {
|
|
740
|
+
method: "GET",
|
|
741
|
+
signal: controller.signal
|
|
742
|
+
});
|
|
743
|
+
return true;
|
|
744
|
+
} catch {
|
|
745
|
+
return false;
|
|
746
|
+
} finally {
|
|
747
|
+
clearTimeout(timer);
|
|
748
|
+
await (0, node_timers_promises.setTimeout)(0);
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
async function readConfigPort(root, configPath) {
|
|
752
|
+
try {
|
|
753
|
+
const vite = (await (0, _farm_js_core.loadConfig)(root, configPath, "development"))?.vite;
|
|
754
|
+
if (!vite || typeof vite === "function") return void 0;
|
|
755
|
+
return normalizePort(vite.server?.port);
|
|
756
|
+
} catch {
|
|
757
|
+
return;
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
function commandExists(command) {
|
|
761
|
+
const result = (0, node_child_process.spawnSync)(command, ["--version"], {
|
|
762
|
+
shell: process.platform === "win32",
|
|
763
|
+
stdio: "ignore"
|
|
764
|
+
});
|
|
765
|
+
return !result.error && result.status === 0;
|
|
766
|
+
}
|
|
767
|
+
function expandTunnelTemplate(template, target, requestedName, requestedHostname) {
|
|
768
|
+
return template.replaceAll("{url}", shellQuote(target.localUrl)).replaceAll("{port}", shellQuote(String(target.port))).replaceAll("{host}", shellQuote(target.host)).replaceAll("{name}", shellQuote(requestedName)).replaceAll("{hostname}", shellQuote(requestedHostname));
|
|
769
|
+
}
|
|
770
|
+
function shellQuote(value) {
|
|
771
|
+
return `"${value.replace(/(["\\$`])/g, "\\$1")}"`;
|
|
772
|
+
}
|
|
773
|
+
function formatCommand(plan) {
|
|
774
|
+
if (plan.shell) return plan.command;
|
|
775
|
+
return [plan.command, ...plan.args].join(" ");
|
|
776
|
+
}
|
|
777
|
+
function normalizeLocalUrl(value) {
|
|
778
|
+
return value.replace(/\/+$/, "");
|
|
779
|
+
}
|
|
780
|
+
function createLocalUrl(host, port) {
|
|
781
|
+
return `http://${host}:${port}`;
|
|
782
|
+
}
|
|
783
|
+
function normalizePort(value) {
|
|
784
|
+
if (value === void 0 || value === null || value === "") return void 0;
|
|
785
|
+
const port = Number(value);
|
|
786
|
+
return Number.isInteger(port) && port > 0 && port <= 65535 ? port : void 0;
|
|
787
|
+
}
|
|
788
|
+
function uniqueNumbers(values) {
|
|
789
|
+
return [...new Set(values.filter((value) => value !== void 0))];
|
|
790
|
+
}
|
|
791
|
+
function sanitizePreviewName(value) {
|
|
792
|
+
if (!value) return void 0;
|
|
793
|
+
return value.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 48);
|
|
794
|
+
}
|
|
795
|
+
function randomPreviewName() {
|
|
796
|
+
return `farm-${Math.random().toString(36).slice(2, 8)}`;
|
|
797
|
+
}
|
|
798
|
+
function normalizePreviewDomain(value) {
|
|
799
|
+
return value.replace(/^https?:\/\//, "").replace(/^\.*/, "").replace(/\/*$/, "");
|
|
800
|
+
}
|
|
801
|
+
//#endregion
|
|
802
|
+
//#region src/generate.ts
|
|
803
|
+
const PRISMA_GENERATED_START = "// Farm.js integrations generated schema: start";
|
|
804
|
+
const PRISMA_GENERATED_END = "// Farm.js integrations generated schema: end";
|
|
805
|
+
async function generateFarmArtifacts(options = {}) {
|
|
806
|
+
const root = node_path.default.resolve(options.root || process.cwd());
|
|
807
|
+
const userConfig = await (0, _farm_js_core.loadConfig)(root, options.configPath, "development");
|
|
808
|
+
if (!userConfig && hasSchemaOptions(options)) throw new Error("No Farm config found. Please create farm.config.ts or config.ts.");
|
|
809
|
+
const resolvedConfig = await (0, _farm_js_core.resolveConfig)({
|
|
810
|
+
root,
|
|
811
|
+
...userConfig || {}
|
|
812
|
+
}, "development");
|
|
813
|
+
const extraRoutes = [...resolvedConfig.openapi?.enabled && resolvedConfig.openapi.route ? [resolvedConfig.openapi.route] : [], ...(0, _farm_js_core.getFarmDocsRouteTypeEntries)(resolvedConfig.docs)];
|
|
814
|
+
await (0, _farm_js_core.generateRouteTypes)({
|
|
815
|
+
root: resolvedConfig.root,
|
|
816
|
+
srcDir: resolvedConfig.srcDir,
|
|
817
|
+
extraRoutes,
|
|
818
|
+
suppressLintOnLink: resolvedConfig.suppressLintOnLink
|
|
819
|
+
});
|
|
820
|
+
await (0, _farm_js_core.generateEnvTypes)({
|
|
821
|
+
root: resolvedConfig.root,
|
|
822
|
+
srcDir: resolvedConfig.srcDir,
|
|
823
|
+
configPath: options.configPath
|
|
824
|
+
});
|
|
825
|
+
const apiGenerator = new _farm_js_core.APITypeGenerator(node_path.default.join(resolvedConfig.root, resolvedConfig.srcDir, "app"));
|
|
826
|
+
const apiRoutes = apiGenerator.scanAPIRoutes();
|
|
827
|
+
const apiTypesPath = node_path.default.join(resolvedConfig.root, resolvedConfig.srcDir, "lib", "api.generated.ts");
|
|
828
|
+
await (0, node_fs_promises.mkdir)(node_path.default.dirname(apiTypesPath), { recursive: true });
|
|
829
|
+
await (0, node_fs_promises.writeFile)(apiTypesPath, apiGenerator.generateAPIRouter(apiRoutes), "utf8");
|
|
830
|
+
if (resolvedConfig.i18n.enabled) await (0, _farm_js_core.generateFarmI18nTypes)({
|
|
831
|
+
root: resolvedConfig.root,
|
|
832
|
+
srcDir: resolvedConfig.srcDir,
|
|
833
|
+
config: resolvedConfig.i18n
|
|
834
|
+
});
|
|
835
|
+
_farm_js_core.logger.success(`Generated route, API, env${resolvedConfig.i18n.enabled ? ", and i18n" : ""} types (${apiRoutes.length} API route${apiRoutes.length === 1 ? "" : "s"}).`);
|
|
836
|
+
const schemas = (0, _farm_js_core.getIntegrationSchemas)(resolvedConfig.integrations);
|
|
837
|
+
const schemaEntries = Object.entries(schemas);
|
|
838
|
+
if (!schemaEntries.length) {
|
|
839
|
+
if (hasSchemaOptions(options)) _farm_js_core.logger.warn("No integration schemas were found in the current Farm config.");
|
|
840
|
+
return;
|
|
841
|
+
}
|
|
842
|
+
const packageManifest = await readPackageManifest(root);
|
|
843
|
+
const schemaOptionsExplicit = hasSchemaOptions(options);
|
|
844
|
+
let orm = null;
|
|
845
|
+
try {
|
|
846
|
+
orm = options.orm ?? await detectSchemaTarget(root, packageManifest);
|
|
847
|
+
} catch (error) {
|
|
848
|
+
if (schemaOptionsExplicit) throw error;
|
|
849
|
+
_farm_js_core.logger.warn(`Integration schemas were found, but Farm could not choose a schema target automatically: ${error.message}`);
|
|
850
|
+
return;
|
|
851
|
+
}
|
|
852
|
+
if (!orm) {
|
|
853
|
+
if (!schemaOptionsExplicit) {
|
|
854
|
+
_farm_js_core.logger.warn("Integration schemas were found, but no data layer was detected. Pass --orm prisma|drizzle|postgres|mysql|sqlite|mongodb to generate schema artifacts.");
|
|
855
|
+
return;
|
|
856
|
+
}
|
|
857
|
+
throw new Error("Could not auto-detect a schema target. Pass one explicitly with --orm prisma|drizzle|postgres|mysql|sqlite|mongodb.");
|
|
858
|
+
}
|
|
859
|
+
const collectedModels = collectSchemaModels(schemaEntries);
|
|
860
|
+
switch (orm) {
|
|
861
|
+
case "prisma": {
|
|
862
|
+
const schemaPath = options.output ? node_path.default.resolve(root, options.output) : node_path.default.join(root, "prisma", "schema.prisma");
|
|
863
|
+
if (!(0, node_fs.existsSync)(schemaPath)) throw new Error(`Prisma target was selected but no schema file was found at ${schemaPath}. Create prisma/schema.prisma or pass --output.`);
|
|
864
|
+
await writePrismaSchema(schemaPath, collectedModels);
|
|
865
|
+
_farm_js_core.logger.success(`Generated Prisma integration schema in ${node_path.default.relative(root, schemaPath)}.`);
|
|
866
|
+
return;
|
|
867
|
+
}
|
|
868
|
+
case "drizzle": {
|
|
869
|
+
const dialect = options.dialect ?? await detectDrizzleDialect(root, packageManifest) ?? void 0;
|
|
870
|
+
if (!dialect) throw new Error("Detected Drizzle but could not determine its dialect. Pass --dialect postgres|mysql|sqlite.");
|
|
871
|
+
const outputPath = options.output ? node_path.default.resolve(root, options.output) : node_path.default.join(root, "farm-integrations.generated.ts");
|
|
872
|
+
await writeGeneratedFile(outputPath, generateDrizzleSchema(collectedModels, dialect));
|
|
873
|
+
_farm_js_core.logger.success(`Generated Drizzle integration schema in ${node_path.default.relative(root, outputPath)}.`);
|
|
874
|
+
return;
|
|
875
|
+
}
|
|
876
|
+
case "postgres":
|
|
877
|
+
case "mysql":
|
|
878
|
+
case "sqlite": {
|
|
879
|
+
const outputPath = options.output ? node_path.default.resolve(root, options.output) : node_path.default.join(root, `farm-integrations.generated.${orm}.sql`);
|
|
880
|
+
await writeGeneratedFile(outputPath, generateSqlSchema(collectedModels, orm));
|
|
881
|
+
_farm_js_core.logger.success(`Generated ${orm} integration schema in ${node_path.default.relative(root, outputPath)}.`);
|
|
882
|
+
return;
|
|
883
|
+
}
|
|
884
|
+
case "mongodb": {
|
|
885
|
+
const outputPath = options.output ? node_path.default.resolve(root, options.output) : node_path.default.join(root, "farm-integrations.generated.mongodb.ts");
|
|
886
|
+
await writeGeneratedFile(outputPath, generateMongoBootstrap(collectedModels));
|
|
887
|
+
_farm_js_core.logger.success(`Generated MongoDB integration bootstrap in ${node_path.default.relative(root, outputPath)}.`);
|
|
888
|
+
return;
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
function hasSchemaOptions(options) {
|
|
893
|
+
return Boolean(options.orm || options.output || options.dialect);
|
|
894
|
+
}
|
|
895
|
+
async function readPackageManifest(root) {
|
|
896
|
+
const packagePath = node_path.default.join(root, "package.json");
|
|
897
|
+
if (!(0, node_fs.existsSync)(packagePath)) return null;
|
|
898
|
+
const source = await (0, node_fs_promises.readFile)(packagePath, "utf8");
|
|
899
|
+
return JSON.parse(source);
|
|
900
|
+
}
|
|
901
|
+
async function detectSchemaTarget(root, packageManifest) {
|
|
902
|
+
const hasPrismaSchema = (0, node_fs.existsSync)(node_path.default.join(root, "prisma", "schema.prisma"));
|
|
903
|
+
const drizzleConfigPath = findExistingPath(root, [
|
|
904
|
+
"drizzle.config.ts",
|
|
905
|
+
"drizzle.config.mts",
|
|
906
|
+
"drizzle.config.js",
|
|
907
|
+
"drizzle.config.mjs",
|
|
908
|
+
"drizzle.config.cjs"
|
|
909
|
+
]);
|
|
910
|
+
if (hasPrismaSchema && drizzleConfigPath) throw new Error("Detected both Prisma and Drizzle in this project. Pass --orm prisma or --orm drizzle to choose one.");
|
|
911
|
+
if (hasPrismaSchema) return "prisma";
|
|
912
|
+
if (drizzleConfigPath) return "drizzle";
|
|
913
|
+
const dependencies = getPackageDependencyNames(packageManifest);
|
|
914
|
+
const candidates = /* @__PURE__ */ new Set();
|
|
915
|
+
if (dependencies.has("@prisma/client") || dependencies.has("prisma")) candidates.add("prisma");
|
|
916
|
+
if (dependencies.has("drizzle-orm") || dependencies.has("drizzle-kit")) candidates.add("drizzle");
|
|
917
|
+
if (hasAnyDependency(dependencies, [
|
|
918
|
+
"pg",
|
|
919
|
+
"postgres",
|
|
920
|
+
"@neondatabase/serverless"
|
|
921
|
+
])) candidates.add("postgres");
|
|
922
|
+
if (hasAnyDependency(dependencies, ["mysql2", "@planetscale/database"])) candidates.add("mysql");
|
|
923
|
+
if (hasAnyDependency(dependencies, ["better-sqlite3", "sqlite3"])) candidates.add("sqlite");
|
|
924
|
+
if (hasAnyDependency(dependencies, ["mongodb", "mongoose"])) candidates.add("mongodb");
|
|
925
|
+
if (candidates.size === 1) return Array.from(candidates)[0];
|
|
926
|
+
if (candidates.size > 1) throw new Error(`Detected multiple possible schema targets (${Array.from(candidates).join(", ")}). Pass --orm to choose one.`);
|
|
927
|
+
return null;
|
|
928
|
+
}
|
|
929
|
+
async function detectDrizzleDialect(root, packageManifest) {
|
|
930
|
+
const configPath = findExistingPath(root, [
|
|
931
|
+
"drizzle.config.ts",
|
|
932
|
+
"drizzle.config.mts",
|
|
933
|
+
"drizzle.config.js",
|
|
934
|
+
"drizzle.config.mjs",
|
|
935
|
+
"drizzle.config.cjs"
|
|
936
|
+
]);
|
|
937
|
+
if (configPath) {
|
|
938
|
+
const dialectMatch = (await (0, node_fs_promises.readFile)(configPath, "utf8")).match(/dialect\s*:\s*["'](postgresql|postgres|mysql|sqlite|turso)["']/);
|
|
939
|
+
if (dialectMatch) {
|
|
940
|
+
const dialect = dialectMatch[1];
|
|
941
|
+
if (dialect === "postgresql" || dialect === "postgres") return "postgres";
|
|
942
|
+
if (dialect === "mysql") return "mysql";
|
|
943
|
+
if (dialect === "sqlite" || dialect === "turso") return "sqlite";
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
const dependencies = getPackageDependencyNames(packageManifest);
|
|
947
|
+
const candidates = /* @__PURE__ */ new Set();
|
|
948
|
+
if (hasAnyDependency(dependencies, [
|
|
949
|
+
"pg",
|
|
950
|
+
"postgres",
|
|
951
|
+
"@neondatabase/serverless"
|
|
952
|
+
])) candidates.add("postgres");
|
|
953
|
+
if (hasAnyDependency(dependencies, ["mysql2", "@planetscale/database"])) candidates.add("mysql");
|
|
954
|
+
if (hasAnyDependency(dependencies, ["better-sqlite3", "sqlite3"])) candidates.add("sqlite");
|
|
955
|
+
return candidates.size === 1 ? Array.from(candidates)[0] : null;
|
|
956
|
+
}
|
|
957
|
+
function findExistingPath(root, relativePaths) {
|
|
958
|
+
for (const relativePath of relativePaths) {
|
|
959
|
+
const absolutePath = node_path.default.join(root, relativePath);
|
|
960
|
+
if ((0, node_fs.existsSync)(absolutePath)) return absolutePath;
|
|
961
|
+
}
|
|
962
|
+
return null;
|
|
963
|
+
}
|
|
964
|
+
function getPackageDependencyNames(packageManifest) {
|
|
965
|
+
return /* @__PURE__ */ new Set([
|
|
966
|
+
...Object.keys(packageManifest?.dependencies || {}),
|
|
967
|
+
...Object.keys(packageManifest?.devDependencies || {}),
|
|
968
|
+
...Object.keys(packageManifest?.peerDependencies || {}),
|
|
969
|
+
...Object.keys(packageManifest?.optionalDependencies || {})
|
|
970
|
+
]);
|
|
971
|
+
}
|
|
972
|
+
function hasAnyDependency(dependencies, candidates) {
|
|
973
|
+
return candidates.some((candidate) => dependencies.has(candidate));
|
|
974
|
+
}
|
|
975
|
+
function collectSchemaModels(schemaEntries) {
|
|
976
|
+
const collectedModels = [];
|
|
977
|
+
const seenModelNames = /* @__PURE__ */ new Map();
|
|
978
|
+
for (const [integrationKey, schema] of schemaEntries) {
|
|
979
|
+
const resolvedModels = resolveSchemaModels(integrationKey, schema);
|
|
980
|
+
for (const [modelKey, model] of Object.entries(resolvedModels)) {
|
|
981
|
+
const collisionKey = model.name.toLowerCase();
|
|
982
|
+
const previousOwner = seenModelNames.get(collisionKey);
|
|
983
|
+
if (previousOwner && previousOwner !== `${integrationKey}.${modelKey}`) throw new Error(`Integration schema model "${integrationKey}.${modelKey}" resolves to "${model.name}", which conflicts with "${previousOwner}". Rename one of the models with schema.models.<model>.name.`);
|
|
984
|
+
seenModelNames.set(collisionKey, `${integrationKey}.${modelKey}`);
|
|
985
|
+
collectedModels.push({
|
|
986
|
+
integrationKey,
|
|
987
|
+
modelKey,
|
|
988
|
+
modelName: model.name,
|
|
989
|
+
exportName: toCamelCase(`${integrationKey}_${modelKey}`),
|
|
990
|
+
prismaModelName: toPascalCase(`${integrationKey}_${modelKey}`),
|
|
991
|
+
model
|
|
992
|
+
});
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
return collectedModels;
|
|
996
|
+
}
|
|
997
|
+
function resolveSchemaModels(integrationKey, schema) {
|
|
998
|
+
const models = Object.fromEntries(Object.entries(schema.models).map(([modelKey, model]) => [modelKey, cloneSchemaModel(model)]));
|
|
999
|
+
for (const [modelKey, extension] of Object.entries(schema.extend || {})) {
|
|
1000
|
+
const existing = models[modelKey];
|
|
1001
|
+
models[modelKey] = {
|
|
1002
|
+
...existing || { fields: {} },
|
|
1003
|
+
...extension.name ? { name: extension.name } : {},
|
|
1004
|
+
...extension.description ? { description: extension.description } : {},
|
|
1005
|
+
fields: {
|
|
1006
|
+
...existing?.fields,
|
|
1007
|
+
...extension.fields
|
|
1008
|
+
},
|
|
1009
|
+
constraints: [...existing?.constraints || [], ...extension.constraints || []],
|
|
1010
|
+
meta: {
|
|
1011
|
+
...existing?.meta,
|
|
1012
|
+
...extension.meta
|
|
1013
|
+
}
|
|
1014
|
+
};
|
|
1015
|
+
}
|
|
1016
|
+
for (const [modelKey, override] of Object.entries(schema.override || {})) {
|
|
1017
|
+
const existing = models[modelKey];
|
|
1018
|
+
if (!existing) throw new Error(`Integration schema override for "${integrationKey}.${modelKey}" is invalid because that model does not exist.`);
|
|
1019
|
+
const fields = { ...existing.fields };
|
|
1020
|
+
for (const [fieldKey, fieldOverride] of Object.entries(override.fields || {})) {
|
|
1021
|
+
const existingField = fields[fieldKey];
|
|
1022
|
+
if (!existingField && !fieldOverride.type) throw new Error(`Integration schema override for "${integrationKey}.${modelKey}.${fieldKey}" is missing a field type.`);
|
|
1023
|
+
fields[fieldKey] = {
|
|
1024
|
+
...existingField,
|
|
1025
|
+
...fieldOverride
|
|
1026
|
+
};
|
|
1027
|
+
}
|
|
1028
|
+
models[modelKey] = {
|
|
1029
|
+
...existing,
|
|
1030
|
+
...override.name ? { name: override.name } : {},
|
|
1031
|
+
...override.description ? { description: override.description } : {},
|
|
1032
|
+
fields,
|
|
1033
|
+
constraints: override.constraints || existing.constraints,
|
|
1034
|
+
meta: {
|
|
1035
|
+
...existing.meta,
|
|
1036
|
+
...override.meta
|
|
1037
|
+
}
|
|
1038
|
+
};
|
|
1039
|
+
}
|
|
1040
|
+
const resolvedModels = {};
|
|
1041
|
+
for (const [modelKey, model] of Object.entries(models)) {
|
|
1042
|
+
const modelName = model.name || toSnakeCase(`${integrationKey}_${modelKey}`);
|
|
1043
|
+
const fieldNames = /* @__PURE__ */ new Set();
|
|
1044
|
+
const resolvedFields = {};
|
|
1045
|
+
for (const [fieldKey, field] of Object.entries(model.fields)) {
|
|
1046
|
+
const fieldName = field.name || toSnakeCase(fieldKey);
|
|
1047
|
+
if (fieldNames.has(fieldName)) throw new Error(`Integration schema model "${integrationKey}.${modelKey}" contains duplicate field name "${fieldName}".`);
|
|
1048
|
+
fieldNames.add(fieldName);
|
|
1049
|
+
resolvedFields[fieldKey] = {
|
|
1050
|
+
...field,
|
|
1051
|
+
name: fieldName
|
|
1052
|
+
};
|
|
1053
|
+
}
|
|
1054
|
+
resolvedModels[modelKey] = {
|
|
1055
|
+
...model,
|
|
1056
|
+
name: modelName,
|
|
1057
|
+
fields: resolvedFields
|
|
1058
|
+
};
|
|
1059
|
+
}
|
|
1060
|
+
return resolvedModels;
|
|
1061
|
+
}
|
|
1062
|
+
function cloneSchemaModel(model) {
|
|
1063
|
+
return {
|
|
1064
|
+
...model,
|
|
1065
|
+
fields: Object.fromEntries(Object.entries(model.fields).map(([fieldKey, field]) => [fieldKey, { ...field }])),
|
|
1066
|
+
constraints: model.constraints ? [...model.constraints] : void 0,
|
|
1067
|
+
meta: model.meta ? { ...model.meta } : void 0
|
|
1068
|
+
};
|
|
1069
|
+
}
|
|
1070
|
+
async function writePrismaSchema(schemaPath, models) {
|
|
1071
|
+
const source = await (0, node_fs_promises.readFile)(schemaPath, "utf8");
|
|
1072
|
+
const generated = createPrismaGeneratedBlock(generatePrismaSchema(models));
|
|
1073
|
+
const pattern = new RegExp(`${escapeRegExp(PRISMA_GENERATED_START)}[\\s\\S]*?${escapeRegExp(PRISMA_GENERATED_END)}`, "m");
|
|
1074
|
+
await (0, node_fs_promises.writeFile)(schemaPath, pattern.test(source) ? source.replace(pattern, generated) : `${source.trimEnd()}\n\n${generated}\n`, "utf8");
|
|
1075
|
+
}
|
|
1076
|
+
function generatePrismaSchema(models) {
|
|
1077
|
+
return models.map((model) => renderPrismaModel(model)).join("\n\n");
|
|
1078
|
+
}
|
|
1079
|
+
function renderPrismaModel(model) {
|
|
1080
|
+
const lines = [`/// Farm.js generated from integration "${model.integrationKey}" model "${model.modelKey}"`, `model ${model.prismaModelName} {`];
|
|
1081
|
+
const modelLevelConstraints = [];
|
|
1082
|
+
for (const [fieldKey, field] of Object.entries(model.model.fields)) {
|
|
1083
|
+
if (field.reference) lines.push(` /// References ${field.reference.model}.${field.reference.field}${field.reference.onDelete ? ` (onDelete: ${field.reference.onDelete})` : ""}`);
|
|
1084
|
+
const parts = [fieldKey, `${getPrismaFieldType(field)}${field.list ? "[]" : isNullableField(field) ? "?" : ""}`];
|
|
1085
|
+
const attributes = [];
|
|
1086
|
+
if (field.primaryKey) {
|
|
1087
|
+
attributes.push("@id");
|
|
1088
|
+
if (field.type === "id" && field.default === void 0) attributes.push("@default(cuid())");
|
|
1089
|
+
if (field.type === "uuid" && field.default === void 0) attributes.push("@default(uuid())");
|
|
1090
|
+
} else if (field.unique) attributes.push("@unique");
|
|
1091
|
+
const defaultAttribute = getPrismaDefaultAttribute(field);
|
|
1092
|
+
if (defaultAttribute) attributes.push(defaultAttribute);
|
|
1093
|
+
if (field.meta?.autoUpdate && field.type === "datetime") attributes.push("@updatedAt");
|
|
1094
|
+
if (field.name !== fieldKey) attributes.push(`@map("${escapeString(field.name)}")`);
|
|
1095
|
+
if (attributes.length) parts.push(attributes.join(" "));
|
|
1096
|
+
lines.push(` ${parts.join(" ")}`);
|
|
1097
|
+
if (field.index) modelLevelConstraints.push(`@@index([${fieldKey}], map: "${escapeString(`${model.modelName}_${field.name}_idx`)}")`);
|
|
1098
|
+
}
|
|
1099
|
+
for (const constraint of model.model.constraints || []) {
|
|
1100
|
+
const fields = constraint.fields.join(", ");
|
|
1101
|
+
const attribute = constraint.type === "unique" ? "@@unique" : "@@index";
|
|
1102
|
+
const suffix = constraint.name ? `, map: "${escapeString(constraint.name)}"` : "";
|
|
1103
|
+
modelLevelConstraints.push(`${attribute}([${fields}]${suffix})`);
|
|
1104
|
+
}
|
|
1105
|
+
lines.push(` @@map("${escapeString(model.modelName)}")`);
|
|
1106
|
+
for (const constraint of modelLevelConstraints) lines.push(` ${constraint}`);
|
|
1107
|
+
lines.push("}");
|
|
1108
|
+
return lines.join("\n");
|
|
1109
|
+
}
|
|
1110
|
+
function getPrismaFieldType(field) {
|
|
1111
|
+
switch (field.type) {
|
|
1112
|
+
case "boolean": return "Boolean";
|
|
1113
|
+
case "integer": return "Int";
|
|
1114
|
+
case "number": return "Float";
|
|
1115
|
+
case "datetime": return "DateTime";
|
|
1116
|
+
case "json": return "Json";
|
|
1117
|
+
default: return "String";
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
function getPrismaDefaultAttribute(field) {
|
|
1121
|
+
if (field.default === void 0) return null;
|
|
1122
|
+
if (field.type === "datetime" && field.default === "now") return "@default(now())";
|
|
1123
|
+
if (typeof field.default === "string") return `@default("${escapeString(field.default)}")`;
|
|
1124
|
+
if (typeof field.default === "number" || typeof field.default === "boolean") return `@default(${String(field.default)})`;
|
|
1125
|
+
return null;
|
|
1126
|
+
}
|
|
1127
|
+
function createPrismaGeneratedBlock(body) {
|
|
1128
|
+
return [
|
|
1129
|
+
PRISMA_GENERATED_START,
|
|
1130
|
+
body,
|
|
1131
|
+
PRISMA_GENERATED_END
|
|
1132
|
+
].join("\n");
|
|
1133
|
+
}
|
|
1134
|
+
function generateDrizzleSchema(models, dialect) {
|
|
1135
|
+
const tableFactoryName = dialect === "postgres" ? "pgTable" : dialect === "mysql" ? "mysqlTable" : "sqliteTable";
|
|
1136
|
+
const importSource = dialect === "postgres" ? "drizzle-orm/pg-core" : dialect === "mysql" ? "drizzle-orm/mysql-core" : "drizzle-orm/sqlite-core";
|
|
1137
|
+
const imports = /* @__PURE__ */ new Set([
|
|
1138
|
+
tableFactoryName,
|
|
1139
|
+
"index",
|
|
1140
|
+
"uniqueIndex"
|
|
1141
|
+
]);
|
|
1142
|
+
for (const model of models) for (const field of Object.values(model.model.fields)) for (const helper of getDrizzleImportNames(dialect, field)) imports.add(helper);
|
|
1143
|
+
const lines = [
|
|
1144
|
+
"// Generated by Farm.js CLI. Review before committing.",
|
|
1145
|
+
`import { ${Array.from(imports).sort().join(", ")} } from "${importSource}";`,
|
|
1146
|
+
""
|
|
1147
|
+
];
|
|
1148
|
+
for (const model of models) {
|
|
1149
|
+
lines.push(renderDrizzleModel(model, dialect, tableFactoryName));
|
|
1150
|
+
lines.push("");
|
|
1151
|
+
}
|
|
1152
|
+
return lines.join("\n").trimEnd() + "\n";
|
|
1153
|
+
}
|
|
1154
|
+
function renderDrizzleModel(model, dialect, tableFactoryName) {
|
|
1155
|
+
const lines = [`// Farm.js generated from integration "${model.integrationKey}" model "${model.modelKey}"`, `export const ${model.exportName} = ${tableFactoryName}("${model.modelName}", {`];
|
|
1156
|
+
for (const [fieldKey, field] of Object.entries(model.model.fields)) {
|
|
1157
|
+
if (field.reference) lines.push(` // ${fieldKey} references ${field.reference.model}.${field.reference.field}${field.reference.onDelete ? ` (onDelete: ${field.reference.onDelete})` : ""}`);
|
|
1158
|
+
lines.push(` ${fieldKey}: ${renderDrizzleColumn(field, dialect)},`);
|
|
1159
|
+
}
|
|
1160
|
+
lines.push("}, (table) => ({");
|
|
1161
|
+
for (const [fieldKey, field] of Object.entries(model.model.fields)) if (field.index) lines.push(` ${fieldKey}Idx: index("${escapeString(`${model.modelName}_${field.name}_idx`)}").on(table.${fieldKey}),`);
|
|
1162
|
+
for (const constraint of model.model.constraints || []) {
|
|
1163
|
+
const builder = constraint.type === "unique" ? "uniqueIndex" : "index";
|
|
1164
|
+
const accessor = constraint.fields.map((fieldKey) => `table.${fieldKey}`).join(", ");
|
|
1165
|
+
const name = constraint.name || `${model.modelName}_${constraint.fields.map((fieldKey) => model.model.fields[fieldKey]?.name || fieldKey).join("_")}_${constraint.type}`;
|
|
1166
|
+
lines.push(` ${toCamelCase(name)}: ${builder}("${escapeString(name)}").on(${accessor}),`);
|
|
1167
|
+
}
|
|
1168
|
+
lines.push("}));");
|
|
1169
|
+
return lines.join("\n");
|
|
1170
|
+
}
|
|
1171
|
+
function getDrizzleImportNames(dialect, field) {
|
|
1172
|
+
switch (dialect) {
|
|
1173
|
+
case "postgres": switch (field.type) {
|
|
1174
|
+
case "boolean": return ["boolean"];
|
|
1175
|
+
case "integer": return ["integer"];
|
|
1176
|
+
case "number": return ["real"];
|
|
1177
|
+
case "datetime": return ["timestamp"];
|
|
1178
|
+
case "json": return ["jsonb"];
|
|
1179
|
+
default: return ["text"];
|
|
1180
|
+
}
|
|
1181
|
+
case "mysql": switch (field.type) {
|
|
1182
|
+
case "boolean": return ["boolean"];
|
|
1183
|
+
case "integer": return ["int"];
|
|
1184
|
+
case "number": return ["double"];
|
|
1185
|
+
case "datetime": return ["datetime"];
|
|
1186
|
+
case "json": return ["json"];
|
|
1187
|
+
case "text": return ["text"];
|
|
1188
|
+
default: return ["varchar"];
|
|
1189
|
+
}
|
|
1190
|
+
case "sqlite": switch (field.type) {
|
|
1191
|
+
case "number": return ["real"];
|
|
1192
|
+
case "integer":
|
|
1193
|
+
case "boolean":
|
|
1194
|
+
case "datetime": return ["integer"];
|
|
1195
|
+
default: return ["text"];
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
function renderDrizzleColumn(field, dialect) {
|
|
1200
|
+
let expression;
|
|
1201
|
+
if (dialect === "postgres") expression = renderPostgresDrizzleColumn(field);
|
|
1202
|
+
else if (dialect === "mysql") expression = renderMysqlDrizzleColumn(field);
|
|
1203
|
+
else expression = renderSqliteDrizzleColumn(field);
|
|
1204
|
+
if (field.primaryKey) expression += ".primaryKey()";
|
|
1205
|
+
if (!field.primaryKey && !isNullableField(field)) expression += ".notNull()";
|
|
1206
|
+
if (!field.primaryKey && field.unique) expression += ".unique()";
|
|
1207
|
+
const defaultValue = getDrizzleDefaultExpression(field, dialect);
|
|
1208
|
+
if (defaultValue) expression += defaultValue;
|
|
1209
|
+
return expression;
|
|
1210
|
+
}
|
|
1211
|
+
function renderPostgresDrizzleColumn(field) {
|
|
1212
|
+
switch (field.type) {
|
|
1213
|
+
case "boolean": return `boolean("${field.name}")`;
|
|
1214
|
+
case "integer": return `integer("${field.name}")`;
|
|
1215
|
+
case "number": return `real("${field.name}")`;
|
|
1216
|
+
case "datetime": return `timestamp("${field.name}", { mode: "date" })`;
|
|
1217
|
+
case "json": return `jsonb("${field.name}")`;
|
|
1218
|
+
default: return `text("${field.name}")`;
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
function renderMysqlDrizzleColumn(field) {
|
|
1222
|
+
switch (field.type) {
|
|
1223
|
+
case "boolean": return `boolean("${field.name}")`;
|
|
1224
|
+
case "integer": return `int("${field.name}")`;
|
|
1225
|
+
case "number": return `double("${field.name}")`;
|
|
1226
|
+
case "datetime": return `datetime("${field.name}", { mode: "date" })`;
|
|
1227
|
+
case "json": return `json("${field.name}")`;
|
|
1228
|
+
case "text": return `text("${field.name}")`;
|
|
1229
|
+
default: return `varchar("${field.name}", { length: 255 })`;
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
function renderSqliteDrizzleColumn(field) {
|
|
1233
|
+
switch (field.type) {
|
|
1234
|
+
case "boolean": return `integer("${field.name}", { mode: "boolean" })`;
|
|
1235
|
+
case "integer": return `integer("${field.name}")`;
|
|
1236
|
+
case "number": return `real("${field.name}")`;
|
|
1237
|
+
case "datetime": return `integer("${field.name}", { mode: "timestamp_ms" })`;
|
|
1238
|
+
default: return `text("${field.name}")`;
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
function getDrizzleDefaultExpression(field, dialect) {
|
|
1242
|
+
if (field.default === void 0) return "";
|
|
1243
|
+
if (field.type === "datetime" && field.default === "now") return dialect === "sqlite" ? "" : ".defaultNow()";
|
|
1244
|
+
if (typeof field.default === "string") return `.default("${escapeString(field.default)}")`;
|
|
1245
|
+
if (typeof field.default === "number" || typeof field.default === "boolean") return `.default(${String(field.default)})`;
|
|
1246
|
+
return "";
|
|
1247
|
+
}
|
|
1248
|
+
function generateSqlSchema(models, dialect) {
|
|
1249
|
+
const lines = ["-- Generated by Farm.js CLI. Review before applying.", ""];
|
|
1250
|
+
const modelLookup = createModelLookup(models);
|
|
1251
|
+
for (const model of models) {
|
|
1252
|
+
lines.push(`-- Integration "${model.integrationKey}" model "${model.modelKey}"`, renderSqlTable(model, dialect, modelLookup), "");
|
|
1253
|
+
for (const statement of renderSqlIndexes(model, dialect)) lines.push(statement, "");
|
|
1254
|
+
}
|
|
1255
|
+
return lines.join("\n").trimEnd() + "\n";
|
|
1256
|
+
}
|
|
1257
|
+
function renderSqlTable(model, dialect, modelLookup) {
|
|
1258
|
+
const lines = [`CREATE TABLE IF NOT EXISTS ${quoteIdentifier(dialect, model.modelName)} (`];
|
|
1259
|
+
const columns = [];
|
|
1260
|
+
const internalReferences = createInternalReferenceLookup(model, dialect, modelLookup);
|
|
1261
|
+
for (const [fieldKey, field] of Object.entries(model.model.fields)) {
|
|
1262
|
+
const parts = [` ${quoteIdentifier(dialect, field.name)}`, getSqlColumnType(field, dialect)];
|
|
1263
|
+
if (field.primaryKey) parts.push("PRIMARY KEY");
|
|
1264
|
+
else if (!isNullableField(field)) parts.push("NOT NULL");
|
|
1265
|
+
if (!field.primaryKey && field.unique) parts.push("UNIQUE");
|
|
1266
|
+
const defaultValue = getSqlDefaultExpression(field, dialect);
|
|
1267
|
+
if (defaultValue) parts.push(`DEFAULT ${defaultValue}`);
|
|
1268
|
+
const reference = internalReferences.get(fieldKey);
|
|
1269
|
+
if (reference) parts.push(reference);
|
|
1270
|
+
else if (field.reference) parts.push(`/* references ${field.reference.model}.${field.reference.field}${field.reference.onDelete ? ` on delete ${field.reference.onDelete}` : ""} */`);
|
|
1271
|
+
columns.push(parts.join(" "));
|
|
1272
|
+
}
|
|
1273
|
+
lines.push(columns.join(",\n"));
|
|
1274
|
+
lines.push(");");
|
|
1275
|
+
return lines.join("\n");
|
|
1276
|
+
}
|
|
1277
|
+
function renderSqlIndexes(model, dialect) {
|
|
1278
|
+
const statements = [];
|
|
1279
|
+
const tableName = quoteIdentifier(dialect, model.modelName);
|
|
1280
|
+
for (const [fieldKey, field] of Object.entries(model.model.fields)) {
|
|
1281
|
+
if (!field.index) continue;
|
|
1282
|
+
const indexName = `${model.modelName}_${field.name}_idx`;
|
|
1283
|
+
statements.push(`CREATE INDEX IF NOT EXISTS ${quoteIdentifier(dialect, indexName)} ON ${tableName} (${quoteIdentifier(dialect, field.name)});`);
|
|
1284
|
+
}
|
|
1285
|
+
for (const constraint of model.model.constraints || []) {
|
|
1286
|
+
const indexName = constraint.name || `${model.modelName}_${constraint.fields.map((fieldKey) => model.model.fields[fieldKey]?.name || fieldKey).join("_")}_${constraint.type}`;
|
|
1287
|
+
const fields = constraint.fields.map((fieldKey) => quoteIdentifier(dialect, model.model.fields[fieldKey]?.name || fieldKey)).join(", ");
|
|
1288
|
+
if (constraint.type === "unique") statements.push(`CREATE UNIQUE INDEX IF NOT EXISTS ${quoteIdentifier(dialect, indexName)} ON ${tableName} (${fields});`);
|
|
1289
|
+
else statements.push(`CREATE INDEX IF NOT EXISTS ${quoteIdentifier(dialect, indexName)} ON ${tableName} (${fields});`);
|
|
1290
|
+
}
|
|
1291
|
+
return statements;
|
|
1292
|
+
}
|
|
1293
|
+
function createInternalReferenceLookup(model, dialect, modelLookup) {
|
|
1294
|
+
const lookup = /* @__PURE__ */ new Map();
|
|
1295
|
+
for (const [fieldKey, field] of Object.entries(model.model.fields)) {
|
|
1296
|
+
if (!field.reference) continue;
|
|
1297
|
+
const referencedModel = modelLookup.get(`${model.integrationKey}.${field.reference.model}`);
|
|
1298
|
+
if (referencedModel) {
|
|
1299
|
+
const referencedField = field.reference.field;
|
|
1300
|
+
const pieces = [`REFERENCES ${quoteIdentifier(dialect, referencedModel.modelName)} (${quoteIdentifier(dialect, referencedModel.model.fields[referencedField]?.name || toSnakeCase(referencedField))})`];
|
|
1301
|
+
if (field.reference.onDelete) pieces.push(`ON DELETE ${field.reference.onDelete.toUpperCase()}`);
|
|
1302
|
+
lookup.set(fieldKey, pieces.join(" "));
|
|
1303
|
+
}
|
|
1304
|
+
}
|
|
1305
|
+
return lookup;
|
|
1306
|
+
}
|
|
1307
|
+
function getSqlColumnType(field, dialect) {
|
|
1308
|
+
if (dialect === "postgres") switch (field.type) {
|
|
1309
|
+
case "boolean": return "BOOLEAN";
|
|
1310
|
+
case "integer": return "INTEGER";
|
|
1311
|
+
case "number": return "DOUBLE PRECISION";
|
|
1312
|
+
case "datetime": return "TIMESTAMPTZ";
|
|
1313
|
+
case "json": return "JSONB";
|
|
1314
|
+
default: return "TEXT";
|
|
1315
|
+
}
|
|
1316
|
+
if (dialect === "mysql") switch (field.type) {
|
|
1317
|
+
case "boolean": return "BOOLEAN";
|
|
1318
|
+
case "integer": return "INT";
|
|
1319
|
+
case "number": return "DOUBLE";
|
|
1320
|
+
case "datetime": return "DATETIME";
|
|
1321
|
+
case "json": return "JSON";
|
|
1322
|
+
case "text": return "TEXT";
|
|
1323
|
+
default: return "VARCHAR(255)";
|
|
1324
|
+
}
|
|
1325
|
+
switch (field.type) {
|
|
1326
|
+
case "boolean":
|
|
1327
|
+
case "integer": return "INTEGER";
|
|
1328
|
+
case "number": return "REAL";
|
|
1329
|
+
default: return "TEXT";
|
|
1330
|
+
}
|
|
1331
|
+
}
|
|
1332
|
+
function getSqlDefaultExpression(field, dialect) {
|
|
1333
|
+
if (field.default === void 0) return null;
|
|
1334
|
+
if (field.type === "datetime" && field.default === "now") return "CURRENT_TIMESTAMP";
|
|
1335
|
+
if (typeof field.default === "string") return `'${escapeString(field.default)}'`;
|
|
1336
|
+
if (typeof field.default === "number") return String(field.default);
|
|
1337
|
+
if (typeof field.default === "boolean") {
|
|
1338
|
+
if (dialect === "sqlite") return field.default ? "1" : "0";
|
|
1339
|
+
return field.default ? "TRUE" : "FALSE";
|
|
1340
|
+
}
|
|
1341
|
+
return null;
|
|
1342
|
+
}
|
|
1343
|
+
function generateMongoBootstrap(models) {
|
|
1344
|
+
const lines = [
|
|
1345
|
+
"// Generated by Farm.js CLI. Review before committing.",
|
|
1346
|
+
"import type { Db } from \"mongodb\";",
|
|
1347
|
+
"",
|
|
1348
|
+
"export async function ensureFarmIntegrationCollections(db: Db) {"
|
|
1349
|
+
];
|
|
1350
|
+
for (const model of models) {
|
|
1351
|
+
lines.push(` // Integration "${model.integrationKey}" model "${model.modelKey}"`);
|
|
1352
|
+
lines.push(` const ${model.exportName} = db.collection("${escapeString(model.modelName)}");`);
|
|
1353
|
+
for (const [fieldKey, field] of Object.entries(model.model.fields)) {
|
|
1354
|
+
if (field.unique) {
|
|
1355
|
+
const options = ["unique: true"];
|
|
1356
|
+
if (isNullableField(field)) options.push("sparse: true");
|
|
1357
|
+
options.push(`name: "${escapeString(`${model.modelName}_${field.name}_unique`)}"`);
|
|
1358
|
+
lines.push(` await ${model.exportName}.createIndex({ ${JSON.stringify(field.name)}: 1 }, { ${options.join(", ")} });`);
|
|
1359
|
+
} else if (field.index) lines.push(` await ${model.exportName}.createIndex({ ${JSON.stringify(field.name)}: 1 }, { name: "${escapeString(`${model.modelName}_${field.name}_idx`)}" });`);
|
|
1360
|
+
if (field.reference) lines.push(` // ${fieldKey} references ${field.reference.model}.${field.reference.field}${field.reference.onDelete ? ` (onDelete: ${field.reference.onDelete})` : ""}`);
|
|
1361
|
+
}
|
|
1362
|
+
for (const constraint of model.model.constraints || []) {
|
|
1363
|
+
const indexSpec = constraint.fields.map((fieldKey) => `${JSON.stringify(model.model.fields[fieldKey]?.name || fieldKey)}: 1`).join(", ");
|
|
1364
|
+
const indexName = constraint.name || `${model.modelName}_${constraint.fields.map((fieldKey) => model.model.fields[fieldKey]?.name || fieldKey).join("_")}_${constraint.type}`;
|
|
1365
|
+
const options = constraint.type === "unique" ? `{ unique: true, name: "${escapeString(indexName)}" }` : `{ name: "${escapeString(indexName)}" }`;
|
|
1366
|
+
lines.push(` await ${model.exportName}.createIndex({ ${indexSpec} }, ${options});`);
|
|
1367
|
+
}
|
|
1368
|
+
lines.push("");
|
|
1369
|
+
}
|
|
1370
|
+
lines.push("}");
|
|
1371
|
+
return lines.join("\n").trimEnd() + "\n";
|
|
1372
|
+
}
|
|
1373
|
+
async function writeGeneratedFile(filePath, contents) {
|
|
1374
|
+
await (0, node_fs_promises.mkdir)(node_path.default.dirname(filePath), { recursive: true });
|
|
1375
|
+
await (0, node_fs_promises.writeFile)(filePath, contents, "utf8");
|
|
1376
|
+
}
|
|
1377
|
+
function isNullableField(field) {
|
|
1378
|
+
return field.nullable === true || field.required === false;
|
|
1379
|
+
}
|
|
1380
|
+
function quoteIdentifier(dialect, value) {
|
|
1381
|
+
if (dialect === "mysql") return `\`${value.replace(/`/g, "``")}\``;
|
|
1382
|
+
return `"${value.replace(/"/g, "\"\"")}"`;
|
|
1383
|
+
}
|
|
1384
|
+
function createModelLookup(models) {
|
|
1385
|
+
return new Map(models.map((model) => [`${model.integrationKey}.${model.modelKey}`, model]));
|
|
1386
|
+
}
|
|
1387
|
+
function toSnakeCase(value) {
|
|
1388
|
+
return value.replace(/([a-z0-9])([A-Z])/g, "$1_$2").replace(/[\s.-]+/g, "_").replace(/__+/g, "_").toLowerCase();
|
|
1389
|
+
}
|
|
1390
|
+
function toPascalCase(value) {
|
|
1391
|
+
return value.replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/[\s._-]+/g, " ").split(" ").filter(Boolean).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join("");
|
|
1392
|
+
}
|
|
1393
|
+
function toCamelCase(value) {
|
|
1394
|
+
const pascal = toPascalCase(value);
|
|
1395
|
+
return pascal ? pascal.charAt(0).toLowerCase() + pascal.slice(1) : pascal;
|
|
1396
|
+
}
|
|
1397
|
+
function escapeString(value) {
|
|
1398
|
+
return value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"").replace(/'/g, "''");
|
|
1399
|
+
}
|
|
1400
|
+
function escapeRegExp(value) {
|
|
1401
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1402
|
+
}
|
|
1403
|
+
//#endregion
|
|
1404
|
+
//#region src/doctor.ts
|
|
1405
|
+
const ROUTE_EXTENSIONS = [
|
|
1406
|
+
"ts",
|
|
1407
|
+
"tsx",
|
|
1408
|
+
"js",
|
|
1409
|
+
"jsx",
|
|
1410
|
+
"md",
|
|
1411
|
+
"mdx"
|
|
1412
|
+
];
|
|
1413
|
+
const CONFIG_FILES = [
|
|
1414
|
+
"farm.config.ts",
|
|
1415
|
+
"farm.config.mts",
|
|
1416
|
+
"farm.config.js",
|
|
1417
|
+
"farm.config.mjs",
|
|
1418
|
+
"config.ts",
|
|
1419
|
+
"config.mts",
|
|
1420
|
+
"config.js",
|
|
1421
|
+
"config.mjs"
|
|
1422
|
+
];
|
|
1423
|
+
async function runFarmDoctor(options = {}) {
|
|
1424
|
+
const root = node_path.default.resolve(options.root || process.cwd());
|
|
1425
|
+
const liveTarget = resolveLiveTarget(options);
|
|
1426
|
+
let liveError;
|
|
1427
|
+
if (!options.offline) try {
|
|
1428
|
+
return createLiveReport(await fetchLiveSnapshot(liveTarget, options), liveTarget, options.now);
|
|
1429
|
+
} catch (error) {
|
|
1430
|
+
liveError = formatError$1(error);
|
|
1431
|
+
}
|
|
1432
|
+
const report = await createProjectReport(root, options);
|
|
1433
|
+
if (!options.offline && hasExplicitLiveTarget(options) && liveError) {
|
|
1434
|
+
report.checks.unshift({
|
|
1435
|
+
status: "warn",
|
|
1436
|
+
code: "LIVE_RUNTIME_UNREACHABLE",
|
|
1437
|
+
title: "Running app was not reachable",
|
|
1438
|
+
message: liveError,
|
|
1439
|
+
action: `Start farm dev or verify ${liveTarget}.`
|
|
1440
|
+
});
|
|
1441
|
+
report.target = {
|
|
1442
|
+
...report.target,
|
|
1443
|
+
url: liveTarget
|
|
1444
|
+
};
|
|
1445
|
+
finalizeReport(report);
|
|
1446
|
+
}
|
|
1447
|
+
return report;
|
|
1448
|
+
}
|
|
1449
|
+
function formatFarmDoctorReport(report, options = {}) {
|
|
1450
|
+
const color = options.color === void 0 ? picocolors.default : picocolors.default.createColors(options.color);
|
|
1451
|
+
const statusStyle = {
|
|
1452
|
+
pass: color.inverse,
|
|
1453
|
+
warn: color.yellow,
|
|
1454
|
+
fail: color.red,
|
|
1455
|
+
info: color.cyan
|
|
1456
|
+
};
|
|
1457
|
+
const lines = [
|
|
1458
|
+
`${color.bold("FARM")} ${color.dim("/")} ${color.bold("DOCTOR")}`,
|
|
1459
|
+
`${color.white(report.project.name)} ${color.dim("/")} ${color.dim(report.source === "live" ? "LIVE RUNTIME" : "PROJECT")}`,
|
|
1460
|
+
""
|
|
1461
|
+
];
|
|
1462
|
+
for (const check of report.checks) {
|
|
1463
|
+
const label = check.status.toUpperCase().padEnd(4);
|
|
1464
|
+
lines.push(`${statusStyle[check.status](label)} ${color.bold(check.title)}`);
|
|
1465
|
+
lines.push(` ${color.dim(check.message)}`);
|
|
1466
|
+
if (check.action) lines.push(` ${color.dim(`Next: ${check.action}`)}`);
|
|
1467
|
+
}
|
|
1468
|
+
const summary = [
|
|
1469
|
+
`${report.summary.pass} passed`,
|
|
1470
|
+
`${report.summary.warn} warning${report.summary.warn === 1 ? "" : "s"}`,
|
|
1471
|
+
`${report.summary.fail} failed`,
|
|
1472
|
+
`${report.summary.info} info`
|
|
1473
|
+
].join(" / ");
|
|
1474
|
+
lines.push("", `${color.bold("SUMMARY")} ${summary}`);
|
|
1475
|
+
if (report.target?.devtoolsUrl) lines.push(`${color.bold("DEVTOOLS")} ${report.target.devtoolsUrl}`);
|
|
1476
|
+
return lines.join("\n");
|
|
1477
|
+
}
|
|
1478
|
+
async function fetchLiveSnapshot(baseUrl, options) {
|
|
1479
|
+
const controller = new AbortController();
|
|
1480
|
+
const timeoutMs = options.timeoutMs ?? 1200;
|
|
1481
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
1482
|
+
timeout.unref?.();
|
|
1483
|
+
try {
|
|
1484
|
+
const response = await (options.fetch || globalThis.fetch)(`${baseUrl}/__farm/devtools.json`, {
|
|
1485
|
+
headers: { accept: "application/json" },
|
|
1486
|
+
signal: controller.signal
|
|
1487
|
+
});
|
|
1488
|
+
if (!response.ok) throw new Error(`Devtools returned ${response.status} from ${baseUrl}.`);
|
|
1489
|
+
const value = await response.json();
|
|
1490
|
+
if (!isLiveSnapshot(value)) throw new Error(`Devtools at ${baseUrl} returned an unsupported snapshot.`);
|
|
1491
|
+
return value;
|
|
1492
|
+
} catch (error) {
|
|
1493
|
+
if (error instanceof Error && error.name === "AbortError") throw new Error(`Timed out after ${timeoutMs}ms while probing ${baseUrl}.`);
|
|
1494
|
+
if (error instanceof TypeError) throw new Error(`Could not connect to ${baseUrl}.`);
|
|
1495
|
+
throw error;
|
|
1496
|
+
} finally {
|
|
1497
|
+
clearTimeout(timeout);
|
|
1498
|
+
}
|
|
1499
|
+
}
|
|
1500
|
+
function createLiveReport(snapshot, baseUrl, now) {
|
|
1501
|
+
const checks = [
|
|
1502
|
+
{
|
|
1503
|
+
status: "pass",
|
|
1504
|
+
code: "LIVE_RUNTIME_READY",
|
|
1505
|
+
title: "Connected to the Farm runtime",
|
|
1506
|
+
message: `${formatCount$1(snapshot.counts.pages, "page")}, ${formatCount$1(snapshot.counts.apiRoutes, "API route")}, and ${formatCount$1(snapshot.counts.middleware, "middleware layer")} are registered.`
|
|
1507
|
+
},
|
|
1508
|
+
{
|
|
1509
|
+
status: "pass",
|
|
1510
|
+
code: "DEPLOYMENT_RESOLVED",
|
|
1511
|
+
title: "Deployment target is resolved",
|
|
1512
|
+
message: `${snapshot.deployment.target} uses the ${snapshot.deployment.preset} preset.`
|
|
1513
|
+
},
|
|
1514
|
+
{
|
|
1515
|
+
status: "info",
|
|
1516
|
+
code: "PRODUCT_SYSTEMS_DISCOVERED",
|
|
1517
|
+
title: "Product systems are visible",
|
|
1518
|
+
message: `${formatCount$1(snapshot.counts.integrations, "integration")}, ${formatCount$1(snapshot.counts.storageMounts, "storage mount")}, ${formatCount$1(snapshot.counts.cronJobs, "cron route")}, and ${formatCount$1(snapshot.counts.workflows, "workflow")}.`
|
|
1519
|
+
},
|
|
1520
|
+
...snapshot.diagnostics.map((diagnostic) => ({
|
|
1521
|
+
status: diagnostic.severity === "error" ? "fail" : diagnostic.severity === "warning" ? "warn" : "info",
|
|
1522
|
+
code: diagnostic.code,
|
|
1523
|
+
title: diagnostic.title,
|
|
1524
|
+
message: diagnostic.message,
|
|
1525
|
+
...diagnostic.action ? { action: diagnostic.action } : {}
|
|
1526
|
+
}))
|
|
1527
|
+
];
|
|
1528
|
+
const report = {
|
|
1529
|
+
generatedAt: snapshot.generatedAt || (now?.() || /* @__PURE__ */ new Date()).toISOString(),
|
|
1530
|
+
source: "live",
|
|
1531
|
+
health: "ready",
|
|
1532
|
+
project: snapshot.project,
|
|
1533
|
+
target: {
|
|
1534
|
+
url: baseUrl,
|
|
1535
|
+
devtoolsUrl: `${baseUrl}/__farm/devtools`,
|
|
1536
|
+
deployment: snapshot.deployment.target,
|
|
1537
|
+
preset: snapshot.deployment.preset
|
|
1538
|
+
},
|
|
1539
|
+
runtime: { ...snapshot.counts },
|
|
1540
|
+
summary: emptySummary(),
|
|
1541
|
+
checks
|
|
1542
|
+
};
|
|
1543
|
+
finalizeReport(report);
|
|
1544
|
+
return report;
|
|
1545
|
+
}
|
|
1546
|
+
async function createProjectReport(root, options) {
|
|
1547
|
+
const checks = [];
|
|
1548
|
+
const report = {
|
|
1549
|
+
generatedAt: (options.now?.() || /* @__PURE__ */ new Date()).toISOString(),
|
|
1550
|
+
source: "project",
|
|
1551
|
+
health: "ready",
|
|
1552
|
+
project: {
|
|
1553
|
+
name: node_path.default.basename(root),
|
|
1554
|
+
root
|
|
1555
|
+
},
|
|
1556
|
+
summary: emptySummary(),
|
|
1557
|
+
checks
|
|
1558
|
+
};
|
|
1559
|
+
collectNodeCheck(checks);
|
|
1560
|
+
collectPackageCheck(root, checks);
|
|
1561
|
+
let config;
|
|
1562
|
+
let userConfig;
|
|
1563
|
+
try {
|
|
1564
|
+
userConfig = await (0, _farm_js_core.loadConfig)(root, options.configPath, "development");
|
|
1565
|
+
if (!userConfig) checks.push({
|
|
1566
|
+
status: "fail",
|
|
1567
|
+
code: "CONFIG_MISSING",
|
|
1568
|
+
title: "Farm config was not found",
|
|
1569
|
+
message: `No Farm config exists under ${root}.`,
|
|
1570
|
+
action: "Add farm.config.ts and export defineConfig({...})."
|
|
1571
|
+
});
|
|
1572
|
+
else {
|
|
1573
|
+
config = await (0, _farm_js_core.resolveConfig)({
|
|
1574
|
+
root,
|
|
1575
|
+
...userConfig
|
|
1576
|
+
}, "development");
|
|
1577
|
+
const configFile = findConfigFile(root, options.configPath);
|
|
1578
|
+
checks.push({
|
|
1579
|
+
status: "pass",
|
|
1580
|
+
code: "CONFIG_VALID",
|
|
1581
|
+
title: "Farm config loads successfully",
|
|
1582
|
+
message: configFile ? node_path.default.relative(root, configFile) || node_path.default.basename(configFile) : "Resolved config"
|
|
1583
|
+
});
|
|
1584
|
+
}
|
|
1585
|
+
} catch (error) {
|
|
1586
|
+
checks.push({
|
|
1587
|
+
status: "fail",
|
|
1588
|
+
code: "CONFIG_INVALID",
|
|
1589
|
+
title: "Farm config could not be resolved",
|
|
1590
|
+
message: formatError$1(error),
|
|
1591
|
+
action: "Fix the config or environment validation error, then run farm doctor again."
|
|
1592
|
+
});
|
|
1593
|
+
}
|
|
1594
|
+
if (config && userConfig) {
|
|
1595
|
+
collectRouterChecks(config, checks);
|
|
1596
|
+
report.target = collectDeploymentChecks(config, userConfig, checks);
|
|
1597
|
+
collectCronChecks(config, options.env || process.env, checks);
|
|
1598
|
+
}
|
|
1599
|
+
finalizeReport(report);
|
|
1600
|
+
return report;
|
|
1601
|
+
}
|
|
1602
|
+
function collectNodeCheck(checks) {
|
|
1603
|
+
const major = Number(process.versions.node.split(".")[0]);
|
|
1604
|
+
checks.push(major >= 18 ? {
|
|
1605
|
+
status: "pass",
|
|
1606
|
+
code: "NODE_SUPPORTED",
|
|
1607
|
+
title: "Node.js is supported",
|
|
1608
|
+
message: `Node ${process.versions.node} satisfies Farm's Node 18+ baseline.`
|
|
1609
|
+
} : {
|
|
1610
|
+
status: "fail",
|
|
1611
|
+
code: "NODE_UNSUPPORTED",
|
|
1612
|
+
title: "Node.js is too old",
|
|
1613
|
+
message: `Node ${process.versions.node} does not satisfy Farm's Node 18+ baseline.`,
|
|
1614
|
+
action: "Upgrade Node.js to version 18 or newer."
|
|
1615
|
+
});
|
|
1616
|
+
}
|
|
1617
|
+
function collectPackageCheck(root, checks) {
|
|
1618
|
+
const packagePath = node_path.default.join(root, "package.json");
|
|
1619
|
+
if (!(0, node_fs.existsSync)(packagePath)) {
|
|
1620
|
+
checks.push({
|
|
1621
|
+
status: "fail",
|
|
1622
|
+
code: "PACKAGE_MISSING",
|
|
1623
|
+
title: "package.json was not found",
|
|
1624
|
+
message: `No package manifest exists under ${root}.`,
|
|
1625
|
+
action: "Run the command from a Farm application root."
|
|
1626
|
+
});
|
|
1627
|
+
return;
|
|
1628
|
+
}
|
|
1629
|
+
try {
|
|
1630
|
+
const manifest = JSON.parse((0, node_fs.readFileSync)(packagePath, "utf8"));
|
|
1631
|
+
const version = {
|
|
1632
|
+
...asRecord(manifest.dependencies),
|
|
1633
|
+
...asRecord(manifest.devDependencies),
|
|
1634
|
+
...asRecord(manifest.peerDependencies)
|
|
1635
|
+
}["@farm.js/core"];
|
|
1636
|
+
checks.push(typeof version === "string" ? {
|
|
1637
|
+
status: "pass",
|
|
1638
|
+
code: "CORE_INSTALLED",
|
|
1639
|
+
title: "Farm core is installed",
|
|
1640
|
+
message: `@farm.js/core ${version}`
|
|
1641
|
+
} : {
|
|
1642
|
+
status: "fail",
|
|
1643
|
+
code: "CORE_MISSING",
|
|
1644
|
+
title: "Farm core is not declared",
|
|
1645
|
+
message: "@farm.js/core is missing from package.json.",
|
|
1646
|
+
action: "Install @farm.js/core and add it to the application dependencies."
|
|
1647
|
+
});
|
|
1648
|
+
} catch (error) {
|
|
1649
|
+
checks.push({
|
|
1650
|
+
status: "fail",
|
|
1651
|
+
code: "PACKAGE_INVALID",
|
|
1652
|
+
title: "package.json is invalid",
|
|
1653
|
+
message: formatError$1(error),
|
|
1654
|
+
action: "Fix the package manifest JSON."
|
|
1655
|
+
});
|
|
1656
|
+
}
|
|
1657
|
+
}
|
|
1658
|
+
function collectRouterChecks(config, checks) {
|
|
1659
|
+
const sources = (0, _farm_js_core.getFarmSourceRoots)(config);
|
|
1660
|
+
const appDirectories = sources.map((source) => node_path.default.join(source.root, source.srcDir, "app"));
|
|
1661
|
+
const hasPages = appDirectories.some((directory) => containsFile(directory, /^page\.(?:ts|tsx|js|jsx|md|mdx)$/));
|
|
1662
|
+
const hasProgrammaticRoutes = sources.some((source) => ROUTE_EXTENSIONS.some((extension) => (0, node_fs.existsSync)(node_path.default.join(source.root, source.srcDir, `farm.routes.${extension}`))));
|
|
1663
|
+
checks.push(hasPages || hasProgrammaticRoutes ? {
|
|
1664
|
+
status: "pass",
|
|
1665
|
+
code: "APP_ROUTER_READY",
|
|
1666
|
+
title: "App router has route modules",
|
|
1667
|
+
message: `${config.srcDir}/app and extended layers are discoverable.`
|
|
1668
|
+
} : {
|
|
1669
|
+
status: "fail",
|
|
1670
|
+
code: "NO_PAGE_ROUTES",
|
|
1671
|
+
title: "No page routes were found",
|
|
1672
|
+
message: `Farm found no page modules under ${config.srcDir}/app.`,
|
|
1673
|
+
action: `Add ${config.srcDir}/app/page.tsx or ${config.srcDir}/farm.routes.tsx.`
|
|
1674
|
+
});
|
|
1675
|
+
const hasRootLayout = appDirectories.some((directory) => ROUTE_EXTENSIONS.some((extension) => (0, node_fs.existsSync)(node_path.default.join(directory, `layout.${extension}`))));
|
|
1676
|
+
checks.push(hasRootLayout ? {
|
|
1677
|
+
status: "pass",
|
|
1678
|
+
code: "ROOT_LAYOUT_READY",
|
|
1679
|
+
title: "Root layout is present",
|
|
1680
|
+
message: "Shared metadata and application chrome have a root boundary."
|
|
1681
|
+
} : {
|
|
1682
|
+
status: "warn",
|
|
1683
|
+
code: "ROOT_LAYOUT_MISSING",
|
|
1684
|
+
title: "Root layout is missing",
|
|
1685
|
+
message: "The application has no shared root layout.",
|
|
1686
|
+
action: `Add ${config.srcDir}/app/layout.tsx.`
|
|
1687
|
+
});
|
|
1688
|
+
}
|
|
1689
|
+
function collectDeploymentChecks(config, userConfig, checks) {
|
|
1690
|
+
const target = String(config.deploy.target || "node");
|
|
1691
|
+
const preset = String(config.deploy.preset || config.preset || "node-server");
|
|
1692
|
+
checks.push({
|
|
1693
|
+
status: "pass",
|
|
1694
|
+
code: "DEPLOYMENT_RESOLVED",
|
|
1695
|
+
title: "Deployment target is resolved",
|
|
1696
|
+
message: `${target} uses the ${preset} preset and writes to ${config.deploy.outputDir}.`
|
|
1697
|
+
});
|
|
1698
|
+
const integrations = Object.values(config.integrations || {}).filter(Boolean).length;
|
|
1699
|
+
const storageMounts = getStorageMountCount(config.storage);
|
|
1700
|
+
checks.push({
|
|
1701
|
+
status: "info",
|
|
1702
|
+
code: "PRODUCT_SYSTEMS_DISCOVERED",
|
|
1703
|
+
title: "Product systems are configured",
|
|
1704
|
+
message: `${formatCount$1(integrations, "integration")} and ${formatCount$1(storageMounts, "storage mount")}.`
|
|
1705
|
+
});
|
|
1706
|
+
if (userConfig.storage && describeRootStorageDriver(userConfig.storage) === "memory" && [
|
|
1707
|
+
"vercel",
|
|
1708
|
+
"cloudflare",
|
|
1709
|
+
"netlify"
|
|
1710
|
+
].includes(target)) checks.push({
|
|
1711
|
+
status: "warn",
|
|
1712
|
+
code: "EPHEMERAL_PRODUCTION_STORAGE",
|
|
1713
|
+
title: "Production storage is in memory",
|
|
1714
|
+
message: `${target} instances do not preserve in-memory data across executions.`,
|
|
1715
|
+
action: "Configure a durable root storage driver for production state."
|
|
1716
|
+
});
|
|
1717
|
+
return {
|
|
1718
|
+
deployment: target,
|
|
1719
|
+
preset
|
|
1720
|
+
};
|
|
1721
|
+
}
|
|
1722
|
+
function collectCronChecks(config, env, checks) {
|
|
1723
|
+
const cron = config.cron;
|
|
1724
|
+
if (!cron.jobs.length) return;
|
|
1725
|
+
const missingRoutes = cron.jobs.filter((job) => !hasCronRoute(config, job));
|
|
1726
|
+
for (const job of missingRoutes) checks.push({
|
|
1727
|
+
status: "warn",
|
|
1728
|
+
code: "CRON_ROUTE_MISSING",
|
|
1729
|
+
title: `Cron route ${job.path} was not found`,
|
|
1730
|
+
message: `${job.name} is scheduled, but its GET API route is not present in the app directory.`,
|
|
1731
|
+
action: "Add the target API route or update the cron path in farm.config.ts."
|
|
1732
|
+
});
|
|
1733
|
+
if (!env[cron.secretEnv]) checks.push({
|
|
1734
|
+
status: "info",
|
|
1735
|
+
code: "CRON_SECRET_NOT_SET",
|
|
1736
|
+
title: `${cron.secretEnv} is not set`,
|
|
1737
|
+
message: "Local manual runs remain available, but production cron routes fail closed.",
|
|
1738
|
+
action: `Set ${cron.secretEnv} in the deployment environment before production.`
|
|
1739
|
+
});
|
|
1740
|
+
}
|
|
1741
|
+
function hasCronRoute(config, job) {
|
|
1742
|
+
const relative = job.path.replace(/^\/+/, "").replace(/^api\//, "");
|
|
1743
|
+
return (0, _farm_js_core.getFarmSourceRoots)(config).some((source) => {
|
|
1744
|
+
const directory = node_path.default.join(source.root, source.srcDir, "app", "api", relative);
|
|
1745
|
+
return ROUTE_EXTENSIONS.some((extension) => (0, node_fs.existsSync)(node_path.default.join(directory, `route.${extension}`)));
|
|
1746
|
+
});
|
|
1747
|
+
}
|
|
1748
|
+
function containsFile(directory, pattern) {
|
|
1749
|
+
if (!(0, node_fs.existsSync)(directory)) return false;
|
|
1750
|
+
const pending = [directory];
|
|
1751
|
+
while (pending.length) {
|
|
1752
|
+
const current = pending.pop();
|
|
1753
|
+
if (!current) continue;
|
|
1754
|
+
for (const entry of (0, node_fs.readdirSync)(current, { withFileTypes: true })) {
|
|
1755
|
+
if (entry.isFile() && pattern.test(entry.name)) return true;
|
|
1756
|
+
if (entry.isDirectory() && !entry.name.startsWith(".")) pending.push(node_path.default.join(current, entry.name));
|
|
1757
|
+
}
|
|
1758
|
+
}
|
|
1759
|
+
return false;
|
|
1760
|
+
}
|
|
1761
|
+
function getStorageMountCount(storage) {
|
|
1762
|
+
if (!storage || typeof storage !== "object") return 1;
|
|
1763
|
+
const mounts = asRecord(storage.mounts);
|
|
1764
|
+
return 1 + Object.keys(mounts).length;
|
|
1765
|
+
}
|
|
1766
|
+
function describeRootStorageDriver(storage) {
|
|
1767
|
+
if (!storage || typeof storage !== "object") return "memory";
|
|
1768
|
+
const value = storage;
|
|
1769
|
+
if (value.kind === "farm-storage-client") return "storage client";
|
|
1770
|
+
if (value.client) return "storage client";
|
|
1771
|
+
if (typeof value.driver === "string") return value.driver;
|
|
1772
|
+
if (typeof value.driver === "function") return "custom";
|
|
1773
|
+
return "memory";
|
|
1774
|
+
}
|
|
1775
|
+
function findConfigFile(root, configPath) {
|
|
1776
|
+
return (configPath ? [configPath, ...CONFIG_FILES] : CONFIG_FILES).map((candidate) => node_path.default.isAbsolute(candidate) ? candidate : node_path.default.join(root, candidate)).find(node_fs.existsSync);
|
|
1777
|
+
}
|
|
1778
|
+
function resolveLiveTarget(options) {
|
|
1779
|
+
return (options.url || `http://${options.host || "localhost"}:${options.port || 3e3}`).replace(/\/+$/, "");
|
|
1780
|
+
}
|
|
1781
|
+
function hasExplicitLiveTarget(options) {
|
|
1782
|
+
return Boolean(options.url || options.host || options.port);
|
|
1783
|
+
}
|
|
1784
|
+
function isLiveSnapshot(value) {
|
|
1785
|
+
if (!value || typeof value !== "object") return false;
|
|
1786
|
+
const snapshot = value;
|
|
1787
|
+
return Boolean(snapshot.project && typeof snapshot.project.name === "string" && snapshot.deployment && typeof snapshot.deployment.target === "string" && snapshot.counts && typeof snapshot.counts.pages === "number" && Array.isArray(snapshot.diagnostics));
|
|
1788
|
+
}
|
|
1789
|
+
function emptySummary() {
|
|
1790
|
+
return {
|
|
1791
|
+
pass: 0,
|
|
1792
|
+
warn: 0,
|
|
1793
|
+
fail: 0,
|
|
1794
|
+
info: 0
|
|
1795
|
+
};
|
|
1796
|
+
}
|
|
1797
|
+
function finalizeReport(report) {
|
|
1798
|
+
const summary = emptySummary();
|
|
1799
|
+
for (const check of report.checks) summary[check.status] += 1;
|
|
1800
|
+
report.summary = summary;
|
|
1801
|
+
report.health = summary.fail > 0 ? "error" : summary.warn > 0 ? "attention" : "ready";
|
|
1802
|
+
}
|
|
1803
|
+
function asRecord(value) {
|
|
1804
|
+
return value && typeof value === "object" ? value : {};
|
|
1805
|
+
}
|
|
1806
|
+
function formatError$1(error) {
|
|
1807
|
+
return error instanceof Error ? error.message : String(error);
|
|
1808
|
+
}
|
|
1809
|
+
function formatCount$1(value, noun) {
|
|
1810
|
+
return `${value} ${noun}${value === 1 ? "" : "s"}`;
|
|
1811
|
+
}
|
|
1812
|
+
//#endregion
|
|
1813
|
+
//#region src/cron.ts
|
|
1814
|
+
async function loadFarmCronConfig(options = {}) {
|
|
1815
|
+
const root = node_path.default.resolve(options.root || process.cwd());
|
|
1816
|
+
const userConfig = await (0, _farm_js_core.loadConfig)(root, options.configPath, "development");
|
|
1817
|
+
if (!userConfig) throw new Error("No Farm config found. Create farm.config.ts before configuring cron routes.");
|
|
1818
|
+
return (await (0, _farm_js_core.resolveConfig)({
|
|
1819
|
+
root,
|
|
1820
|
+
...userConfig
|
|
1821
|
+
}, "development")).cron;
|
|
1822
|
+
}
|
|
1823
|
+
async function listFarmCronJobs(options = {}) {
|
|
1824
|
+
return (await loadFarmCronConfig(options)).jobs;
|
|
1825
|
+
}
|
|
1826
|
+
function formatFarmCronJobs(jobs) {
|
|
1827
|
+
if (jobs.length === 0) return "No cron routes configured in farm.config.ts.";
|
|
1828
|
+
const rows = jobs.map((job) => [
|
|
1829
|
+
job.name,
|
|
1830
|
+
job.schedule.join(", "),
|
|
1831
|
+
job.path,
|
|
1832
|
+
job.description || ""
|
|
1833
|
+
]);
|
|
1834
|
+
const headings = [
|
|
1835
|
+
"NAME",
|
|
1836
|
+
"SCHEDULE (UTC)",
|
|
1837
|
+
"ROUTE",
|
|
1838
|
+
"DESCRIPTION"
|
|
1839
|
+
];
|
|
1840
|
+
const widths = headings.map((heading, column) => Math.max(heading.length, ...rows.map((row) => row[column].length)));
|
|
1841
|
+
return [headings, ...rows].map((row) => row.map((value, column) => value.padEnd(widths[column])).join(" ").trimEnd()).join("\n");
|
|
1842
|
+
}
|
|
1843
|
+
async function runFarmCronJob(name, options = {}) {
|
|
1844
|
+
const cron = await loadFarmCronConfig(options);
|
|
1845
|
+
const job = cron.jobs.find((entry) => entry.name === name);
|
|
1846
|
+
if (!job) {
|
|
1847
|
+
const available = cron.jobs.map((entry) => entry.name).join(", ");
|
|
1848
|
+
throw new Error(available ? `Cron route ${JSON.stringify(name)} was not found. Available routes: ${available}.` : `Cron route ${JSON.stringify(name)} was not found. No cron routes are configured.`);
|
|
1849
|
+
}
|
|
1850
|
+
return invokeFarmCronJob(job, cron, options);
|
|
1851
|
+
}
|
|
1852
|
+
async function startFarmCronScheduler(options = {}) {
|
|
1853
|
+
const cron = await loadFarmCronConfig(options);
|
|
1854
|
+
const entries = [];
|
|
1855
|
+
for (const job of cron.jobs) for (const schedule of job.schedule) {
|
|
1856
|
+
const timer = new croner.Cron(schedule, {
|
|
1857
|
+
name: `farm:cron:${job.name}:${schedule}`,
|
|
1858
|
+
timezone: "UTC",
|
|
1859
|
+
protect: () => {
|
|
1860
|
+
_farm_js_core.logger.warn(`Cron ${job.name} skipped an overlapping run.`);
|
|
1861
|
+
},
|
|
1862
|
+
catch: (error) => {
|
|
1863
|
+
_farm_js_core.logger.error(`Cron ${job.name} failed: ${formatError(error)}`);
|
|
1864
|
+
}
|
|
1865
|
+
}, async () => {
|
|
1866
|
+
_farm_js_core.logger.info(`Cron ${job.name} -> ${job.path}`);
|
|
1867
|
+
const result = await invokeFarmCronJob(job, cron, {
|
|
1868
|
+
...options,
|
|
1869
|
+
trigger: "development"
|
|
1870
|
+
});
|
|
1871
|
+
_farm_js_core.logger.success(`Cron ${job.name} completed in ${result.durationMs}ms.`);
|
|
1872
|
+
});
|
|
1873
|
+
entries.push({
|
|
1874
|
+
job,
|
|
1875
|
+
schedule,
|
|
1876
|
+
timer
|
|
1877
|
+
});
|
|
1878
|
+
}
|
|
1879
|
+
if (entries.length === 0) _farm_js_core.logger.info("No cron routes are configured; the development scheduler is idle.");
|
|
1880
|
+
else {
|
|
1881
|
+
_farm_js_core.logger.info(`Development cron scheduler started with ${entries.length} UTC schedule${entries.length === 1 ? "" : "s"}.`);
|
|
1882
|
+
for (const entry of entries) {
|
|
1883
|
+
const nextRun = entry.timer.nextRun();
|
|
1884
|
+
_farm_js_core.logger.info(`Cron ${entry.job.name} (${entry.schedule}) next runs ${nextRun?.toISOString() || "never"}.`);
|
|
1885
|
+
}
|
|
1886
|
+
}
|
|
1887
|
+
return {
|
|
1888
|
+
entries,
|
|
1889
|
+
stop() {
|
|
1890
|
+
for (const entry of entries) entry.timer.stop();
|
|
1891
|
+
}
|
|
1892
|
+
};
|
|
1893
|
+
}
|
|
1894
|
+
async function invokeFarmCronJob(job, cron, options) {
|
|
1895
|
+
const target = resolveCronTarget(job, options);
|
|
1896
|
+
const headers = new Headers({
|
|
1897
|
+
accept: "application/json",
|
|
1898
|
+
"x-farm-cron-name": job.name,
|
|
1899
|
+
"x-farm-cron-trigger": options.trigger || "manual"
|
|
1900
|
+
});
|
|
1901
|
+
const secret = options.secret ?? process.env[cron.secretEnv];
|
|
1902
|
+
if (secret) headers.set("authorization", `Bearer ${secret}`);
|
|
1903
|
+
const controller = new AbortController();
|
|
1904
|
+
const timeout = setTimeout(() => controller.abort(), options.timeoutMs ?? 1e4);
|
|
1905
|
+
timeout.unref?.();
|
|
1906
|
+
const startedAt = Date.now();
|
|
1907
|
+
try {
|
|
1908
|
+
const response = await (options.fetch || globalThis.fetch)(target, {
|
|
1909
|
+
method: "GET",
|
|
1910
|
+
headers,
|
|
1911
|
+
signal: controller.signal
|
|
1912
|
+
});
|
|
1913
|
+
const body = await readResponseBody(response);
|
|
1914
|
+
const durationMs = Date.now() - startedAt;
|
|
1915
|
+
if (!response.ok) throw new Error(`Cron ${job.name} received ${response.status} from ${job.path}${formatResponseDetail(body)}.`);
|
|
1916
|
+
return {
|
|
1917
|
+
job,
|
|
1918
|
+
url: target,
|
|
1919
|
+
status: response.status,
|
|
1920
|
+
body,
|
|
1921
|
+
durationMs
|
|
1922
|
+
};
|
|
1923
|
+
} catch (error) {
|
|
1924
|
+
if (error instanceof Error && error.name === "AbortError") throw new Error(`Cron ${job.name} timed out after ${options.timeoutMs ?? 1e4}ms.`);
|
|
1925
|
+
if (error instanceof TypeError) throw new Error(`Could not reach ${target}. Start the Farm dev server or pass --url to a running app.`);
|
|
1926
|
+
throw error;
|
|
1927
|
+
} finally {
|
|
1928
|
+
clearTimeout(timeout);
|
|
1929
|
+
}
|
|
1930
|
+
}
|
|
1931
|
+
function resolveCronTarget(job, options) {
|
|
1932
|
+
const baseURL = options.url || `http://${options.host || "localhost"}:${options.port || 3e3}`;
|
|
1933
|
+
const normalizedBase = baseURL.endsWith("/") ? baseURL : `${baseURL}/`;
|
|
1934
|
+
return new URL(job.path.replace(/^\/+/, ""), normalizedBase).toString();
|
|
1935
|
+
}
|
|
1936
|
+
async function readResponseBody(response) {
|
|
1937
|
+
if (response.status === 204) return null;
|
|
1938
|
+
const text = await response.text();
|
|
1939
|
+
if (!text) return null;
|
|
1940
|
+
if ((response.headers.get("content-type") || "").includes("application/json")) try {
|
|
1941
|
+
return JSON.parse(text);
|
|
1942
|
+
} catch {
|
|
1943
|
+
return text;
|
|
1944
|
+
}
|
|
1945
|
+
return text;
|
|
1946
|
+
}
|
|
1947
|
+
function formatResponseDetail(body) {
|
|
1948
|
+
if (body === null || body === void 0 || body === "") return "";
|
|
1949
|
+
const detail = typeof body === "string" ? body : JSON.stringify(body);
|
|
1950
|
+
return detail ? `: ${detail}` : "";
|
|
1951
|
+
}
|
|
1952
|
+
function formatError(error) {
|
|
1953
|
+
return error instanceof Error ? error.message : String(error);
|
|
1954
|
+
}
|
|
1955
|
+
//#endregion
|
|
1956
|
+
//#region src/migrate.ts
|
|
1957
|
+
async function migrateFarm(options = {}) {
|
|
1958
|
+
const root = node_path.default.resolve(options.root || process.cwd());
|
|
1959
|
+
if (options.source) {
|
|
1960
|
+
if (options.source === "inspect") {
|
|
1961
|
+
const detections = await inspectFrameworkMigrations(root);
|
|
1962
|
+
printFrameworkInspection(root, detections);
|
|
1963
|
+
return detections;
|
|
1964
|
+
}
|
|
1965
|
+
if (!isFrameworkMigrationSource(options.source)) throw new Error(`Unsupported migration source "${options.source}". Use "inspect", "next", or "tanstack".`);
|
|
1966
|
+
const plan = await createFrameworkMigrationPlan(root, options.source, { force: options.force });
|
|
1967
|
+
printFrameworkMigrationPlan(plan, Boolean(options.write && !options.dryRun));
|
|
1968
|
+
if (!options.write || options.dryRun) {
|
|
1969
|
+
_farm_js_core.logger.info(`Dry run only. Re-run with "farm migrate ${options.source} --write" to apply.`);
|
|
1970
|
+
return plan;
|
|
1971
|
+
}
|
|
1972
|
+
await applyFrameworkMigrationPlan(plan);
|
|
1973
|
+
_farm_js_core.logger.success(`Applied ${formatOperationCount(plan.operations.filter((operation) => !operation.skipped).length)}.`);
|
|
1974
|
+
return plan;
|
|
1975
|
+
}
|
|
1976
|
+
const cliCommands = options.commands?.filter(Boolean) || [];
|
|
1977
|
+
const configuredCommands = getConfiguredCommands((await (0, _farm_js_core.loadConfig)(root, options.configPath, "development"))?.migrations);
|
|
1978
|
+
const commands = (cliCommands.length ? cliCommands : configuredCommands).map((entry, index) => resolveMigrationCommand(root, entry, index)).filter((entry) => !!entry);
|
|
1979
|
+
if (!commands.length) throw new Error("No migrations configured. Add migrations.commands to farm.config.ts or pass --command.");
|
|
1980
|
+
if (options.dryRun) {
|
|
1981
|
+
_farm_js_core.logger.info(`Found ${formatCount(commands.length)} to run:`);
|
|
1982
|
+
for (const command of commands) _farm_js_core.logger.info(` ${command.name}: ${command.command}`);
|
|
1983
|
+
return;
|
|
1984
|
+
}
|
|
1985
|
+
for (const command of commands) {
|
|
1986
|
+
_farm_js_core.logger.info(`Running ${command.name}: ${command.command}`);
|
|
1987
|
+
await runMigrationCommand(command);
|
|
1988
|
+
}
|
|
1989
|
+
_farm_js_core.logger.success(`Ran ${formatCount(commands.length)} successfully.`);
|
|
1990
|
+
}
|
|
1991
|
+
function getConfiguredCommands(migrations) {
|
|
1992
|
+
if (!migrations) return [];
|
|
1993
|
+
if (Array.isArray(migrations)) return migrations;
|
|
1994
|
+
return migrations.commands || [];
|
|
1995
|
+
}
|
|
1996
|
+
function resolveMigrationCommand(root, entry, index) {
|
|
1997
|
+
if (typeof entry === "string") {
|
|
1998
|
+
const command = entry.trim();
|
|
1999
|
+
if (!command) throw new Error(`Migration command ${index + 1} is empty.`);
|
|
2000
|
+
return {
|
|
2001
|
+
command,
|
|
2002
|
+
name: `migration ${index + 1}`,
|
|
2003
|
+
cwd: root
|
|
2004
|
+
};
|
|
2005
|
+
}
|
|
2006
|
+
if (entry.skip) return null;
|
|
2007
|
+
const command = entry.command.trim();
|
|
2008
|
+
if (!command) throw new Error(`Migration command ${index + 1} is empty.`);
|
|
2009
|
+
return {
|
|
2010
|
+
command,
|
|
2011
|
+
name: entry.name || `migration ${index + 1}`,
|
|
2012
|
+
cwd: entry.cwd ? node_path.default.resolve(root, entry.cwd) : root,
|
|
2013
|
+
env: entry.env
|
|
2014
|
+
};
|
|
2015
|
+
}
|
|
2016
|
+
function runMigrationCommand(command) {
|
|
2017
|
+
return new Promise((resolve, reject) => {
|
|
2018
|
+
const child = (0, node_child_process.spawn)(command.command, {
|
|
2019
|
+
cwd: command.cwd,
|
|
2020
|
+
env: {
|
|
2021
|
+
...process.env,
|
|
2022
|
+
...command.env
|
|
2023
|
+
},
|
|
2024
|
+
shell: true,
|
|
2025
|
+
stdio: "inherit"
|
|
2026
|
+
});
|
|
2027
|
+
child.on("error", reject);
|
|
2028
|
+
child.on("close", (code, signal) => {
|
|
2029
|
+
if (code === 0) {
|
|
2030
|
+
resolve();
|
|
2031
|
+
return;
|
|
2032
|
+
}
|
|
2033
|
+
const reason = signal ? `signal ${signal}` : `exit code ${code}`;
|
|
2034
|
+
reject(/* @__PURE__ */ new Error(`${command.name} failed with ${reason}.`));
|
|
2035
|
+
});
|
|
2036
|
+
});
|
|
2037
|
+
}
|
|
2038
|
+
function formatCount(count) {
|
|
2039
|
+
return `${count} migration command${count === 1 ? "" : "s"}`;
|
|
2040
|
+
}
|
|
2041
|
+
function formatOperationCount(count) {
|
|
2042
|
+
return `${count} migration operation${count === 1 ? "" : "s"}`;
|
|
2043
|
+
}
|
|
2044
|
+
function isFrameworkMigrationSource(value) {
|
|
2045
|
+
return value === "next" || value === "tanstack";
|
|
2046
|
+
}
|
|
2047
|
+
async function inspectFrameworkMigrations(root) {
|
|
2048
|
+
const packageJson = await readPackageJson(root);
|
|
2049
|
+
return [detectNext(root, packageJson), detectTanStack(root, packageJson)].filter((entry) => entry.confidence > 0).sort((a, b) => b.confidence - a.confidence);
|
|
2050
|
+
}
|
|
2051
|
+
async function createFrameworkMigrationPlan(root, source, options = {}) {
|
|
2052
|
+
const detection = (await inspectFrameworkMigrations(root)).find((entry) => entry.source === source) || {
|
|
2053
|
+
source,
|
|
2054
|
+
confidence: 0,
|
|
2055
|
+
evidence: [`No ${source} markers detected.`]
|
|
2056
|
+
};
|
|
2057
|
+
const packageJson = await readPackageJson(root);
|
|
2058
|
+
const plan = {
|
|
2059
|
+
source,
|
|
2060
|
+
root,
|
|
2061
|
+
confidence: detection.confidence,
|
|
2062
|
+
evidence: detection.evidence,
|
|
2063
|
+
operations: [],
|
|
2064
|
+
warnings: [],
|
|
2065
|
+
manual: []
|
|
2066
|
+
};
|
|
2067
|
+
if (source === "next") await planNextMigration(root, packageJson, plan, options);
|
|
2068
|
+
else await planTanStackMigration(root, packageJson, plan, options);
|
|
2069
|
+
addSharedFarmFiles(root, packageJson, plan, source, options);
|
|
2070
|
+
return plan;
|
|
2071
|
+
}
|
|
2072
|
+
async function planNextMigration(root, packageJson, plan, options) {
|
|
2073
|
+
const rootAppDir = node_path.default.join(root, "app");
|
|
2074
|
+
const srcAppDir = node_path.default.join(root, "src", "app");
|
|
2075
|
+
const sourceAppDir = (0, node_fs.existsSync)(rootAppDir) ? rootAppDir : (0, node_fs.existsSync)(srcAppDir) ? srcAppDir : null;
|
|
2076
|
+
if (!sourceAppDir) {
|
|
2077
|
+
plan.warnings.push("No Next App Router directory found at app/ or src/app/.");
|
|
2078
|
+
if ((0, node_fs.existsSync)(node_path.default.join(root, "pages"))) plan.manual.push("Pages Router files in pages/ need manual conversion to src/app/**/page.tsx.");
|
|
2079
|
+
return;
|
|
2080
|
+
}
|
|
2081
|
+
const allFiles = await collectFiles(sourceAppDir);
|
|
2082
|
+
const files = allFiles.filter(isMigratableTextFile);
|
|
2083
|
+
for (const file of allFiles) if (!isMigratableTextFile(file)) plan.manual.push(`Move or review non-code app asset ${toPosix(node_path.default.relative(root, file))}.`);
|
|
2084
|
+
for (const file of files) {
|
|
2085
|
+
const relative = node_path.default.relative(sourceAppDir, file);
|
|
2086
|
+
const target = node_path.default.join(srcAppDir, relative);
|
|
2087
|
+
const transformed = transformNextContent(await (0, node_fs_promises.readFile)(file, "utf8"));
|
|
2088
|
+
collectNextManualNotes(relative, transformed, plan);
|
|
2089
|
+
await addWriteFileOperation(plan, {
|
|
2090
|
+
root,
|
|
2091
|
+
source: file,
|
|
2092
|
+
target,
|
|
2093
|
+
content: transformed,
|
|
2094
|
+
description: sourceAppDir === srcAppDir ? `Update Next-compatible app file ${toPosix(node_path.default.join("src/app", relative))}` : `Copy Next App Router file to ${toPosix(node_path.default.join("src/app", relative))}`,
|
|
2095
|
+
force: options.force,
|
|
2096
|
+
allowExisting: sourceAppDir === srcAppDir
|
|
2097
|
+
});
|
|
2098
|
+
}
|
|
2099
|
+
const rootMiddleware = [
|
|
2100
|
+
"middleware.ts",
|
|
2101
|
+
"middleware.tsx",
|
|
2102
|
+
"middleware.js",
|
|
2103
|
+
"middleware.jsx"
|
|
2104
|
+
].map((file) => node_path.default.join(root, file)).find((file) => (0, node_fs.existsSync)(file));
|
|
2105
|
+
if (rootMiddleware) {
|
|
2106
|
+
const extension = node_path.default.extname(rootMiddleware);
|
|
2107
|
+
await addWriteFileOperation(plan, {
|
|
2108
|
+
root,
|
|
2109
|
+
source: rootMiddleware,
|
|
2110
|
+
target: node_path.default.join(srcAppDir, `middleware${extension}`),
|
|
2111
|
+
content: await (0, node_fs_promises.readFile)(rootMiddleware, "utf8"),
|
|
2112
|
+
description: `Copy root middleware to ${toPosix(node_path.default.join("src/app", `middleware${extension}`))}`,
|
|
2113
|
+
force: options.force
|
|
2114
|
+
});
|
|
2115
|
+
plan.manual.push("Review migrated middleware for next/server APIs; Farm middleware uses @farm.js/core/middleware.");
|
|
2116
|
+
}
|
|
2117
|
+
if ((0, node_fs.existsSync)(node_path.default.join(root, "next.config.js")) || (0, node_fs.existsSync)(node_path.default.join(root, "next.config.mjs"))) plan.manual.push("Review next.config.* and move equivalent settings into farm.config.ts or Vite config.");
|
|
2118
|
+
if (packageJson) addPackageOperation(root, plan, createMigratedPackageJson(packageJson, "next"));
|
|
2119
|
+
}
|
|
2120
|
+
async function planTanStackMigration(root, packageJson, plan, options) {
|
|
2121
|
+
const routesDir = [node_path.default.join(root, "src", "routes"), node_path.default.join(root, "routes")].find((dir) => (0, node_fs.existsSync)(dir));
|
|
2122
|
+
if (!routesDir) {
|
|
2123
|
+
plan.warnings.push("No TanStack Router file-route directory found at src/routes/ or routes/.");
|
|
2124
|
+
return;
|
|
2125
|
+
}
|
|
2126
|
+
const appDir = node_path.default.join(root, "src", "app");
|
|
2127
|
+
const files = (await collectFiles(routesDir)).filter((file) => {
|
|
2128
|
+
const basename = node_path.default.basename(file);
|
|
2129
|
+
return /\.(tsx|ts|jsx|js)$/.test(file) && basename !== "routeTree.gen.ts" && basename !== "routeTree.gen.tsx";
|
|
2130
|
+
});
|
|
2131
|
+
for (const file of files) {
|
|
2132
|
+
const target = getTanStackTargetPath(routesDir, file, appDir);
|
|
2133
|
+
if (!target) {
|
|
2134
|
+
plan.manual.push(`Review ${toPosix(node_path.default.relative(root, file))}; root routes and route trees are not copied automatically.`);
|
|
2135
|
+
continue;
|
|
2136
|
+
}
|
|
2137
|
+
await addWriteFileOperation(plan, {
|
|
2138
|
+
root,
|
|
2139
|
+
source: file,
|
|
2140
|
+
target,
|
|
2141
|
+
content: transformTanStackContent(await (0, node_fs_promises.readFile)(file, "utf8"), node_path.default.relative(root, file), plan),
|
|
2142
|
+
description: `Convert TanStack route to ${toPosix(node_path.default.relative(root, target))}`,
|
|
2143
|
+
force: options.force
|
|
2144
|
+
});
|
|
2145
|
+
}
|
|
2146
|
+
plan.manual.push("Review loaders, beforeLoad hooks, search params, and Route.use* calls; Farm page modules should move that logic into props, API routes, or server helpers.");
|
|
2147
|
+
if (packageJson) addPackageOperation(root, plan, createMigratedPackageJson(packageJson, "tanstack"));
|
|
2148
|
+
}
|
|
2149
|
+
function addSharedFarmFiles(root, packageJson, plan, source, options) {
|
|
2150
|
+
if (![
|
|
2151
|
+
"farm.config.ts",
|
|
2152
|
+
"farm.config.mts",
|
|
2153
|
+
"farm.config.js",
|
|
2154
|
+
"farm.config.mjs"
|
|
2155
|
+
].map((file) => node_path.default.join(root, file)).find((file) => (0, node_fs.existsSync)(file))) {
|
|
2156
|
+
const output = source === "next" ? `import { defineConfig } from "@farm.js/core";
|
|
2157
|
+
|
|
2158
|
+
export default defineConfig({
|
|
2159
|
+
srcDir: "src",
|
|
2160
|
+
});
|
|
2161
|
+
` : `import { defineConfig } from "@farm.js/core";
|
|
2162
|
+
|
|
2163
|
+
export default defineConfig({
|
|
2164
|
+
srcDir: "src",
|
|
2165
|
+
});
|
|
2166
|
+
`;
|
|
2167
|
+
plan.operations.push({
|
|
2168
|
+
kind: "write-file",
|
|
2169
|
+
path: node_path.default.join(root, "farm.config.ts"),
|
|
2170
|
+
description: "Create farm.config.ts",
|
|
2171
|
+
content: output,
|
|
2172
|
+
skipped: false
|
|
2173
|
+
});
|
|
2174
|
+
}
|
|
2175
|
+
const layoutPath = node_path.default.join(root, "src", "app", "layout.tsx");
|
|
2176
|
+
if (!(0, node_fs.existsSync)(layoutPath)) plan.operations.push({
|
|
2177
|
+
kind: "write-file",
|
|
2178
|
+
path: layoutPath,
|
|
2179
|
+
description: "Create a minimal root layout",
|
|
2180
|
+
content: `import type { LayoutProps } from "@farm.js/core";
|
|
2181
|
+
|
|
2182
|
+
export default function Layout({ children }: LayoutProps) {
|
|
2183
|
+
return <>{children}</>;
|
|
2184
|
+
}
|
|
2185
|
+
`,
|
|
2186
|
+
skipped: false
|
|
2187
|
+
});
|
|
2188
|
+
if (!packageJson) plan.warnings.push("No package.json found; add @farm.js/core and @farm.js/cli manually.");
|
|
2189
|
+
}
|
|
2190
|
+
async function addWriteFileOperation(plan, options) {
|
|
2191
|
+
const exists = (0, node_fs.existsSync)(options.target);
|
|
2192
|
+
const sameFile = options.source && node_path.default.resolve(options.source) === node_path.default.resolve(options.target);
|
|
2193
|
+
const skipped = exists && !sameFile && !options.allowExisting && !options.force;
|
|
2194
|
+
plan.operations.push({
|
|
2195
|
+
kind: "write-file",
|
|
2196
|
+
path: options.target,
|
|
2197
|
+
description: options.description,
|
|
2198
|
+
content: options.content,
|
|
2199
|
+
skipped,
|
|
2200
|
+
reason: skipped ? `${toPosix(node_path.default.relative(options.root, options.target))} already exists; pass --force to overwrite.` : void 0
|
|
2201
|
+
});
|
|
2202
|
+
}
|
|
2203
|
+
function addPackageOperation(root, plan, migrated) {
|
|
2204
|
+
if (!migrated.changes.length) return;
|
|
2205
|
+
plan.operations.push({
|
|
2206
|
+
kind: "update-package",
|
|
2207
|
+
path: node_path.default.join(root, "package.json"),
|
|
2208
|
+
description: "Update package.json scripts and Farm dependencies",
|
|
2209
|
+
content: `${JSON.stringify(migrated.packageJson, null, 2)}\n`,
|
|
2210
|
+
changes: migrated.changes
|
|
2211
|
+
});
|
|
2212
|
+
}
|
|
2213
|
+
async function applyFrameworkMigrationPlan(plan) {
|
|
2214
|
+
for (const operation of plan.operations) {
|
|
2215
|
+
if (operation.skipped) {
|
|
2216
|
+
_farm_js_core.logger.warn(`Skipped ${toPosix(node_path.default.relative(plan.root, operation.path))}: ${operation.reason}`);
|
|
2217
|
+
continue;
|
|
2218
|
+
}
|
|
2219
|
+
if (operation.kind === "write-file" || operation.kind === "update-package") {
|
|
2220
|
+
await (0, node_fs_promises.mkdir)(node_path.default.dirname(operation.path), { recursive: true });
|
|
2221
|
+
await (0, node_fs_promises.writeFile)(operation.path, operation.content || "", "utf8");
|
|
2222
|
+
}
|
|
2223
|
+
}
|
|
2224
|
+
}
|
|
2225
|
+
function printFrameworkInspection(root, detections) {
|
|
2226
|
+
_farm_js_core.logger.info(`Migration inspection for ${root}`);
|
|
2227
|
+
if (!detections.length) {
|
|
2228
|
+
_farm_js_core.logger.warn("No supported framework detected yet. Supported sources: next, tanstack.");
|
|
2229
|
+
return;
|
|
2230
|
+
}
|
|
2231
|
+
for (const detection of detections) {
|
|
2232
|
+
_farm_js_core.logger.info(`${detection.source}: ${detection.confidence}% confidence`);
|
|
2233
|
+
for (const evidence of detection.evidence) _farm_js_core.logger.info(` - ${evidence}`);
|
|
2234
|
+
}
|
|
2235
|
+
}
|
|
2236
|
+
function printFrameworkMigrationPlan(plan, willWrite) {
|
|
2237
|
+
_farm_js_core.logger.info(`${willWrite ? "Applying" : "Prepared"} ${plan.source} migration plan (${plan.confidence}% confidence):`);
|
|
2238
|
+
for (const evidence of plan.evidence) _farm_js_core.logger.info(` evidence: ${evidence}`);
|
|
2239
|
+
if (!plan.operations.length) _farm_js_core.logger.warn("No file operations were planned.");
|
|
2240
|
+
else {
|
|
2241
|
+
_farm_js_core.logger.info("Operations:");
|
|
2242
|
+
for (const operation of plan.operations) {
|
|
2243
|
+
const marker = operation.skipped ? "skip" : willWrite ? "write" : "plan";
|
|
2244
|
+
_farm_js_core.logger.info(` [${marker}] ${operation.description}`);
|
|
2245
|
+
if (operation.changes?.length) for (const change of operation.changes) _farm_js_core.logger.info(` - ${change}`);
|
|
2246
|
+
if (operation.reason) _farm_js_core.logger.info(` ${operation.reason}`);
|
|
2247
|
+
}
|
|
2248
|
+
}
|
|
2249
|
+
if (plan.warnings.length) {
|
|
2250
|
+
_farm_js_core.logger.warn("Warnings:");
|
|
2251
|
+
for (const warning of unique(plan.warnings)) _farm_js_core.logger.warn(` - ${warning}`);
|
|
2252
|
+
}
|
|
2253
|
+
if (plan.manual.length) {
|
|
2254
|
+
_farm_js_core.logger.info("Manual review:");
|
|
2255
|
+
for (const note of unique(plan.manual)) _farm_js_core.logger.info(` - ${note}`);
|
|
2256
|
+
}
|
|
2257
|
+
}
|
|
2258
|
+
function detectNext(root, packageJson) {
|
|
2259
|
+
const evidence = [];
|
|
2260
|
+
let confidence = 0;
|
|
2261
|
+
if (hasPackage(packageJson, "next")) {
|
|
2262
|
+
confidence += 55;
|
|
2263
|
+
evidence.push("package.json depends on next");
|
|
2264
|
+
}
|
|
2265
|
+
if ((0, node_fs.existsSync)(node_path.default.join(root, "app"))) {
|
|
2266
|
+
confidence += 30;
|
|
2267
|
+
evidence.push("app/ directory exists");
|
|
2268
|
+
}
|
|
2269
|
+
if ((0, node_fs.existsSync)(node_path.default.join(root, "src", "app"))) {
|
|
2270
|
+
confidence += 25;
|
|
2271
|
+
evidence.push("src/app/ directory exists");
|
|
2272
|
+
}
|
|
2273
|
+
if ((0, node_fs.existsSync)(node_path.default.join(root, "next.config.js")) || (0, node_fs.existsSync)(node_path.default.join(root, "next.config.mjs"))) {
|
|
2274
|
+
confidence += 15;
|
|
2275
|
+
evidence.push("next.config.* exists");
|
|
2276
|
+
}
|
|
2277
|
+
if ((0, node_fs.existsSync)(node_path.default.join(root, "pages"))) {
|
|
2278
|
+
confidence += 10;
|
|
2279
|
+
evidence.push("pages/ directory exists");
|
|
2280
|
+
}
|
|
2281
|
+
return {
|
|
2282
|
+
source: "next",
|
|
2283
|
+
confidence: Math.min(confidence, 100),
|
|
2284
|
+
evidence
|
|
2285
|
+
};
|
|
2286
|
+
}
|
|
2287
|
+
function detectTanStack(root, packageJson) {
|
|
2288
|
+
const evidence = [];
|
|
2289
|
+
let confidence = 0;
|
|
2290
|
+
if (hasPackage(packageJson, "@tanstack/react-router")) {
|
|
2291
|
+
confidence += 60;
|
|
2292
|
+
evidence.push("package.json depends on @tanstack/react-router");
|
|
2293
|
+
}
|
|
2294
|
+
if ((0, node_fs.existsSync)(node_path.default.join(root, "src", "routes"))) {
|
|
2295
|
+
confidence += 35;
|
|
2296
|
+
evidence.push("src/routes/ directory exists");
|
|
2297
|
+
}
|
|
2298
|
+
if ((0, node_fs.existsSync)(node_path.default.join(root, "routes"))) {
|
|
2299
|
+
confidence += 25;
|
|
2300
|
+
evidence.push("routes/ directory exists");
|
|
2301
|
+
}
|
|
2302
|
+
if ((0, node_fs.existsSync)(node_path.default.join(root, "src", "routeTree.gen.ts"))) {
|
|
2303
|
+
confidence += 15;
|
|
2304
|
+
evidence.push("src/routeTree.gen.ts exists");
|
|
2305
|
+
}
|
|
2306
|
+
return {
|
|
2307
|
+
source: "tanstack",
|
|
2308
|
+
confidence: Math.min(confidence, 100),
|
|
2309
|
+
evidence
|
|
2310
|
+
};
|
|
2311
|
+
}
|
|
2312
|
+
async function collectFiles(dir) {
|
|
2313
|
+
const entries = await (0, node_fs_promises.readdir)(dir);
|
|
2314
|
+
const files = [];
|
|
2315
|
+
for (const entry of entries) {
|
|
2316
|
+
if (entry === "node_modules" || entry === ".next" || entry === ".output") continue;
|
|
2317
|
+
const fullPath = node_path.default.join(dir, entry);
|
|
2318
|
+
const info = await (0, node_fs_promises.stat)(fullPath);
|
|
2319
|
+
if (info.isDirectory()) files.push(...await collectFiles(fullPath));
|
|
2320
|
+
else if (info.isFile()) files.push(fullPath);
|
|
2321
|
+
}
|
|
2322
|
+
return files.sort();
|
|
2323
|
+
}
|
|
2324
|
+
function transformNextContent(content) {
|
|
2325
|
+
return content.replace(/import\s+([A-Za-z_$][\w$]*)\s+from\s+["']next\/link["'];?/g, (_match, localName) => localName === "Link" ? `import { Link } from "@farm.js/core/client";` : `import { Link as ${localName} } from "@farm.js/core/client";`).replace(/from\s+["']next\/navigation["']/g, `from "@farm.js/core/navigation"`).replace(/from\s+["']next\/headers["']/g, `from "@farm.js/core/headers"`);
|
|
2326
|
+
}
|
|
2327
|
+
function collectNextManualNotes(relative, content, plan) {
|
|
2328
|
+
const imports = Array.from(content.matchAll(/from\s+["'](next\/[^"']+)["']/g)).map((match) => match[1]);
|
|
2329
|
+
for (const importId of imports) plan.manual.push(`Review ${toPosix(relative)}; it still imports ${importId}.`);
|
|
2330
|
+
if (/getServerSideProps|getStaticProps|getInitialProps/.test(content)) plan.manual.push(`Review ${toPosix(relative)}; Pages Router data functions need manual App Router/Farm conversion.`);
|
|
2331
|
+
}
|
|
2332
|
+
function isMigratableTextFile(file) {
|
|
2333
|
+
return /\.(ts|tsx|js|jsx|mjs|cjs|md|mdx|css|json|txt)$/.test(file);
|
|
2334
|
+
}
|
|
2335
|
+
function getTanStackTargetPath(routesDir, file, appDir) {
|
|
2336
|
+
const withoutExt = toPosix(node_path.default.relative(routesDir, file)).replace(/\.(tsx|ts|jsx|js)$/, "");
|
|
2337
|
+
if (withoutExt === "__root" || withoutExt === "routeTree.gen") return null;
|
|
2338
|
+
const rawParts = withoutExt.split("/").flatMap((segment) => segment.split(".")).filter((segment) => segment && !segment.startsWith("_"));
|
|
2339
|
+
if (!rawParts.length) return null;
|
|
2340
|
+
const routeParts = (rawParts[rawParts.length - 1] === "index" ? rawParts.slice(0, -1) : rawParts).map(convertTanStackSegment);
|
|
2341
|
+
return node_path.default.join(appDir, ...routeParts, "page.tsx");
|
|
2342
|
+
}
|
|
2343
|
+
function convertTanStackSegment(segment) {
|
|
2344
|
+
if (segment === "$") return "[...splat]";
|
|
2345
|
+
if (segment.startsWith("$")) return `[${segment.slice(1)}]`;
|
|
2346
|
+
return segment;
|
|
2347
|
+
}
|
|
2348
|
+
function transformTanStackContent(content, relativeFile, plan) {
|
|
2349
|
+
if (/Route\.use[A-Z]/.test(content) || /useLoaderData|beforeLoad|loader:/.test(content)) plan.manual.push(`Review ${toPosix(relativeFile)}; it uses TanStack route runtime APIs.`);
|
|
2350
|
+
if (/export\s+default\s+/.test(content)) return content;
|
|
2351
|
+
const componentName = content.match(/component\s*:\s*([A-Za-z_$][\w$]*)/)?.[1];
|
|
2352
|
+
if (!componentName) {
|
|
2353
|
+
plan.manual.push(`Add a default export to ${toPosix(relativeFile)}; no component: Identifier was found.`);
|
|
2354
|
+
return content;
|
|
2355
|
+
}
|
|
2356
|
+
return `${content.trimEnd()}
|
|
2357
|
+
|
|
2358
|
+
export default ${componentName};
|
|
2359
|
+
`;
|
|
2360
|
+
}
|
|
2361
|
+
function createMigratedPackageJson(packageJson, source) {
|
|
2362
|
+
const nextPackageJson = cloneJson(packageJson);
|
|
2363
|
+
const changes = [];
|
|
2364
|
+
nextPackageJson.scripts = nextPackageJson.scripts || {};
|
|
2365
|
+
const scriptReplacements = source === "next" ? [
|
|
2366
|
+
[
|
|
2367
|
+
"dev",
|
|
2368
|
+
"farm dev",
|
|
2369
|
+
/(^|\s)next\s+dev(\s|$)/
|
|
2370
|
+
],
|
|
2371
|
+
[
|
|
2372
|
+
"build",
|
|
2373
|
+
"farm build",
|
|
2374
|
+
/(^|\s)next\s+build(\s|$)/
|
|
2375
|
+
],
|
|
2376
|
+
[
|
|
2377
|
+
"start",
|
|
2378
|
+
"node .output/server/index.mjs",
|
|
2379
|
+
/(^|\s)next\s+start(\s|$)/
|
|
2380
|
+
]
|
|
2381
|
+
] : [[
|
|
2382
|
+
"dev",
|
|
2383
|
+
"farm dev",
|
|
2384
|
+
/(^|\s)vite(\s|$)|(^|\s)vinxi\s+dev(\s|$)/
|
|
2385
|
+
], [
|
|
2386
|
+
"build",
|
|
2387
|
+
"farm build",
|
|
2388
|
+
/(^|\s)vite\s+build(\s|$)|(^|\s)vinxi\s+build(\s|$)/
|
|
2389
|
+
]];
|
|
2390
|
+
for (const [name, value, matcher] of scriptReplacements) {
|
|
2391
|
+
const existing = nextPackageJson.scripts[name];
|
|
2392
|
+
if (!existing || matcher.test(existing)) {
|
|
2393
|
+
if (existing !== value) {
|
|
2394
|
+
nextPackageJson.scripts[name] = value;
|
|
2395
|
+
changes.push(`scripts.${name} -> ${value}`);
|
|
2396
|
+
}
|
|
2397
|
+
}
|
|
2398
|
+
}
|
|
2399
|
+
nextPackageJson.dependencies = nextPackageJson.dependencies || {};
|
|
2400
|
+
if (!nextPackageJson.dependencies["@farm.js/core"]) {
|
|
2401
|
+
nextPackageJson.dependencies["@farm.js/core"] = "latest";
|
|
2402
|
+
changes.push("dependencies.@farm.js/core -> latest");
|
|
2403
|
+
}
|
|
2404
|
+
nextPackageJson.devDependencies = nextPackageJson.devDependencies || {};
|
|
2405
|
+
if (!nextPackageJson.devDependencies["@farm.js/cli"]) {
|
|
2406
|
+
nextPackageJson.devDependencies["@farm.js/cli"] = "latest";
|
|
2407
|
+
changes.push("devDependencies.@farm.js/cli -> latest");
|
|
2408
|
+
}
|
|
2409
|
+
return {
|
|
2410
|
+
packageJson: nextPackageJson,
|
|
2411
|
+
changes
|
|
2412
|
+
};
|
|
2413
|
+
}
|
|
2414
|
+
async function readPackageJson(root) {
|
|
2415
|
+
const packagePath = node_path.default.join(root, "package.json");
|
|
2416
|
+
if (!(0, node_fs.existsSync)(packagePath)) return null;
|
|
2417
|
+
return JSON.parse(await (0, node_fs_promises.readFile)(packagePath, "utf8"));
|
|
2418
|
+
}
|
|
2419
|
+
function hasPackage(packageJson, packageName) {
|
|
2420
|
+
return Boolean(packageJson?.dependencies?.[packageName] || packageJson?.devDependencies?.[packageName] || packageJson?.peerDependencies?.[packageName]);
|
|
2421
|
+
}
|
|
2422
|
+
function cloneJson(value) {
|
|
2423
|
+
return JSON.parse(JSON.stringify(value));
|
|
2424
|
+
}
|
|
2425
|
+
function unique(values) {
|
|
2426
|
+
return [...new Set(values)];
|
|
2427
|
+
}
|
|
2428
|
+
function toPosix(value) {
|
|
2429
|
+
return value.split(node_path.default.sep).join("/");
|
|
2430
|
+
}
|
|
2431
|
+
//#endregion
|
|
2432
|
+
exports.addFarmIntegration = require_add_integration.addFarmIntegration;
|
|
2433
|
+
exports.buildFarm = require_build.buildFarm;
|
|
2434
|
+
exports.createFrameworkMigrationPlan = createFrameworkMigrationPlan;
|
|
2435
|
+
exports.createGatewaySession = createGatewaySession;
|
|
2436
|
+
exports.createPreviewGatewayPlan = createPreviewGatewayPlan;
|
|
2437
|
+
exports.createPreviewTunnelPlan = createPreviewTunnelPlan;
|
|
2438
|
+
Object.defineProperty(exports, "createServer", {
|
|
2439
|
+
enumerable: true,
|
|
2440
|
+
get: function() {
|
|
2441
|
+
return _farm_js_core_server.createServer;
|
|
2442
|
+
}
|
|
2443
|
+
});
|
|
2444
|
+
exports.deployFarm = deployFarm;
|
|
2445
|
+
exports.formatFarmCronJobs = formatFarmCronJobs;
|
|
2446
|
+
exports.formatFarmDoctorReport = formatFarmDoctorReport;
|
|
2447
|
+
exports.forwardGatewayRequest = forwardGatewayRequest;
|
|
2448
|
+
exports.generateFarmArtifacts = generateFarmArtifacts;
|
|
2449
|
+
exports.inspectFrameworkMigrations = inspectFrameworkMigrations;
|
|
2450
|
+
exports.listFarmCronJobs = listFarmCronJobs;
|
|
2451
|
+
exports.listFarmIntegrationProviders = require_add_integration.listFarmIntegrationProviders;
|
|
2452
|
+
exports.loadFarmCronConfig = loadFarmCronConfig;
|
|
2453
|
+
exports.migrateFarm = migrateFarm;
|
|
2454
|
+
exports.parsePreviewPublicUrl = parsePreviewPublicUrl;
|
|
2455
|
+
exports.previewFarm = previewFarm;
|
|
2456
|
+
exports.resolveCloudflareAgentDeployPlan = resolveCloudflareAgentDeployPlan;
|
|
2457
|
+
exports.resolvePreviewTarget = resolvePreviewTarget;
|
|
2458
|
+
exports.runFarmCronJob = runFarmCronJob;
|
|
2459
|
+
exports.runFarmDoctor = runFarmDoctor;
|
|
2460
|
+
exports.runPreviewGateway = runPreviewGateway;
|
|
2461
|
+
Object.defineProperty(exports, "startDevServer", {
|
|
2462
|
+
enumerable: true,
|
|
2463
|
+
get: function() {
|
|
2464
|
+
return _farm_js_core_server.startDevServer;
|
|
2465
|
+
}
|
|
2466
|
+
});
|
|
2467
|
+
exports.startFarmCronScheduler = startFarmCronScheduler;
|
|
2468
|
+
|
|
2469
|
+
//# sourceMappingURL=index.js.map
|