@danielwyq/netchat 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,553 @@
1
+ import { createRequire as __createRequire } from "node:module"; const require = __createRequire(import.meta.url);
2
+
3
+ // apps/server/src/local-app.ts
4
+ import { spawn } from "node:child_process";
5
+ import { createHash } from "node:crypto";
6
+ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
7
+ import { createServer } from "node:net";
8
+ import os from "node:os";
9
+ import path from "node:path";
10
+ import { setTimeout as delay } from "node:timers/promises";
11
+ import { fileURLToPath } from "node:url";
12
+ var runtimeRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..");
13
+ var sourceMode = existsSync(path.join(runtimeRoot, "apps", "server", "src", "index.ts"));
14
+ var tsxCliPath = path.join(runtimeRoot, "node_modules", "tsx", "dist", "cli.mjs");
15
+ var sourceWebDistPath = path.join(runtimeRoot, "apps", "web", "dist");
16
+ var packagedWebDistPath = path.join(runtimeRoot, "dist", "web");
17
+ var webDistPath = sourceMode ? sourceWebDistPath : packagedWebDistPath;
18
+ var webDistIndexPath = path.join(webDistPath, "index.html");
19
+ var webBuildMarkerPath = path.join(webDistPath, ".netchat-local-build.json");
20
+ var webSourceRoots = [
21
+ path.join(runtimeRoot, "apps", "web", "src"),
22
+ path.join(runtimeRoot, "apps", "web", "index.html"),
23
+ path.join(runtimeRoot, "apps", "web", "package.json"),
24
+ path.join(runtimeRoot, "apps", "web", "tsconfig.json"),
25
+ path.join(runtimeRoot, "apps", "web", "vite.config.ts"),
26
+ path.join(runtimeRoot, "apps", "web", "tailwind.config.ts"),
27
+ path.join(runtimeRoot, "apps", "web", "postcss.config.cjs")
28
+ ];
29
+ var sourceServerEntryPath = path.join(runtimeRoot, "apps", "server", "src", "index.ts");
30
+ var packagedServerEntryPath = path.join(runtimeRoot, "dist", "apps", "server", "index.mjs");
31
+ var sourceDaemonEntryPath = path.join(runtimeRoot, "apps", "daemon", "src", "index.ts");
32
+ var packagedDaemonEntryPath = path.join(runtimeRoot, "dist", "apps", "daemon", "index.mjs");
33
+ var managedChildren = [];
34
+ var shuttingDown = false;
35
+ void main().catch(async (error) => {
36
+ console.error(`[netchat-local] ${error instanceof Error ? error.message : String(error)}`);
37
+ await shutdown("Local app failed to start cleanly.", 1);
38
+ });
39
+ async function main() {
40
+ if (sourceMode && !existsSync(tsxCliPath)) {
41
+ throw new Error(`tsx is not installed at ${tsxCliPath}. Run npm install first.`);
42
+ }
43
+ const config = await resolveLocalAppConfig(process.argv.slice(2));
44
+ mkdirSync(config.appDataDirectory, { recursive: true });
45
+ log("Preparing the local web app...");
46
+ if (!sourceMode) {
47
+ log("Using the packaged web build.");
48
+ } else {
49
+ const npmCommand = resolveNpmCommand();
50
+ const buildReason = resolveWebBuildReason(config);
51
+ if (config.webBuildMode === "skip") {
52
+ log("Skipping the web build because --skip-web-build was requested.");
53
+ } else if (buildReason) {
54
+ log(buildReason);
55
+ await runCommand(npmCommand.command, [...npmCommand.args, "run", "build:web"], {
56
+ cwd: runtimeRoot,
57
+ env: process.env,
58
+ shell: npmCommand.shell,
59
+ stdio: "inherit"
60
+ });
61
+ writeWebBuildMarker();
62
+ } else {
63
+ log("Reusing the existing web build.");
64
+ }
65
+ }
66
+ if (!existsSync(webDistIndexPath)) {
67
+ throw new Error(`Local web build not found at ${webDistPath}.`);
68
+ }
69
+ const serverProcess = startProcess("controller", process.execPath, resolveManagedEntryArgs("server"), {
70
+ cwd: runtimeRoot,
71
+ env: {
72
+ ...process.env,
73
+ NODE_NO_WARNINGS: "1",
74
+ NETCHAT_APP_DATA_DIR: config.appDataDirectory,
75
+ NETCHAT_DAEMON_URL: config.daemonUrl,
76
+ NETCHAT_LAUNCH_CWD: process.env.NETCHAT_LAUNCH_CWD ?? config.workingDirectory,
77
+ NETCHAT_WORKSPACE_DIR: config.workingDirectory,
78
+ NETCHAT_LOCAL_MODE: "true",
79
+ NETCHAT_SHOW_SESSION_IDS: String(config.showSessionIds),
80
+ NETCHAT_WEB_DIST_PATH: webDistPath,
81
+ PORT: String(config.serverPort),
82
+ ...config.databasePath ? { NETCHAT_APP_DB_PATH: config.databasePath } : {}
83
+ },
84
+ stdio: "inherit"
85
+ });
86
+ void serverProcess.exitPromise.catch(() => void 0);
87
+ await waitForHealth(`${config.serverUrl}/health`);
88
+ const daemonProcess = startProcess("daemon", process.execPath, resolveManagedEntryArgs("daemon"), {
89
+ cwd: runtimeRoot,
90
+ env: {
91
+ ...process.env,
92
+ NODE_NO_WARNINGS: "1",
93
+ CLAUDE_PROJECT_CWD: process.env.CLAUDE_PROJECT_CWD ?? config.workingDirectory,
94
+ DAEMON_PORT: String(config.daemonPort),
95
+ NETCHAT_APP_DATA_DIR: config.appDataDirectory,
96
+ NETCHAT_LAUNCH_CWD: process.env.NETCHAT_LAUNCH_CWD ?? config.workingDirectory,
97
+ NETCHAT_LOCAL_MODE: "true",
98
+ NETCHAT_MACHINE_STATE_PATH: config.machineStatePath,
99
+ NETCHAT_SERVER_URL: config.serverUrl,
100
+ NETCHAT_WORKSPACE_DIR: config.workingDirectory
101
+ },
102
+ stdio: "inherit"
103
+ });
104
+ void daemonProcess.exitPromise.catch(() => void 0);
105
+ await waitForHealth(`${config.daemonUrl}/health`);
106
+ await waitForOnlineMachine(`${config.serverUrl}/api/machines`);
107
+ log(`Local controller ready at ${config.serverUrl}.`);
108
+ log(`Workspace-scoped net history will persist under ${config.appDataDirectory}.`);
109
+ if (config.showSessionIds) {
110
+ log("Developer mode is enabled: every message bubble will show its session_id.");
111
+ }
112
+ if (config.openBrowser) {
113
+ await openBrowser(config.serverUrl);
114
+ }
115
+ await Promise.race([serverProcess.exitPromise, daemonProcess.exitPromise]);
116
+ }
117
+ process.on("SIGINT", () => {
118
+ void shutdown("Received SIGINT, stopping local services...");
119
+ });
120
+ process.on("SIGTERM", () => {
121
+ void shutdown("Received SIGTERM, stopping local services...");
122
+ });
123
+ async function shutdown(message, exitCode = 0) {
124
+ if (shuttingDown) {
125
+ return;
126
+ }
127
+ shuttingDown = true;
128
+ log(message);
129
+ for (const child of managedChildren) {
130
+ if (!child.killed) {
131
+ child.kill();
132
+ }
133
+ }
134
+ await delay(200);
135
+ process.exit(exitCode);
136
+ }
137
+ function startProcess(description, command, args, options) {
138
+ const child = spawn(command, args, options);
139
+ managedChildren.push(child);
140
+ const exitPromise = new Promise((_, reject) => {
141
+ child.on("exit", (code, signal) => {
142
+ if (shuttingDown) {
143
+ return;
144
+ }
145
+ const detail = code !== null ? `exit code ${code}` : signal ? `signal ${signal}` : "an unknown reason";
146
+ reject(new Error(`${description} process stopped with ${detail}.`));
147
+ void shutdown(`${description} process stopped with ${detail}.`, 1);
148
+ });
149
+ child.on("error", (error) => {
150
+ if (shuttingDown) {
151
+ return;
152
+ }
153
+ reject(error);
154
+ void shutdown(`Failed to start the ${description} process: ${error.message}`, 1);
155
+ });
156
+ });
157
+ return {
158
+ child,
159
+ exitPromise
160
+ };
161
+ }
162
+ function resolveManagedEntryArgs(target) {
163
+ if (sourceMode) {
164
+ return [tsxCliPath, target === "server" ? sourceServerEntryPath : sourceDaemonEntryPath];
165
+ }
166
+ return [target === "server" ? packagedServerEntryPath : packagedDaemonEntryPath];
167
+ }
168
+ async function resolveLocalAppConfig(argv) {
169
+ const parsedArgs = parseLocalAppArgs(argv);
170
+ const workingDirectory = resolveLaunchWorkingDirectory();
171
+ const appDataDirectory = parsedArgs.appDataDirectory ?? readStringEnv("NETCHAT_APP_DATA_DIR") ?? path.join(os.homedir(), ".netchat", "workspaces", createWorkspaceStorageKey(workingDirectory));
172
+ const databasePath = parsedArgs.databasePath ?? readStringEnv("NETCHAT_APP_DB_PATH");
173
+ const machineStatePath = parsedArgs.machineStatePath ?? readStringEnv("NETCHAT_MACHINE_STATE_PATH") ?? path.join(appDataDirectory, "machine.json");
174
+ const configuredServerPort = parsedArgs.serverPort ?? readPortEnv("PORT");
175
+ const serverPort = await resolvePort("controller", configuredServerPort ?? 3001, {
176
+ explicit: configuredServerPort !== null
177
+ });
178
+ const configuredDaemonPort = parsedArgs.daemonPort ?? readPortEnv("DAEMON_PORT");
179
+ const daemonPort = await resolvePort("daemon", configuredDaemonPort ?? 4318, {
180
+ explicit: configuredDaemonPort !== null,
181
+ reservedPorts: /* @__PURE__ */ new Set([serverPort])
182
+ });
183
+ const openBrowser2 = parsedArgs.openBrowser ?? !readBooleanEnv("NETCHAT_NO_BROWSER");
184
+ const webBuildMode = parsedArgs.webBuildMode ?? (readBooleanEnv("NETCHAT_FORCE_WEB_BUILD") ? "force" : readBooleanEnv("NETCHAT_SKIP_WEB_BUILD") ? "skip" : "auto");
185
+ const showSessionIds = parsedArgs.showSessionIds ?? readBooleanEnv("NETCHAT_SHOW_SESSION_IDS");
186
+ return {
187
+ appDataDirectory,
188
+ databasePath,
189
+ machineStatePath,
190
+ workingDirectory,
191
+ serverPort,
192
+ daemonPort,
193
+ serverUrl: `http://127.0.0.1:${serverPort}`,
194
+ daemonUrl: `http://127.0.0.1:${daemonPort}`,
195
+ openBrowser: openBrowser2,
196
+ webBuildMode,
197
+ showSessionIds
198
+ };
199
+ }
200
+ function parseLocalAppArgs(argv) {
201
+ const parsed = {
202
+ appDataDirectory: null,
203
+ databasePath: null,
204
+ machineStatePath: null,
205
+ serverPort: null,
206
+ daemonPort: null,
207
+ openBrowser: null,
208
+ webBuildMode: null,
209
+ showSessionIds: null
210
+ };
211
+ for (let index = 0; index < argv.length; index += 1) {
212
+ const token = argv[index];
213
+ const [flag, inlineValue] = token.split("=", 2);
214
+ switch (flag) {
215
+ case "--help":
216
+ case "-h":
217
+ printLocalAppHelp();
218
+ process.exit(0);
219
+ case "--port": {
220
+ const option = readOptionValue(flag, inlineValue, argv, index);
221
+ parsed.serverPort = parsePort(flag, option.value);
222
+ index = option.nextIndex;
223
+ break;
224
+ }
225
+ case "--daemon-port": {
226
+ const option = readOptionValue(flag, inlineValue, argv, index);
227
+ parsed.daemonPort = parsePort(flag, option.value);
228
+ index = option.nextIndex;
229
+ break;
230
+ }
231
+ case "--data-dir":
232
+ case "--app-data-dir": {
233
+ const option = readOptionValue(flag, inlineValue, argv, index);
234
+ parsed.appDataDirectory = option.value;
235
+ index = option.nextIndex;
236
+ break;
237
+ }
238
+ case "--db-path":
239
+ case "--app-db-path": {
240
+ const option = readOptionValue(flag, inlineValue, argv, index);
241
+ parsed.databasePath = option.value;
242
+ index = option.nextIndex;
243
+ break;
244
+ }
245
+ case "--machine-state-path": {
246
+ const option = readOptionValue(flag, inlineValue, argv, index);
247
+ parsed.machineStatePath = option.value;
248
+ index = option.nextIndex;
249
+ break;
250
+ }
251
+ case "--no-browser":
252
+ parsed.openBrowser = false;
253
+ break;
254
+ case "--browser":
255
+ parsed.openBrowser = true;
256
+ break;
257
+ case "--skip-web-build":
258
+ parsed.webBuildMode = "skip";
259
+ break;
260
+ case "--rebuild-web":
261
+ case "--force-web-build":
262
+ parsed.webBuildMode = "force";
263
+ break;
264
+ case "--show-session-ids":
265
+ parsed.showSessionIds = true;
266
+ break;
267
+ default:
268
+ throw new Error(`Unknown option ${token}. Run with --help to see supported flags.`);
269
+ }
270
+ }
271
+ return parsed;
272
+ }
273
+ function readOptionValue(flag, inlineValue, argv, index) {
274
+ if (inlineValue !== void 0) {
275
+ return {
276
+ value: inlineValue,
277
+ nextIndex: index
278
+ };
279
+ }
280
+ const nextValue = argv[index + 1];
281
+ if (!nextValue || nextValue.startsWith("--")) {
282
+ throw new Error(`Missing value for ${flag}.`);
283
+ }
284
+ return {
285
+ value: nextValue,
286
+ nextIndex: index + 1
287
+ };
288
+ }
289
+ function printLocalAppHelp() {
290
+ console.log(
291
+ [
292
+ "netchat local",
293
+ "",
294
+ "Start the controller, daemon, and web UI with local-first defaults.",
295
+ "",
296
+ "Examples:",
297
+ " npx @danielwyq/netchat@latest",
298
+ " npx @danielwyq/netchat@latest --no-browser",
299
+ " npx @danielwyq/netchat@latest --show-session-ids",
300
+ " npx @danielwyq/netchat@latest --port 3002 --daemon-port 4319",
301
+ "",
302
+ "Options:",
303
+ " --port <number> Controller port (default: 3001)",
304
+ " --daemon-port <number> Daemon port (default: 4318)",
305
+ " --data-dir <path> Override the local app data directory",
306
+ " --db-path <path> Override the SQLite database path",
307
+ " --machine-state-path <path> Override the daemon machine-state path",
308
+ " --no-browser Do not open the browser automatically",
309
+ " --browser Force opening the browser even if env overrides disable it",
310
+ " --skip-web-build Reuse the existing web build without rebuilding",
311
+ " --rebuild-web Force a fresh web build before startup",
312
+ " --show-session-ids Developer flag: render each message bubble's session_id"
313
+ ].join("\n")
314
+ );
315
+ }
316
+ function parsePort(flag, value) {
317
+ const port = Number(value);
318
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
319
+ throw new Error(`${flag} must be an integer between 1 and 65535.`);
320
+ }
321
+ return port;
322
+ }
323
+ function readStringEnv(name) {
324
+ const value = process.env[name]?.trim();
325
+ return value && value.length > 0 ? value : null;
326
+ }
327
+ function readBooleanEnv(name) {
328
+ return process.env[name]?.trim().toLowerCase() === "true";
329
+ }
330
+ function readPortEnv(name) {
331
+ const value = readStringEnv(name);
332
+ return value ? parsePort(name, value) : null;
333
+ }
334
+ function resolveLaunchWorkingDirectory() {
335
+ const configuredPath = readStringEnv("NETCHAT_WORKSPACE_DIR") ?? readStringEnv("NETCHAT_LAUNCH_CWD") ?? readStringEnv("CLAUDE_PROJECT_CWD") ?? process.cwd();
336
+ return normalizeWorkingDirectory(configuredPath);
337
+ }
338
+ function normalizeWorkingDirectory(value) {
339
+ return path.resolve(value).replace(/\\/g, "/");
340
+ }
341
+ function createWorkspaceStorageKey(workingDirectory) {
342
+ const normalized = process.platform === "win32" ? workingDirectory.toLowerCase() : workingDirectory;
343
+ return createHash("sha256").update(normalized).digest("hex").slice(0, 16);
344
+ }
345
+ async function resolvePort(label, preferredPort, options) {
346
+ const reservedPorts = options.reservedPorts ?? /* @__PURE__ */ new Set();
347
+ if (reservedPorts.has(preferredPort)) {
348
+ if (options.explicit) {
349
+ throw new Error(`The requested ${label} port ${preferredPort} is already reserved by another netchat service.`);
350
+ }
351
+ const nextPort2 = await findAvailablePort(preferredPort + 1, reservedPorts);
352
+ log(`Port ${preferredPort} is reserved already, so the local ${label} will use ${nextPort2}.`);
353
+ return nextPort2;
354
+ }
355
+ if (await isPortAvailable(preferredPort)) {
356
+ return preferredPort;
357
+ }
358
+ if (options.explicit) {
359
+ throw new Error(`The requested ${label} port ${preferredPort} is already in use.`);
360
+ }
361
+ const nextPort = await findAvailablePort(preferredPort + 1, reservedPorts);
362
+ log(`Port ${preferredPort} is already in use, so the local ${label} will use ${nextPort}.`);
363
+ return nextPort;
364
+ }
365
+ async function findAvailablePort(startPort, reservedPorts) {
366
+ for (let port = startPort; port <= 65535; port += 1) {
367
+ if (reservedPorts.has(port)) {
368
+ continue;
369
+ }
370
+ if (await isPortAvailable(port)) {
371
+ return port;
372
+ }
373
+ }
374
+ throw new Error("Could not find a free local port for netchat.");
375
+ }
376
+ function isPortAvailable(port) {
377
+ return new Promise((resolve) => {
378
+ const probe = createServer();
379
+ probe.once("error", () => {
380
+ resolve(false);
381
+ });
382
+ probe.once("listening", () => {
383
+ probe.close(() => resolve(true));
384
+ });
385
+ probe.listen({
386
+ port,
387
+ host: "0.0.0.0"
388
+ });
389
+ });
390
+ }
391
+ function resolveWebBuildReason(config) {
392
+ if (!sourceMode) {
393
+ return null;
394
+ }
395
+ if (config.webBuildMode === "force") {
396
+ return "Building the web UI because --rebuild-web was requested.";
397
+ }
398
+ if (!existsSync(webDistIndexPath)) {
399
+ return "Building the web UI because no local build is available.";
400
+ }
401
+ const existingMarker = readWebBuildMarker();
402
+ if (!existingMarker) {
403
+ return "Building the web UI because the existing local build predates the portable runtime bundle.";
404
+ }
405
+ if (isWebBuildOutdated()) {
406
+ return "Building the web UI because the frontend sources changed since the last local build.";
407
+ }
408
+ return null;
409
+ }
410
+ function readWebBuildMarker() {
411
+ if (!existsSync(webBuildMarkerPath)) {
412
+ return null;
413
+ }
414
+ try {
415
+ return JSON.parse(readFileSync(webBuildMarkerPath, "utf8"));
416
+ } catch {
417
+ return null;
418
+ }
419
+ }
420
+ function writeWebBuildMarker() {
421
+ const marker = {
422
+ version: 2
423
+ };
424
+ writeFileSync(webBuildMarkerPath, `${JSON.stringify(marker, null, 2)}
425
+ `, "utf8");
426
+ }
427
+ function isWebBuildOutdated() {
428
+ if (!existsSync(webDistIndexPath)) {
429
+ return true;
430
+ }
431
+ let buildTimestamp = 0;
432
+ try {
433
+ buildTimestamp = statSync(webDistIndexPath).mtimeMs;
434
+ } catch {
435
+ return true;
436
+ }
437
+ return getLatestModifiedTime(webSourceRoots) > buildTimestamp;
438
+ }
439
+ function getLatestModifiedTime(pathsToInspect) {
440
+ let latest = 0;
441
+ for (const targetPath of pathsToInspect) {
442
+ latest = Math.max(latest, getPathModifiedTime(targetPath));
443
+ }
444
+ return latest;
445
+ }
446
+ function getPathModifiedTime(targetPath) {
447
+ if (!existsSync(targetPath)) {
448
+ return 0;
449
+ }
450
+ const stats = statSync(targetPath);
451
+ if (!stats.isDirectory()) {
452
+ return stats.mtimeMs;
453
+ }
454
+ let latest = stats.mtimeMs;
455
+ for (const entry of readdirSync(targetPath, { withFileTypes: true })) {
456
+ if (entry.name === "dist" || entry.name === "node_modules") {
457
+ continue;
458
+ }
459
+ latest = Math.max(latest, getPathModifiedTime(path.join(targetPath, entry.name)));
460
+ }
461
+ return latest;
462
+ }
463
+ function resolveNpmCommand() {
464
+ const npmCliPath = process.env.npm_execpath?.trim();
465
+ if (npmCliPath) {
466
+ return {
467
+ command: process.execPath,
468
+ args: [npmCliPath],
469
+ shell: false
470
+ };
471
+ }
472
+ return {
473
+ command: process.platform === "win32" ? "npm.cmd" : "npm",
474
+ args: [],
475
+ shell: process.platform === "win32"
476
+ };
477
+ }
478
+ async function waitForHealth(url, timeoutMs = 3e4) {
479
+ const deadline = Date.now() + timeoutMs;
480
+ while (Date.now() < deadline) {
481
+ try {
482
+ const response = await fetch(url);
483
+ if (response.ok) {
484
+ return;
485
+ }
486
+ } catch {
487
+ }
488
+ await delay(300);
489
+ }
490
+ throw new Error(`Timed out waiting for ${url} to become healthy.`);
491
+ }
492
+ async function waitForOnlineMachine(url, timeoutMs = 3e4) {
493
+ const deadline = Date.now() + timeoutMs;
494
+ while (Date.now() < deadline) {
495
+ try {
496
+ const machines = await requestJson(url);
497
+ if (machines.some((machine) => machine.status === "online")) {
498
+ return;
499
+ }
500
+ } catch {
501
+ }
502
+ await delay(500);
503
+ }
504
+ throw new Error("Timed out waiting for the local daemon to register an online machine.");
505
+ }
506
+ async function requestJson(url, init) {
507
+ const response = await fetch(url, {
508
+ headers: {
509
+ "content-type": "application/json",
510
+ ...init?.headers ?? {}
511
+ },
512
+ ...init
513
+ });
514
+ if (!response.ok) {
515
+ throw new Error(await response.text());
516
+ }
517
+ return response.json();
518
+ }
519
+ async function openBrowser(url) {
520
+ const command = process.platform === "win32" ? { file: "cmd.exe", args: ["/c", "start", "", url] } : process.platform === "darwin" ? { file: "open", args: [url] } : { file: "xdg-open", args: [url] };
521
+ try {
522
+ const browserProcess = spawn(command.file, command.args, {
523
+ cwd: runtimeRoot,
524
+ detached: true,
525
+ stdio: "ignore"
526
+ });
527
+ browserProcess.unref();
528
+ } catch (error) {
529
+ log(
530
+ `Could not open the browser automatically: ${error instanceof Error ? error.message : String(error)}`
531
+ );
532
+ }
533
+ }
534
+ function runCommand(command, args, options) {
535
+ return new Promise((resolve, reject) => {
536
+ const child = spawn(command, args, options);
537
+ child.on("exit", (code, signal) => {
538
+ if (code === 0) {
539
+ resolve();
540
+ return;
541
+ }
542
+ reject(
543
+ new Error(
544
+ `${command} ${args.join(" ")} failed with ${code !== null ? `exit code ${code}` : `signal ${signal}`}.`
545
+ )
546
+ );
547
+ });
548
+ child.on("error", reject);
549
+ });
550
+ }
551
+ function log(message) {
552
+ console.info(`\x1B[37m[netchat-local][info] ${message}\x1B[0m`);
553
+ }