threadforge 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +152 -0
- package/bin/forge.js +1050 -0
- package/bin/host-commands.js +344 -0
- package/bin/platform-commands.js +570 -0
- package/package.json +71 -0
- package/shared/auth.js +475 -0
- package/src/core/DirectMessageBus.js +364 -0
- package/src/core/EndpointResolver.js +247 -0
- package/src/core/ForgeContext.js +2227 -0
- package/src/core/ForgeHost.js +122 -0
- package/src/core/ForgePlatform.js +145 -0
- package/src/core/Ingress.js +768 -0
- package/src/core/Interceptors.js +420 -0
- package/src/core/MessageBus.js +310 -0
- package/src/core/Prometheus.js +305 -0
- package/src/core/RequestContext.js +413 -0
- package/src/core/RoutingStrategy.js +316 -0
- package/src/core/Supervisor.js +1306 -0
- package/src/core/ThreadAllocator.js +196 -0
- package/src/core/WorkerChannelManager.js +879 -0
- package/src/core/config.js +624 -0
- package/src/core/host-config.js +311 -0
- package/src/core/network-utils.js +166 -0
- package/src/core/platform-config.js +308 -0
- package/src/decorators/ServiceProxy.js +899 -0
- package/src/decorators/index.js +571 -0
- package/src/deploy/NginxGenerator.js +865 -0
- package/src/deploy/PlatformManifestGenerator.js +96 -0
- package/src/deploy/RouteManifestGenerator.js +112 -0
- package/src/deploy/index.js +984 -0
- package/src/frontend/FrontendDevLifecycle.js +65 -0
- package/src/frontend/FrontendPluginOrchestrator.js +187 -0
- package/src/frontend/SiteResolver.js +63 -0
- package/src/frontend/StaticMountRegistry.js +90 -0
- package/src/frontend/index.js +5 -0
- package/src/frontend/plugins/index.js +2 -0
- package/src/frontend/plugins/viteFrontend.js +79 -0
- package/src/frontend/types.js +35 -0
- package/src/index.js +56 -0
- package/src/internals.js +31 -0
- package/src/plugins/PluginManager.js +537 -0
- package/src/plugins/ScopedPostgres.js +192 -0
- package/src/plugins/ScopedRedis.js +142 -0
- package/src/plugins/index.js +1729 -0
- package/src/registry/ServiceRegistry.js +796 -0
- package/src/scaling/ScaleAdvisor.js +442 -0
- package/src/services/Service.js +195 -0
- package/src/services/worker-bootstrap.js +676 -0
- package/src/templates/auth-service.js +65 -0
- package/src/templates/identity-service.js +75 -0
package/bin/forge.js
ADDED
|
@@ -0,0 +1,1050 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ThreadForge CLI
|
|
5
|
+
*
|
|
6
|
+
* Commands:
|
|
7
|
+
* forge dev - Start in development mode with hot reload
|
|
8
|
+
* forge start - Start in production mode
|
|
9
|
+
* forge build - Build frontend sites via frontend plugins
|
|
10
|
+
* forge stop - Stop a running supervisor (SIGTERM)
|
|
11
|
+
* forge init [dir] - Scaffold a new ThreadForge project
|
|
12
|
+
* forge status - Show runtime status (connects to metrics endpoint)
|
|
13
|
+
*
|
|
14
|
+
* TODO: implement `forge scale <svc> <n>` and `forge restart <svc>` commands
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import fs from "node:fs";
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
20
|
+
import { loadConfig } from "../src/core/config.js";
|
|
21
|
+
import { Supervisor } from "../src/core/Supervisor.js";
|
|
22
|
+
import { bindFrontendDevCleanup, wrapShutdownWithCleanup } from "../src/frontend/FrontendDevLifecycle.js";
|
|
23
|
+
import { resolveFrontendSites, resolveSitesMap } from "../src/frontend/SiteResolver.js";
|
|
24
|
+
|
|
25
|
+
const args = process.argv.slice(2);
|
|
26
|
+
const command = args[0];
|
|
27
|
+
|
|
28
|
+
const FORGE_CONFIG_NAMES = ["forge.config.js", "forge.config.mjs", "threadforge.config.js", "threadforge.config.mjs"];
|
|
29
|
+
|
|
30
|
+
async function findConfig() {
|
|
31
|
+
const cwd = process.cwd();
|
|
32
|
+
|
|
33
|
+
// Check for --config flag
|
|
34
|
+
const configIdx = args.indexOf("--config");
|
|
35
|
+
if (configIdx !== -1 && args[configIdx + 1]) {
|
|
36
|
+
const resolved = path.resolve(cwd, args[configIdx + 1]);
|
|
37
|
+
if (resolved !== cwd && !resolved.startsWith(cwd + path.sep)) {
|
|
38
|
+
console.error("Error: Config path must be within the project directory");
|
|
39
|
+
process.exit(1);
|
|
40
|
+
}
|
|
41
|
+
return resolved;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
for (const name of FORGE_CONFIG_NAMES) {
|
|
45
|
+
const fullPath = path.join(cwd, name);
|
|
46
|
+
try {
|
|
47
|
+
await fs.promises.access(fullPath);
|
|
48
|
+
return fullPath;
|
|
49
|
+
} catch {}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function stopFrontendDevHandle(handle) {
|
|
56
|
+
if (!handle) return;
|
|
57
|
+
if (typeof handle === "function") {
|
|
58
|
+
await handle();
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
for (const method of ["dispose", "stop", "close", "kill"]) {
|
|
63
|
+
if (typeof handle[method] === "function") {
|
|
64
|
+
await handle[method]();
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (handle.process?.pid && typeof handle.process.kill === "function") {
|
|
70
|
+
handle.process.kill("SIGTERM");
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function startFrontendDevSessions(config) {
|
|
75
|
+
const frontendSites = resolveFrontendSites(config);
|
|
76
|
+
if (frontendSites.length === 0) {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const { FrontendPluginOrchestrator } = await import("../src/frontend/FrontendPluginOrchestrator.js");
|
|
81
|
+
const orchestrator = new FrontendPluginOrchestrator();
|
|
82
|
+
const frontendPlugins = config.frontendPlugins ?? [];
|
|
83
|
+
|
|
84
|
+
orchestrator.register(frontendPlugins);
|
|
85
|
+
await orchestrator.validateAll({ logger: console, mode: "dev", config });
|
|
86
|
+
await orchestrator.registerAll({ logger: console, mode: "dev", config });
|
|
87
|
+
|
|
88
|
+
if (orchestrator.plugins.size === 0) {
|
|
89
|
+
throw new Error(
|
|
90
|
+
"Frontend sites are configured, but no frontendPlugins are registered in config.",
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const handles = [];
|
|
95
|
+
let disposed = false;
|
|
96
|
+
const cleanup = async () => {
|
|
97
|
+
if (disposed) return;
|
|
98
|
+
disposed = true;
|
|
99
|
+
for (const session of [...handles].reverse()) {
|
|
100
|
+
try {
|
|
101
|
+
await stopFrontendDevHandle(session.handle);
|
|
102
|
+
} catch (err) {
|
|
103
|
+
console.warn(` [dev] Failed to stop frontend session "${session.siteId}": ${err.message}`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
await orchestrator.disposeAll({ logger: console, mode: "dev", config });
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
let started = 0;
|
|
110
|
+
try {
|
|
111
|
+
for (const site of frontendSites) {
|
|
112
|
+
const plugin = orchestrator.resolvePluginForSite(site);
|
|
113
|
+
if (!plugin.capabilities.includes("frontend-dev")) {
|
|
114
|
+
console.log(` [dev] Skipping frontend site "${site.siteId}" (${plugin.name} has no frontend-dev capability)`);
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const handle = await orchestrator.devSite(site, { logger: console, mode: "dev", config });
|
|
119
|
+
handles.push({ siteId: site.siteId, plugin: plugin.name, handle });
|
|
120
|
+
started++;
|
|
121
|
+
}
|
|
122
|
+
} catch (err) {
|
|
123
|
+
await cleanup();
|
|
124
|
+
throw err;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (started > 0) {
|
|
128
|
+
console.log(` [dev] Frontend dev sessions started: ${started}`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return { cleanup };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function cmdStart(isDev = false) {
|
|
135
|
+
const configPath = await findConfig();
|
|
136
|
+
if (!configPath) {
|
|
137
|
+
console.error(" Error: No config file found in current directory.");
|
|
138
|
+
console.error(` Searched for: ${FORGE_CONFIG_NAMES.join(", ")}`);
|
|
139
|
+
console.error(" Run `forge init` to create one, or use `--config <path>` to specify manually.");
|
|
140
|
+
process.exit(1);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
let config;
|
|
144
|
+
try {
|
|
145
|
+
const configUrl = pathToFileURL(configPath).href;
|
|
146
|
+
config = await loadConfig(configUrl);
|
|
147
|
+
config._configUrl = configUrl; // workers reimport this for plugins
|
|
148
|
+
} catch (err) {
|
|
149
|
+
console.error(`\n \u2717 Error loading ${path.basename(configPath)}:`);
|
|
150
|
+
console.error(` ${err.message}`);
|
|
151
|
+
if (err.code === "ERR_MODULE_NOT_FOUND") {
|
|
152
|
+
console.error(" Hint: Check your import paths and run `npm install` to ensure dependencies are installed.");
|
|
153
|
+
}
|
|
154
|
+
process.exit(1);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (isDev) {
|
|
158
|
+
config.watch = true;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const fileWatchers = [];
|
|
162
|
+
const closeDevWatchers = async () => {
|
|
163
|
+
for (const watcher of fileWatchers.splice(0)) {
|
|
164
|
+
try {
|
|
165
|
+
watcher.close();
|
|
166
|
+
} catch {}
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
let frontendDev = null;
|
|
171
|
+
if (isDev) {
|
|
172
|
+
try {
|
|
173
|
+
frontendDev = await startFrontendDevSessions(config);
|
|
174
|
+
} catch (err) {
|
|
175
|
+
console.error(` Frontend dev setup failed: ${err.message}`);
|
|
176
|
+
process.exit(1);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// D11: Warn when `connects` targets undefined services
|
|
181
|
+
if (config.services) {
|
|
182
|
+
const serviceNames = new Set(Object.keys(config.services));
|
|
183
|
+
for (const [name, svc] of Object.entries(config.services)) {
|
|
184
|
+
if (Array.isArray(svc.connects)) {
|
|
185
|
+
for (const target of svc.connects) {
|
|
186
|
+
if (!serviceNames.has(target)) {
|
|
187
|
+
console.warn(` Warning: service "${name}" connects to "${target}", which is not defined in the config.`);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const supervisor = new Supervisor(config);
|
|
195
|
+
const devLifecycle = isDev
|
|
196
|
+
? bindFrontendDevCleanup(
|
|
197
|
+
async () => {
|
|
198
|
+
await closeDevWatchers();
|
|
199
|
+
if (frontendDev?.cleanup) {
|
|
200
|
+
await frontendDev.cleanup();
|
|
201
|
+
}
|
|
202
|
+
},
|
|
203
|
+
{ logger: console },
|
|
204
|
+
)
|
|
205
|
+
: null;
|
|
206
|
+
if (devLifecycle) {
|
|
207
|
+
wrapShutdownWithCleanup(supervisor, devLifecycle.runCleanup);
|
|
208
|
+
}
|
|
209
|
+
try {
|
|
210
|
+
await supervisor.start();
|
|
211
|
+
} catch (err) {
|
|
212
|
+
if (devLifecycle) {
|
|
213
|
+
await devLifecycle.runCleanup();
|
|
214
|
+
devLifecycle.dispose();
|
|
215
|
+
}
|
|
216
|
+
console.error(`\n \u2717 Startup failed:`);
|
|
217
|
+
console.error(` ${err.message}`);
|
|
218
|
+
process.exit(1);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// D15: In dev mode, watch for file changes and log them
|
|
222
|
+
if (isDev) {
|
|
223
|
+
const watchDirs = new Set();
|
|
224
|
+
for (const svc of Object.values(config.services)) {
|
|
225
|
+
if (svc.entry) {
|
|
226
|
+
const entryDir = path.dirname(path.resolve(process.cwd(), svc.entry));
|
|
227
|
+
watchDirs.add(entryDir);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
for (const dir of watchDirs) {
|
|
231
|
+
try {
|
|
232
|
+
const watcher = fs.watch(dir, { recursive: true }, (eventType, filename) => {
|
|
233
|
+
if (filename && (filename.endsWith('.js') || filename.endsWith('.mjs') || filename.endsWith('.ts'))) {
|
|
234
|
+
console.log(` [dev] File change detected: ${filename} (${eventType})`);
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
fileWatchers.push(watcher);
|
|
238
|
+
// Prevent dev file-watch handles from blocking process exit during shutdown.
|
|
239
|
+
if (typeof watcher.unref === "function") watcher.unref();
|
|
240
|
+
} catch {
|
|
241
|
+
// Directory may not exist or be unwatchable — skip silently
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function detectLocalThreadforgeDependency(cwd) {
|
|
248
|
+
const linkedPackagePath = path.join(cwd, "node_modules", "threadforge");
|
|
249
|
+
try {
|
|
250
|
+
const stat = fs.lstatSync(linkedPackagePath);
|
|
251
|
+
if (!stat.isSymbolicLink()) return null;
|
|
252
|
+
|
|
253
|
+
const realPath = fs.realpathSync(linkedPackagePath);
|
|
254
|
+
const relativePath = path.relative(cwd, realPath) || ".";
|
|
255
|
+
const normalized = (relativePath.startsWith(".") ? relativePath : `./${relativePath}`).split(path.sep).join("/");
|
|
256
|
+
return `file:${normalized}`;
|
|
257
|
+
} catch {
|
|
258
|
+
return null;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function resolveInitDependency(cwd, source = "auto") {
|
|
263
|
+
if (source === "npm") {
|
|
264
|
+
return { spec: "threadforge", label: "npm" };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (source === "github") {
|
|
268
|
+
return { spec: "github:ChrisBland/threadforge", label: "github" };
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (source === "local" || source === "auto") {
|
|
272
|
+
const localSpec = detectLocalThreadforgeDependency(cwd);
|
|
273
|
+
if (localSpec) {
|
|
274
|
+
return { spec: localSpec, label: "local-link" };
|
|
275
|
+
}
|
|
276
|
+
if (source === "local") {
|
|
277
|
+
throw new Error('No linked local ThreadForge found at ./node_modules/threadforge. Run "npm link threadforge" first.');
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return { spec: "github:ChrisBland/threadforge", label: "github" };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function parseInitArgs(initArgs = []) {
|
|
285
|
+
let targetDir = null;
|
|
286
|
+
let source = "auto";
|
|
287
|
+
|
|
288
|
+
for (let i = 0; i < initArgs.length; i++) {
|
|
289
|
+
const token = initArgs[i];
|
|
290
|
+
|
|
291
|
+
if (token === "--source") {
|
|
292
|
+
const value = initArgs[i + 1];
|
|
293
|
+
if (!value) {
|
|
294
|
+
console.error(" Error: --source requires a value: auto, github, local, npm");
|
|
295
|
+
process.exit(1);
|
|
296
|
+
}
|
|
297
|
+
source = value.toLowerCase();
|
|
298
|
+
i++;
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
if (token.startsWith("--source=")) {
|
|
303
|
+
source = token.slice("--source=".length).toLowerCase();
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
if (token === "--local") {
|
|
308
|
+
source = "local";
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if (token === "--help" || token === "-h") {
|
|
313
|
+
console.log("\n Usage: forge init [dir] [--source auto|github|local|npm]\n");
|
|
314
|
+
process.exit(0);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
if (token.startsWith("-")) {
|
|
318
|
+
console.error(` Unknown init option: ${token}`);
|
|
319
|
+
process.exit(1);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
if (targetDir !== null) {
|
|
323
|
+
console.error(` Unexpected argument: ${token}`);
|
|
324
|
+
console.error(" Usage: forge init [dir] [--source auto|github|local|npm]");
|
|
325
|
+
process.exit(1);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
targetDir = token;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
if (!["auto", "github", "local", "npm"].includes(source)) {
|
|
332
|
+
console.error(` Invalid --source value: ${source}`);
|
|
333
|
+
console.error(" Valid values: auto, github, local, npm");
|
|
334
|
+
process.exit(1);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
return { targetDir, source };
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
async function cmdInit(targetDir, options = {}) {
|
|
341
|
+
let cwd = process.cwd();
|
|
342
|
+
const source = options.source ?? "auto";
|
|
343
|
+
|
|
344
|
+
if (targetDir) {
|
|
345
|
+
cwd = path.resolve(cwd, targetDir);
|
|
346
|
+
fs.mkdirSync(cwd, { recursive: true });
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const configPath = path.join(cwd, "forge.config.js");
|
|
350
|
+
|
|
351
|
+
if (fs.existsSync(configPath)) {
|
|
352
|
+
console.error(" forge.config.js already exists in this directory.");
|
|
353
|
+
process.exit(1);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
let dependency;
|
|
357
|
+
let dependencySourceLabel;
|
|
358
|
+
try {
|
|
359
|
+
const resolved = resolveInitDependency(cwd, source);
|
|
360
|
+
dependency = resolved.spec;
|
|
361
|
+
dependencySourceLabel = resolved.label;
|
|
362
|
+
} catch (err) {
|
|
363
|
+
console.error(` Error: ${err.message}`);
|
|
364
|
+
process.exit(1);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Create package.json if it doesn't exist
|
|
368
|
+
const pkgPath = path.join(cwd, "package.json");
|
|
369
|
+
if (!fs.existsSync(pkgPath)) {
|
|
370
|
+
const projectName = path.basename(cwd);
|
|
371
|
+
fs.writeFileSync(
|
|
372
|
+
pkgPath,
|
|
373
|
+
`${JSON.stringify(
|
|
374
|
+
{
|
|
375
|
+
name: projectName,
|
|
376
|
+
version: "1.0.0",
|
|
377
|
+
type: "module",
|
|
378
|
+
scripts: {
|
|
379
|
+
dev: "forge dev",
|
|
380
|
+
start: "forge start",
|
|
381
|
+
},
|
|
382
|
+
dependencies: {
|
|
383
|
+
threadforge: dependency,
|
|
384
|
+
},
|
|
385
|
+
},
|
|
386
|
+
null,
|
|
387
|
+
2,
|
|
388
|
+
)}\n`,
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// Always use package name for imports to ensure generated code works after npm install
|
|
393
|
+
const configImportPath = "threadforge";
|
|
394
|
+
const serviceImportPath = "threadforge";
|
|
395
|
+
|
|
396
|
+
// Create project structure
|
|
397
|
+
const dirs = ["services"];
|
|
398
|
+
for (const dir of dirs) {
|
|
399
|
+
fs.mkdirSync(path.join(cwd, dir), { recursive: true });
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// Write config file
|
|
403
|
+
fs.writeFileSync(
|
|
404
|
+
configPath,
|
|
405
|
+
`import { defineServices } from '${configImportPath}';
|
|
406
|
+
|
|
407
|
+
export default defineServices({
|
|
408
|
+
api: {
|
|
409
|
+
entry: './services/api.js',
|
|
410
|
+
type: 'edge',
|
|
411
|
+
port: 3000,
|
|
412
|
+
threads: 'auto',
|
|
413
|
+
weight: 2,
|
|
414
|
+
// connects: ['otherService'], // add services this one calls
|
|
415
|
+
},
|
|
416
|
+
});
|
|
417
|
+
`,
|
|
418
|
+
);
|
|
419
|
+
|
|
420
|
+
// Write example service
|
|
421
|
+
fs.writeFileSync(
|
|
422
|
+
path.join(cwd, "services", "api.js"),
|
|
423
|
+
`import { Service } from '${serviceImportPath}';
|
|
424
|
+
|
|
425
|
+
export default class ApiService extends Service {
|
|
426
|
+
static contract = {
|
|
427
|
+
expose: ['getGreeting'],
|
|
428
|
+
routes: [
|
|
429
|
+
{ method: 'GET', path: '/health', handler: 'healthCheck' },
|
|
430
|
+
{ method: 'GET', path: '/hello/:name', handler: 'hello' },
|
|
431
|
+
],
|
|
432
|
+
};
|
|
433
|
+
|
|
434
|
+
async onStart(ctx) {
|
|
435
|
+
if (ctx.workerId === 0) {
|
|
436
|
+
ctx.logger.info('API service ready');
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
async healthCheck() {
|
|
441
|
+
return { status: 'ok', worker: this.ctx.workerId };
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
async hello(body, params) {
|
|
445
|
+
return { message: \`Hello, \${params.name}!\`, worker: this.ctx.workerId };
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
async getGreeting(name) {
|
|
449
|
+
return { message: \`Hello, \${name}!\` };
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
async onMessage(from, payload) {
|
|
453
|
+
this.ctx.logger.info(\`Message from \${from}\`, payload);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
`,
|
|
457
|
+
);
|
|
458
|
+
|
|
459
|
+
const shouldSuggestCd = Boolean(targetDir) && path.resolve(process.cwd(), targetDir) !== process.cwd();
|
|
460
|
+
const cdStep = shouldSuggestCd ? `cd "${path.basename(cwd)}" && ` : "";
|
|
461
|
+
const usingLocalLink = dependencySourceLabel === "local-link";
|
|
462
|
+
console.log(`
|
|
463
|
+
\u26A1 ThreadForge project initialized!
|
|
464
|
+
|
|
465
|
+
Created:
|
|
466
|
+
forge.config.js - Service configuration
|
|
467
|
+
services/api.js - Example API service
|
|
468
|
+
|
|
469
|
+
Dependency source: ${dependency}
|
|
470
|
+
|
|
471
|
+
Next steps:
|
|
472
|
+
1. ${cdStep}npm install
|
|
473
|
+
2. npm run dev
|
|
474
|
+
3. curl http://localhost:3000/health
|
|
475
|
+
|
|
476
|
+
${usingLocalLink ? "" : "Tip: for local linked development, run `forge init [dir] --source local` after `npm link threadforge`."}
|
|
477
|
+
`);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
async function resolveMetricsPort() {
|
|
481
|
+
const config = await findConfig().then(async (p) => {
|
|
482
|
+
if (!p) return null;
|
|
483
|
+
try { return await loadConfig(pathToFileURL(p).href); } catch { return null; }
|
|
484
|
+
});
|
|
485
|
+
return config?.metricsPort ?? parseInt(process.env.FORGE_METRICS_PORT || "9090", 10);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
async function cmdStatus() {
|
|
489
|
+
const metricsPort = await resolveMetricsPort();
|
|
490
|
+
try {
|
|
491
|
+
const res = await fetch(`http://localhost:${metricsPort}/status`);
|
|
492
|
+
const data = await res.json();
|
|
493
|
+
|
|
494
|
+
// M-CLI-2: --json flag outputs raw JSON and exits
|
|
495
|
+
if (args.includes("--json")) {
|
|
496
|
+
console.log(JSON.stringify(data, null, 2));
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
console.log("");
|
|
501
|
+
console.log(" \u26A1 ThreadForge Status");
|
|
502
|
+
console.log(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
|
|
503
|
+
console.log(` Uptime: ${Math.floor(data.uptime)}s`);
|
|
504
|
+
console.log("");
|
|
505
|
+
|
|
506
|
+
console.log(" \u250C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510");
|
|
507
|
+
console.log(" \u2502 Group \u2502 Services \u2502 Port \u2502 Workers \u2502 PIDs \u2502");
|
|
508
|
+
console.log(" \u251C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2524");
|
|
509
|
+
|
|
510
|
+
for (const pg of data.processGroups) {
|
|
511
|
+
const group = pg.group.replace("_isolated:", "").padEnd(16);
|
|
512
|
+
const svcNames = pg.services.map((s) => s.name).join(", ");
|
|
513
|
+
const services = svcNames.substring(0, 25).padEnd(25);
|
|
514
|
+
const edgeService = pg.services.find((s) => s.type === "edge");
|
|
515
|
+
const port = edgeService ? String(edgeService.port).padEnd(5) : " \u2014 ";
|
|
516
|
+
const workers = String(pg.workers).padEnd(7);
|
|
517
|
+
const pidList = pg.pids.join(", ");
|
|
518
|
+
const pids = (pidList.length > 16 ? `${pg.pids.length} workers` : pidList).padEnd(16);
|
|
519
|
+
console.log(` \u2502 ${group} \u2502 ${services} \u2502 ${port} \u2502 ${workers} \u2502 ${pids} \u2502`);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
console.log(" \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518");
|
|
523
|
+
console.log(` CPUs: ${data.totalCpus} total`);
|
|
524
|
+
console.log("");
|
|
525
|
+
} catch (_err) {
|
|
526
|
+
console.error(" Could not connect to ThreadForge runtime.");
|
|
527
|
+
console.error("");
|
|
528
|
+
console.error(" Checklist:");
|
|
529
|
+
console.error(" - Is the runtime running? (forge start / forge dev)");
|
|
530
|
+
console.error(` - Is the metrics port correct? (currently ${metricsPort})`);
|
|
531
|
+
console.error(" - Is a firewall blocking the connection?");
|
|
532
|
+
process.exit(1);
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
function isProcessAlive(pid) {
|
|
537
|
+
try {
|
|
538
|
+
process.kill(pid, 0);
|
|
539
|
+
return true;
|
|
540
|
+
} catch (err) {
|
|
541
|
+
if (err.code === "ESRCH") return false;
|
|
542
|
+
if (err.code === "EPERM") return true;
|
|
543
|
+
throw err;
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
async function cmdStop() {
|
|
548
|
+
let pid = null;
|
|
549
|
+
|
|
550
|
+
// H4: Try reading PID from .forge.pid first
|
|
551
|
+
const pidFilePath = path.join(process.cwd(), ".forge.pid");
|
|
552
|
+
try {
|
|
553
|
+
const pidStr = fs.readFileSync(pidFilePath, "utf8").trim();
|
|
554
|
+
const filePid = Number(pidStr);
|
|
555
|
+
if (Number.isInteger(filePid) && filePid > 1 && isProcessAlive(filePid)) {
|
|
556
|
+
pid = filePid;
|
|
557
|
+
}
|
|
558
|
+
} catch {}
|
|
559
|
+
|
|
560
|
+
// Fall back to metrics endpoint if PID file is missing or stale
|
|
561
|
+
if (!pid) {
|
|
562
|
+
const metricsPort = await resolveMetricsPort();
|
|
563
|
+
let data;
|
|
564
|
+
try {
|
|
565
|
+
const res = await fetch(`http://localhost:${metricsPort}/status`);
|
|
566
|
+
if (!res.ok) throw new Error(`status endpoint returned ${res.status}`);
|
|
567
|
+
data = await res.json();
|
|
568
|
+
} catch (_err) {
|
|
569
|
+
console.error(" Could not connect to ThreadForge runtime.");
|
|
570
|
+
console.error(" Ensure it is running and the metrics port is correct.");
|
|
571
|
+
process.exit(1);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
pid = Number(data?.supervisorPid);
|
|
575
|
+
if (!Number.isInteger(pid) || pid < 2) {
|
|
576
|
+
console.error(" Could not determine supervisor PID from /status.");
|
|
577
|
+
process.exit(1);
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
if (pid === process.pid) {
|
|
581
|
+
console.error(" Refusing to signal current CLI process.");
|
|
582
|
+
process.exit(1);
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
try {
|
|
586
|
+
process.kill(pid, "SIGTERM");
|
|
587
|
+
} catch (err) {
|
|
588
|
+
if (err.code === "ESRCH") {
|
|
589
|
+
console.log(` Runtime already stopped (PID ${pid} not found).`);
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
if (err.code === "EPERM") {
|
|
593
|
+
console.error(` Permission denied stopping PID ${pid}.`);
|
|
594
|
+
process.exit(1);
|
|
595
|
+
}
|
|
596
|
+
throw err;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
const waitMs = 15000;
|
|
600
|
+
const deadline = Date.now() + waitMs;
|
|
601
|
+
while (Date.now() < deadline) {
|
|
602
|
+
if (!isProcessAlive(pid)) {
|
|
603
|
+
console.log(` \u2713 Stopped ThreadForge supervisor (PID ${pid}).`);
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
606
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// Final short settle window to reduce false "still draining" reports
|
|
610
|
+
// when the process exits right at the timeout boundary.
|
|
611
|
+
const settleMs = 1000;
|
|
612
|
+
const settleDeadline = Date.now() + settleMs;
|
|
613
|
+
while (Date.now() < settleDeadline) {
|
|
614
|
+
if (!isProcessAlive(pid)) {
|
|
615
|
+
console.log(` \u2713 Stopped ThreadForge supervisor (PID ${pid}).`);
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
console.log(` Shutdown in progress for supervisor PID ${pid} (waited ${Math.floor(waitMs / 1000)}s).`);
|
|
622
|
+
console.log(" It may still be draining (or may have exited moments ago). Run `forge status` to confirm.");
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function printHelp() {
|
|
626
|
+
console.log(`
|
|
627
|
+
\u26A1 ThreadForge CLI
|
|
628
|
+
|
|
629
|
+
Usage: forge <command> [options]
|
|
630
|
+
|
|
631
|
+
Commands:
|
|
632
|
+
init [dir] Scaffold a new ThreadForge project
|
|
633
|
+
dev Start in development mode
|
|
634
|
+
start Start in production mode
|
|
635
|
+
build Build frontend sites via frontend plugins
|
|
636
|
+
stop Stop a running ThreadForge supervisor
|
|
637
|
+
status Show runtime status
|
|
638
|
+
generate Generate route manifests for ForgeProxy
|
|
639
|
+
deploy Generate multi-machine deployment artifacts
|
|
640
|
+
scale <svc> <n> Scale a service (not yet implemented)
|
|
641
|
+
restart <svc> Restart a service (not yet implemented)
|
|
642
|
+
host <subcommand> Multi-project hosting (init, start, status, ...)
|
|
643
|
+
platform <subcommand> Platform mode (init, start, add, generate, ...)
|
|
644
|
+
|
|
645
|
+
Options:
|
|
646
|
+
--config <path> Path to config file (default: forge.config.js)
|
|
647
|
+
--source <mode> Init dependency source: auto|github|local|npm
|
|
648
|
+
--local Shorthand for: forge init --source local
|
|
649
|
+
--version, -v Show version number
|
|
650
|
+
--help Show this help message
|
|
651
|
+
|
|
652
|
+
Examples:
|
|
653
|
+
forge init
|
|
654
|
+
forge init my-app
|
|
655
|
+
forge init . --source local
|
|
656
|
+
forge dev
|
|
657
|
+
forge start --config ./my-config.js
|
|
658
|
+
forge build
|
|
659
|
+
forge stop
|
|
660
|
+
forge generate
|
|
661
|
+
forge status
|
|
662
|
+
`);
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
async function cmdBuild() {
|
|
666
|
+
const configPath = await findConfig();
|
|
667
|
+
if (!configPath) {
|
|
668
|
+
console.error(" Error: No config file found in current directory.");
|
|
669
|
+
console.error(` Searched for: ${FORGE_CONFIG_NAMES.join(", ")}`);
|
|
670
|
+
console.error(" Run `forge init` to create one, or use `--config <path>` to specify manually.");
|
|
671
|
+
process.exit(1);
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
let config;
|
|
675
|
+
try {
|
|
676
|
+
const configUrl = pathToFileURL(configPath).href;
|
|
677
|
+
config = await loadConfig(configUrl);
|
|
678
|
+
} catch (err) {
|
|
679
|
+
console.error(`\n ✗ Error loading ${path.basename(configPath)}:`);
|
|
680
|
+
console.error(` ${err.message}`);
|
|
681
|
+
process.exit(1);
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
const sitesMap = resolveSitesMap(config);
|
|
685
|
+
const frontendSites = Object.values(sitesMap).filter((site) => site?.frontend);
|
|
686
|
+
if (frontendSites.length === 0) {
|
|
687
|
+
console.log(" No frontend sites configured. Nothing to build.");
|
|
688
|
+
return;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
const { FrontendPluginOrchestrator } = await import("../src/frontend/FrontendPluginOrchestrator.js");
|
|
692
|
+
const orchestrator = new FrontendPluginOrchestrator();
|
|
693
|
+
const frontendPlugins = config.frontendPlugins ?? [];
|
|
694
|
+
|
|
695
|
+
try {
|
|
696
|
+
orchestrator.register(frontendPlugins);
|
|
697
|
+
await orchestrator.validateAll({ logger: console });
|
|
698
|
+
await orchestrator.registerAll({ logger: console });
|
|
699
|
+
} catch (err) {
|
|
700
|
+
console.error(` Frontend plugin setup failed: ${err.message}`);
|
|
701
|
+
process.exit(1);
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
if (orchestrator.plugins.size === 0) {
|
|
705
|
+
console.error(" Frontend sites are configured, but no frontendPlugins are registered in config.");
|
|
706
|
+
process.exit(1);
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
const results = [];
|
|
710
|
+
const mountsBySite = {};
|
|
711
|
+
let failures = 0;
|
|
712
|
+
|
|
713
|
+
try {
|
|
714
|
+
console.log(`\n ⚡ Building frontend sites (${frontendSites.length})\n`);
|
|
715
|
+
|
|
716
|
+
for (const site of frontendSites) {
|
|
717
|
+
const pluginName = site.frontend.plugin;
|
|
718
|
+
try {
|
|
719
|
+
console.log(` • ${site.siteId} (${pluginName})`);
|
|
720
|
+
const result = await orchestrator.buildSite(site, { logger: console });
|
|
721
|
+
const mounts = await orchestrator.staticMounts(site, { logger: console });
|
|
722
|
+
mountsBySite[site.siteId] = mounts;
|
|
723
|
+
results.push({
|
|
724
|
+
siteId: site.siteId,
|
|
725
|
+
plugin: pluginName,
|
|
726
|
+
outDir: result?.outDir ?? site.frontend.outDir,
|
|
727
|
+
});
|
|
728
|
+
console.log(` ✓ ${result?.outDir ?? site.frontend.outDir}`);
|
|
729
|
+
} catch (err) {
|
|
730
|
+
failures++;
|
|
731
|
+
console.error(` ✗ ${err.message}`);
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
} finally {
|
|
735
|
+
await orchestrator.disposeAll({ logger: console });
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
const outputDir = path.join(process.cwd(), ".threadforge");
|
|
739
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
740
|
+
const manifestPath = path.join(outputDir, "frontend-manifest.json");
|
|
741
|
+
fs.writeFileSync(
|
|
742
|
+
manifestPath,
|
|
743
|
+
JSON.stringify(
|
|
744
|
+
{
|
|
745
|
+
generatedAt: new Date().toISOString(),
|
|
746
|
+
sites: results,
|
|
747
|
+
mounts: mountsBySite,
|
|
748
|
+
},
|
|
749
|
+
null,
|
|
750
|
+
2,
|
|
751
|
+
),
|
|
752
|
+
);
|
|
753
|
+
|
|
754
|
+
console.log(`\n Wrote frontend manifest: ${manifestPath}`);
|
|
755
|
+
if (failures > 0) {
|
|
756
|
+
console.error(` Frontend build completed with ${failures} failure(s).`);
|
|
757
|
+
process.exit(1);
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
async function cmdDeploy() {
|
|
762
|
+
const configPath = await findConfig();
|
|
763
|
+
if (!configPath) {
|
|
764
|
+
console.error(" Error: No config file found in current directory.");
|
|
765
|
+
console.error(` Searched for: ${FORGE_CONFIG_NAMES.join(", ")}`);
|
|
766
|
+
console.error(" Run `forge init` to create one, or use `--config <path>` to specify manually.");
|
|
767
|
+
process.exit(1);
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
// Look for forge.deploy.js
|
|
771
|
+
const deployPath = path.join(process.cwd(), "forge.deploy.js");
|
|
772
|
+
if (!fs.existsSync(deployPath)) {
|
|
773
|
+
console.log(` No forge.deploy.js found. Creating a template...`);
|
|
774
|
+
const template = generateDeployTemplate();
|
|
775
|
+
fs.writeFileSync(deployPath, template);
|
|
776
|
+
console.log(` Created: ${deployPath}`);
|
|
777
|
+
console.log(` Edit the manifest with your node addresses, then run 'forge deploy' again.`);
|
|
778
|
+
return;
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
const { generateAll } = await import("../src/deploy/index.js");
|
|
782
|
+
|
|
783
|
+
// Load base config
|
|
784
|
+
let config;
|
|
785
|
+
try {
|
|
786
|
+
const configUrl = pathToFileURL(configPath).href;
|
|
787
|
+
config = await loadConfig(configUrl);
|
|
788
|
+
} catch (err) {
|
|
789
|
+
console.error(`\n \u2717 Error loading ${path.basename(configPath)}:`);
|
|
790
|
+
console.error(` ${err.message}`);
|
|
791
|
+
if (err.code === "ERR_MODULE_NOT_FOUND") {
|
|
792
|
+
console.error(" Hint: Check your import paths and run `npm install` to ensure dependencies are installed.");
|
|
793
|
+
}
|
|
794
|
+
process.exit(1);
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
// Load deploy manifest
|
|
798
|
+
let deployMod;
|
|
799
|
+
try {
|
|
800
|
+
const deployUrl = pathToFileURL(deployPath).href;
|
|
801
|
+
deployMod = await import(deployUrl);
|
|
802
|
+
} catch (err) {
|
|
803
|
+
console.error(`\n \u2717 Error loading ${path.basename(deployPath)}:`);
|
|
804
|
+
console.error(` ${err.message}`);
|
|
805
|
+
process.exit(1);
|
|
806
|
+
}
|
|
807
|
+
const manifest = deployMod.default ?? deployMod;
|
|
808
|
+
|
|
809
|
+
const outputDir = path.join(process.cwd(), "deploy");
|
|
810
|
+
const isDryRun = args.includes("--dry-run");
|
|
811
|
+
|
|
812
|
+
try {
|
|
813
|
+
if (isDryRun) {
|
|
814
|
+
// M-CLI-3: Dry run — validate and show what would be generated without writing
|
|
815
|
+
const { loadManifest, generateNodeConfigs } = await import("../src/deploy/index.js");
|
|
816
|
+
const validated = loadManifest(manifest, config.services);
|
|
817
|
+
|
|
818
|
+
console.log(`\n \u26A1 Dry run — no files will be written\n`);
|
|
819
|
+
console.log(` Nodes:`);
|
|
820
|
+
for (const [nodeName, nodeDef] of Object.entries(manifest.nodes)) {
|
|
821
|
+
console.log(` ${nodeName.padEnd(16)} ${nodeDef.host.padEnd(16)} [${nodeDef.services.join(", ")}]`);
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
const nodeNames = Object.keys(manifest.nodes);
|
|
825
|
+
const files = [
|
|
826
|
+
...nodeNames.map((n) => `${n}/forge.config.js`),
|
|
827
|
+
"docker-compose.yml",
|
|
828
|
+
"nginx.conf",
|
|
829
|
+
...nodeNames.map((n) => `forge-${n}.service`),
|
|
830
|
+
...nodeNames.map((n) => `${n}.env`),
|
|
831
|
+
"deploy.sh",
|
|
832
|
+
".env.example",
|
|
833
|
+
".gitignore",
|
|
834
|
+
];
|
|
835
|
+
|
|
836
|
+
console.log(`\n Files that WOULD be generated:`);
|
|
837
|
+
for (const file of files) {
|
|
838
|
+
console.log(` deploy/${file}`);
|
|
839
|
+
}
|
|
840
|
+
return;
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
const result = generateAll(manifest, config.services, outputDir);
|
|
844
|
+
|
|
845
|
+
console.log(`\n \u26A1 Deployment artifacts generated\n`);
|
|
846
|
+
console.log(` Nodes:`);
|
|
847
|
+
for (const node of result.nodes) {
|
|
848
|
+
const nodeDef = manifest.nodes[node];
|
|
849
|
+
console.log(` ${node.padEnd(16)} ${nodeDef.host.padEnd(16)} [${nodeDef.services.join(", ")}]`);
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
console.log(`\n Files:`);
|
|
853
|
+
for (const file of result.files) {
|
|
854
|
+
console.log(` deploy/${file}`);
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
console.log(`\n Next steps:`);
|
|
858
|
+
console.log(` 1. Review the generated configs in ./deploy/`);
|
|
859
|
+
console.log(` 2. Test locally: docker compose -f deploy/docker-compose.yml up`);
|
|
860
|
+
console.log(` 3. Deploy: ./deploy/deploy.sh`);
|
|
861
|
+
console.log(` 4. Rolling deploy: ./deploy/deploy.sh --rolling`);
|
|
862
|
+
} catch (err) {
|
|
863
|
+
console.error(` Error: ${err.message}`);
|
|
864
|
+
process.exit(1);
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
function generateDeployTemplate() {
|
|
869
|
+
return `// forge.deploy.js \u2014 Multi-machine deployment manifest
|
|
870
|
+
// Edit this file with your node addresses, then run: forge deploy
|
|
871
|
+
|
|
872
|
+
export default {
|
|
873
|
+
cluster: 'my-saas',
|
|
874
|
+
|
|
875
|
+
nodes: {
|
|
876
|
+
'web-1': {
|
|
877
|
+
host: '10.0.1.10',
|
|
878
|
+
services: ['gateway'],
|
|
879
|
+
role: 'edge',
|
|
880
|
+
},
|
|
881
|
+
'api-1': {
|
|
882
|
+
host: '10.0.1.20',
|
|
883
|
+
services: ['users'],
|
|
884
|
+
role: 'api',
|
|
885
|
+
},
|
|
886
|
+
'worker-1': {
|
|
887
|
+
host: '10.0.1.30',
|
|
888
|
+
services: ['notifications'],
|
|
889
|
+
role: 'worker',
|
|
890
|
+
},
|
|
891
|
+
},
|
|
892
|
+
|
|
893
|
+
registry: 'multicast',
|
|
894
|
+
httpBasePort: 4000,
|
|
895
|
+
metricsPort: 9090,
|
|
896
|
+
};
|
|
897
|
+
`;
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
async function cmdGenerate() {
|
|
901
|
+
const configPath = await findConfig();
|
|
902
|
+
if (!configPath) {
|
|
903
|
+
console.error(" Error: No config file found in current directory.");
|
|
904
|
+
console.error(` Searched for: ${FORGE_CONFIG_NAMES.join(", ")}`);
|
|
905
|
+
console.error(" Run `forge init` to create one, or use `--config <path>` to specify manually.");
|
|
906
|
+
process.exit(1);
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
const { getContract } = await import("../src/decorators/index.js");
|
|
910
|
+
const { generateRouteManifest } = await import("../src/deploy/RouteManifestGenerator.js");
|
|
911
|
+
|
|
912
|
+
let config;
|
|
913
|
+
try {
|
|
914
|
+
const configUrl = pathToFileURL(configPath).href;
|
|
915
|
+
config = await loadConfig(configUrl);
|
|
916
|
+
} catch (err) {
|
|
917
|
+
console.error(`\n \u2717 Error loading configuration:`);
|
|
918
|
+
console.error(` ${err.message}`);
|
|
919
|
+
process.exit(1);
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
// Load all service classes to read their contracts
|
|
923
|
+
const serviceClasses = new Map();
|
|
924
|
+
for (const [name, svc] of Object.entries(config.services)) {
|
|
925
|
+
if (!svc.entry) continue;
|
|
926
|
+
try {
|
|
927
|
+
const entryPath = path.resolve(process.cwd(), svc.entry);
|
|
928
|
+
const entryUrl = pathToFileURL(entryPath).href;
|
|
929
|
+
const mod = await import(entryUrl);
|
|
930
|
+
serviceClasses.set(name, mod.default ?? mod);
|
|
931
|
+
} catch (err) {
|
|
932
|
+
console.warn(` \u26A0 Could not load ${name}: ${err.message}`);
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
// Create output directory
|
|
937
|
+
const routesDir = path.join(process.cwd(), "routes");
|
|
938
|
+
fs.mkdirSync(routesDir, { recursive: true });
|
|
939
|
+
|
|
940
|
+
let routeManifests = 0;
|
|
941
|
+
|
|
942
|
+
for (const [name, ServiceClass] of serviceClasses) {
|
|
943
|
+
const contract = getContract(ServiceClass);
|
|
944
|
+
if (!contract || contract.methods.size === 0) continue;
|
|
945
|
+
|
|
946
|
+
// Generate route manifest for ForgeProxy
|
|
947
|
+
const svcConfig = config.services[name] ?? {};
|
|
948
|
+
const manifest = generateRouteManifest(name, ServiceClass, svcConfig);
|
|
949
|
+
if (manifest) {
|
|
950
|
+
const manifestPath = path.join(routesDir, `${name}.yaml`);
|
|
951
|
+
fs.writeFileSync(manifestPath, manifest);
|
|
952
|
+
routeManifests++;
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
console.log(` \u2713 ${name}`);
|
|
956
|
+
if (manifest) console.log(` routes/${name}.yaml`);
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
if (routeManifests === 0) {
|
|
960
|
+
console.log(" No services with contracts found. Add static contract to your services.");
|
|
961
|
+
} else {
|
|
962
|
+
console.log(`\n Generated ${routeManifests} route manifest(s):`);
|
|
963
|
+
console.log(` Routes: ./routes/ (${routeManifests} manifests for ForgeProxy)`);
|
|
964
|
+
console.log(`\n ForgeProxy reads ./routes/*.yaml automatically on startup.`);
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
// -- Main --
|
|
969
|
+
|
|
970
|
+
if (args.includes('--version') || args.includes('-v')) {
|
|
971
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
972
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8'));
|
|
973
|
+
console.log(`threadforge v${pkg.version}`);
|
|
974
|
+
process.exit(0);
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
switch (command) {
|
|
978
|
+
case "dev":
|
|
979
|
+
cmdStart(true);
|
|
980
|
+
break;
|
|
981
|
+
|
|
982
|
+
case "start":
|
|
983
|
+
cmdStart(false);
|
|
984
|
+
break;
|
|
985
|
+
|
|
986
|
+
case "build":
|
|
987
|
+
cmdBuild();
|
|
988
|
+
break;
|
|
989
|
+
|
|
990
|
+
case "init":
|
|
991
|
+
{
|
|
992
|
+
const { targetDir, source } = parseInitArgs(args.slice(1));
|
|
993
|
+
cmdInit(targetDir, { source });
|
|
994
|
+
}
|
|
995
|
+
break;
|
|
996
|
+
|
|
997
|
+
case "status":
|
|
998
|
+
cmdStatus();
|
|
999
|
+
break;
|
|
1000
|
+
|
|
1001
|
+
case "stop":
|
|
1002
|
+
cmdStop();
|
|
1003
|
+
break;
|
|
1004
|
+
|
|
1005
|
+
case "generate":
|
|
1006
|
+
cmdGenerate();
|
|
1007
|
+
break;
|
|
1008
|
+
|
|
1009
|
+
case "deploy":
|
|
1010
|
+
cmdDeploy();
|
|
1011
|
+
break;
|
|
1012
|
+
|
|
1013
|
+
case "scale":
|
|
1014
|
+
console.error(" `forge scale` is not yet implemented.");
|
|
1015
|
+
console.error(" To scale a service, update the `threads` field in your config and restart.");
|
|
1016
|
+
console.error(" Auto-scaling recommendations are available via ScaleAdvisor (see `forge status`).");
|
|
1017
|
+
process.exit(1);
|
|
1018
|
+
break;
|
|
1019
|
+
|
|
1020
|
+
case "restart":
|
|
1021
|
+
console.error(" `forge restart` is not yet implemented.");
|
|
1022
|
+
console.error(" To restart, run `forge stop` followed by `forge start`.");
|
|
1023
|
+
console.error(" For zero-downtime restarts, use a process manager (systemd, pm2) or rolling deploy.");
|
|
1024
|
+
process.exit(1);
|
|
1025
|
+
break;
|
|
1026
|
+
|
|
1027
|
+
case "host": {
|
|
1028
|
+
const { cmdHost } = await import("./host-commands.js");
|
|
1029
|
+
cmdHost(args.slice(1));
|
|
1030
|
+
break;
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
case "platform": {
|
|
1034
|
+
const { cmdPlatform } = await import("./platform-commands.js");
|
|
1035
|
+
cmdPlatform(args.slice(1));
|
|
1036
|
+
break;
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
case "help":
|
|
1040
|
+
case "--help":
|
|
1041
|
+
case "-h":
|
|
1042
|
+
case undefined:
|
|
1043
|
+
printHelp();
|
|
1044
|
+
break;
|
|
1045
|
+
|
|
1046
|
+
default:
|
|
1047
|
+
console.error(` Unknown command: ${command}`);
|
|
1048
|
+
printHelp();
|
|
1049
|
+
process.exit(1);
|
|
1050
|
+
}
|