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.
Files changed (51) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +152 -0
  3. package/bin/forge.js +1050 -0
  4. package/bin/host-commands.js +344 -0
  5. package/bin/platform-commands.js +570 -0
  6. package/package.json +71 -0
  7. package/shared/auth.js +475 -0
  8. package/src/core/DirectMessageBus.js +364 -0
  9. package/src/core/EndpointResolver.js +247 -0
  10. package/src/core/ForgeContext.js +2227 -0
  11. package/src/core/ForgeHost.js +122 -0
  12. package/src/core/ForgePlatform.js +145 -0
  13. package/src/core/Ingress.js +768 -0
  14. package/src/core/Interceptors.js +420 -0
  15. package/src/core/MessageBus.js +310 -0
  16. package/src/core/Prometheus.js +305 -0
  17. package/src/core/RequestContext.js +413 -0
  18. package/src/core/RoutingStrategy.js +316 -0
  19. package/src/core/Supervisor.js +1306 -0
  20. package/src/core/ThreadAllocator.js +196 -0
  21. package/src/core/WorkerChannelManager.js +879 -0
  22. package/src/core/config.js +624 -0
  23. package/src/core/host-config.js +311 -0
  24. package/src/core/network-utils.js +166 -0
  25. package/src/core/platform-config.js +308 -0
  26. package/src/decorators/ServiceProxy.js +899 -0
  27. package/src/decorators/index.js +571 -0
  28. package/src/deploy/NginxGenerator.js +865 -0
  29. package/src/deploy/PlatformManifestGenerator.js +96 -0
  30. package/src/deploy/RouteManifestGenerator.js +112 -0
  31. package/src/deploy/index.js +984 -0
  32. package/src/frontend/FrontendDevLifecycle.js +65 -0
  33. package/src/frontend/FrontendPluginOrchestrator.js +187 -0
  34. package/src/frontend/SiteResolver.js +63 -0
  35. package/src/frontend/StaticMountRegistry.js +90 -0
  36. package/src/frontend/index.js +5 -0
  37. package/src/frontend/plugins/index.js +2 -0
  38. package/src/frontend/plugins/viteFrontend.js +79 -0
  39. package/src/frontend/types.js +35 -0
  40. package/src/index.js +56 -0
  41. package/src/internals.js +31 -0
  42. package/src/plugins/PluginManager.js +537 -0
  43. package/src/plugins/ScopedPostgres.js +192 -0
  44. package/src/plugins/ScopedRedis.js +142 -0
  45. package/src/plugins/index.js +1729 -0
  46. package/src/registry/ServiceRegistry.js +796 -0
  47. package/src/scaling/ScaleAdvisor.js +442 -0
  48. package/src/services/Service.js +195 -0
  49. package/src/services/worker-bootstrap.js +676 -0
  50. package/src/templates/auth-service.js +65 -0
  51. 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
+ }