agent-message 0.1.3 → 0.2.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.
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import { spawn, spawnSync } from 'node:child_process'
4
+ import { generateKeyPairSync } from 'node:crypto'
4
5
  import { closeSync, existsSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
5
6
  import { access, constants } from 'node:fs/promises'
6
7
  import os from 'node:os'
@@ -15,6 +16,8 @@ const DEFAULT_WEB_PORT = 45788
15
16
  const STARTUP_ATTEMPTS = 40
16
17
  const STARTUP_DELAY_MS = 500
17
18
  const PROCESS_STOP_DELAY_MS = 1000
19
+ const DEFAULT_WEB_PUSH_SUBJECT = 'mailto:agent-message@local.invalid'
20
+ const DEFAULT_TUNNEL_WEB_PUSH_SUBJECT = 'https://agent-message.namjaeyoun.com'
18
21
 
19
22
  const scriptDir = dirname(fileURLToPath(import.meta.url))
20
23
  const packageRoot = resolve(scriptDir, '..', '..')
@@ -22,6 +25,12 @@ const bundleRoot = resolve(packageRoot, 'npm', 'runtime')
22
25
  const bundleBinDir = join(bundleRoot, 'bin')
23
26
  const bundleGatewayPath = join(bundleRoot, 'agent_gateway.mjs')
24
27
  const bundleWebDistDir = join(bundleRoot, 'web-dist')
28
+ const sourceServerDir = resolve(packageRoot, 'server')
29
+ const sourceWebDir = resolve(packageRoot, 'web')
30
+ const sourceGatewayPath = resolve(packageRoot, 'deploy', 'agent_gateway.mjs')
31
+ const sourceWebDistDir = resolve(sourceWebDir, 'dist')
32
+ const tunnelConfigPath = resolve(packageRoot, 'deploy', 'agent_tunnel_config.yml')
33
+ const tunnelName = 'agent-namjaeyoun-com'
25
34
 
26
35
  const lifecycleCommands = new Set(['start', 'stop', 'status'])
27
36
 
@@ -39,7 +48,9 @@ async function main() {
39
48
 
40
49
  if (lifecycleCommands.has(command)) {
41
50
  const options = parseLifecycleOptions(rest)
42
- await ensureBundleReady()
51
+ if (!options.dev) {
52
+ await ensureBundleReady()
53
+ }
43
54
 
44
55
  if (command === 'start') {
45
56
  await startStack(options)
@@ -59,14 +70,16 @@ async function main() {
59
70
 
60
71
  function printRootUsage() {
61
72
  console.error(`Usage:
62
- agent-message start [--runtime-dir <dir>] [--api-host <host>] [--api-port <port>] [--web-host <host>] [--web-port <port>]
63
- agent-message stop [--runtime-dir <dir>]
64
- agent-message status [--runtime-dir <dir>] [--api-host <host>] [--api-port <port>] [--web-host <host>] [--web-port <port>]
73
+ agent-message start [--dev] [--with-tunnel] [--runtime-dir <dir>] [--api-host <host>] [--api-port <port>] [--web-host <host>] [--web-port <port>]
74
+ agent-message stop [--dev] [--with-tunnel] [--runtime-dir <dir>]
75
+ agent-message status [--dev] [--runtime-dir <dir>] [--api-host <host>] [--api-port <port>] [--web-host <host>] [--web-port <port>]
65
76
  agent-message <existing-cli-command> [...args]`)
66
77
  }
67
78
 
68
79
  function parseLifecycleOptions(args) {
69
80
  const options = {
81
+ dev: false,
82
+ withTunnel: false,
70
83
  runtimeDir: join(os.homedir(), '.agent-message'),
71
84
  apiHost: DEFAULT_API_HOST,
72
85
  apiPort: DEFAULT_API_PORT,
@@ -81,6 +94,14 @@ function parseLifecycleOptions(args) {
81
94
  process.exit(0)
82
95
  }
83
96
 
97
+ if (arg === '--dev') {
98
+ options.dev = true
99
+ continue
100
+ }
101
+ if (arg === '--with-tunnel' || arg === '--all') {
102
+ options.withTunnel = true
103
+ continue
104
+ }
84
105
  if (arg === '--runtime-dir') {
85
106
  options.runtimeDir = requireOptionValue(args, ++index, arg)
86
107
  continue
@@ -164,20 +185,25 @@ function resolveBinaryPath(baseName) {
164
185
  function runtimePaths(runtimeDir) {
165
186
  return {
166
187
  runtimeDir,
188
+ binDir: join(runtimeDir, 'bin'),
167
189
  logDir: join(runtimeDir, 'logs'),
168
190
  uploadDir: join(runtimeDir, 'uploads'),
169
191
  serverLog: join(runtimeDir, 'logs', 'server.log'),
170
192
  gatewayLog: join(runtimeDir, 'logs', 'gateway.log'),
193
+ tunnelLog: join(runtimeDir, 'logs', 'named-tunnel.log'),
171
194
  serverPidfile: join(runtimeDir, 'server.pid'),
172
195
  gatewayPidfile: join(runtimeDir, 'gateway.pid'),
196
+ tunnelPidfile: join(runtimeDir, 'named-tunnel.pid'),
173
197
  stackMetadataPath: join(runtimeDir, 'stack.json'),
174
198
  sqlitePath: join(runtimeDir, 'agent_message.sqlite'),
199
+ webPushConfigPath: join(runtimeDir, 'web-push.json'),
175
200
  }
176
201
  }
177
202
 
178
203
  async function startStack(options) {
179
204
  const paths = runtimePaths(options.runtimeDir)
180
205
  mkdirSync(paths.runtimeDir, { recursive: true })
206
+ mkdirSync(paths.binDir, { recursive: true })
181
207
  mkdirSync(paths.logDir, { recursive: true })
182
208
  mkdirSync(paths.uploadDir, { recursive: true })
183
209
 
@@ -185,38 +211,55 @@ async function startStack(options) {
185
211
 
186
212
  writeFileSync(paths.serverLog, '')
187
213
  writeFileSync(paths.gatewayLog, '')
214
+ if (options.withTunnel) {
215
+ ensureTunnelTargetMatchesDefaults(options)
216
+ writeFileSync(paths.tunnelLog, '')
217
+ }
188
218
 
189
- const serverBinary = resolveBinaryPath('agent-message-server')
190
- const gatewayChild = { pid: null }
219
+ const launchSpec = options.dev ? buildDevLaunchSpec(paths) : buildBundledLaunchSpec()
191
220
 
192
221
  try {
193
- const serverChild = spawnDetached(serverBinary, [], {
222
+ const webPushConfig = ensureWebPushConfig(paths, options)
223
+ const serverChild = spawnDetached(launchSpec.serverCommand, launchSpec.serverArgs, {
194
224
  ...process.env,
195
225
  SERVER_ADDR: `${options.apiHost}:${options.apiPort}`,
196
226
  DB_DRIVER: 'sqlite',
197
227
  SQLITE_DSN: paths.sqlitePath,
198
228
  UPLOAD_DIR: paths.uploadDir,
199
229
  CORS_ALLOWED_ORIGINS: '*',
230
+ WEB_PUSH_VAPID_PUBLIC_KEY: webPushConfig.publicKey,
231
+ WEB_PUSH_VAPID_PRIVATE_KEY: webPushConfig.privateKey,
232
+ WEB_PUSH_SUBJECT: webPushConfig.subject,
200
233
  }, paths.serverLog)
201
234
  writeFileSync(paths.serverPidfile, `${serverChild.pid}\n`)
202
235
 
203
236
  await waitForHttp(`http://${options.apiHost}:${options.apiPort}/healthz`, 'API server')
204
237
 
205
- gatewayChild.pid = spawnDetached(
206
- process.execPath,
207
- [bundleGatewayPath],
238
+ const gatewayChild = spawnDetached(
239
+ launchSpec.gatewayCommand,
240
+ launchSpec.gatewayArgs,
208
241
  {
209
242
  ...process.env,
210
243
  AGENT_GATEWAY_HOST: options.webHost,
211
244
  AGENT_GATEWAY_PORT: String(options.webPort),
212
245
  AGENT_API_ORIGIN: `http://${options.apiHost}:${options.apiPort}`,
213
- AGENT_WEB_DIST: bundleWebDistDir,
246
+ AGENT_WEB_DIST: launchSpec.webDistDir,
214
247
  },
215
248
  paths.gatewayLog,
216
- ).pid
249
+ )
217
250
  writeFileSync(paths.gatewayPidfile, `${gatewayChild.pid}\n`)
218
251
 
219
252
  await waitForHttp(`http://${options.webHost}:${options.webPort}`, 'web gateway')
253
+ if (options.withTunnel) {
254
+ const tunnelChild = spawnDetached(
255
+ 'cloudflared',
256
+ ['tunnel', '--config', tunnelConfigPath, 'run', tunnelName],
257
+ process.env,
258
+ paths.tunnelLog,
259
+ )
260
+ writeFileSync(paths.tunnelPidfile, `${tunnelChild.pid}\n`)
261
+ await waitForLogMessage(paths.tunnelLog, 'Registered tunnel connection', 'named tunnel')
262
+ }
220
263
  writeStackMetadata(paths.stackMetadataPath, options)
221
264
  } catch (error) {
222
265
  await stopStack(options, { quiet: true })
@@ -227,16 +270,122 @@ async function startStack(options) {
227
270
  console.log(`API: http://${options.apiHost}:${options.apiPort}`)
228
271
  console.log(`Web: http://${options.webHost}:${options.webPort}`)
229
272
  console.log(`Logs: ${paths.serverLog} ${paths.gatewayLog}`)
273
+ console.log(`Web Push: ${paths.webPushConfigPath}`)
274
+ if (options.withTunnel) {
275
+ console.log(`Tunnel: https://agent.namjaeyoun.com`)
276
+ console.log(`Tunnel log: ${paths.tunnelLog}`)
277
+ }
278
+ }
279
+
280
+ function ensureWebPushConfig(paths, options) {
281
+ const envPublicKey = process.env.WEB_PUSH_VAPID_PUBLIC_KEY?.trim() ?? ''
282
+ const envPrivateKey = process.env.WEB_PUSH_VAPID_PRIVATE_KEY?.trim() ?? ''
283
+ const envSubject = process.env.WEB_PUSH_SUBJECT?.trim() ?? ''
284
+ const defaultSubject = resolveDefaultWebPushSubject(options)
285
+
286
+ if ((envPublicKey && !envPrivateKey) || (!envPublicKey && envPrivateKey)) {
287
+ throw new Error('WEB_PUSH_VAPID_PUBLIC_KEY and WEB_PUSH_VAPID_PRIVATE_KEY must be set together.')
288
+ }
289
+
290
+ if (envPublicKey && envPrivateKey) {
291
+ return {
292
+ publicKey: envPublicKey,
293
+ privateKey: envPrivateKey,
294
+ subject: envSubject || defaultSubject,
295
+ }
296
+ }
297
+
298
+ const stored = readStoredWebPushConfig(paths.webPushConfigPath)
299
+ if (stored) {
300
+ const storedSubject = stored.subject || ''
301
+ const shouldMigrateDefaultSubject =
302
+ storedSubject === '' ||
303
+ (storedSubject === DEFAULT_WEB_PUSH_SUBJECT && defaultSubject !== DEFAULT_WEB_PUSH_SUBJECT)
304
+ const resolved = {
305
+ publicKey: stored.publicKey,
306
+ privateKey: stored.privateKey,
307
+ subject: envSubject || (shouldMigrateDefaultSubject ? defaultSubject : storedSubject),
308
+ }
309
+ if (resolved.subject !== stored.subject) {
310
+ writeStoredWebPushConfig(paths.webPushConfigPath, resolved)
311
+ }
312
+ return resolved
313
+ }
314
+
315
+ const generated = generateWebPushConfig(envSubject || defaultSubject)
316
+ writeStoredWebPushConfig(paths.webPushConfigPath, generated)
317
+ return generated
318
+ }
319
+
320
+ function resolveDefaultWebPushSubject(options) {
321
+ if (options?.withTunnel) {
322
+ return DEFAULT_TUNNEL_WEB_PUSH_SUBJECT
323
+ }
324
+ return DEFAULT_WEB_PUSH_SUBJECT
325
+ }
326
+
327
+ function readStoredWebPushConfig(path) {
328
+ if (!existsSync(path)) {
329
+ return null
330
+ }
331
+
332
+ try {
333
+ const parsed = JSON.parse(readFileSync(path, 'utf8'))
334
+ const publicKey = typeof parsed.publicKey === 'string' ? parsed.publicKey.trim() : ''
335
+ const privateKey = typeof parsed.privateKey === 'string' ? parsed.privateKey.trim() : ''
336
+ const subject = typeof parsed.subject === 'string' ? parsed.subject.trim() : ''
337
+ if (!publicKey || !privateKey) {
338
+ return null
339
+ }
340
+ return {
341
+ publicKey,
342
+ privateKey,
343
+ subject: subject || DEFAULT_WEB_PUSH_SUBJECT,
344
+ }
345
+ } catch {
346
+ return null
347
+ }
348
+ }
349
+
350
+ function writeStoredWebPushConfig(path, config) {
351
+ writeFileSync(path, `${JSON.stringify(config, null, 2)}\n`)
352
+ }
353
+
354
+ function generateWebPushConfig(subject) {
355
+ const { privateKey, publicKey } = generateKeyPairSync('ec', {
356
+ namedCurve: 'prime256v1',
357
+ })
358
+ const privateJWK = privateKey.export({ format: 'jwk' })
359
+ const publicJWK = publicKey.export({ format: 'jwk' })
360
+
361
+ if (
362
+ typeof privateJWK.d !== 'string' ||
363
+ typeof publicJWK.x !== 'string' ||
364
+ typeof publicJWK.y !== 'string'
365
+ ) {
366
+ throw new Error('failed to generate VAPID keys')
367
+ }
368
+
369
+ const x = Buffer.from(publicJWK.x, 'base64url')
370
+ const y = Buffer.from(publicJWK.y, 'base64url')
371
+ const publicKeyBytes = Buffer.concat([Buffer.from([0x04]), x, y])
372
+
373
+ return {
374
+ publicKey: publicKeyBytes.toString('base64url'),
375
+ privateKey: privateJWK.d,
376
+ subject,
377
+ }
230
378
  }
231
379
 
232
380
  async function stopStack(options, { quiet }) {
233
381
  const paths = runtimePaths(options.runtimeDir)
234
382
  const stoppedServer = await killFromPidfile(paths.serverPidfile)
235
383
  const stoppedGateway = await killFromPidfile(paths.gatewayPidfile)
384
+ const stoppedTunnel = await killFromPidfile(paths.tunnelPidfile)
236
385
  rmSync(paths.stackMetadataPath, { force: true })
237
386
 
238
387
  if (!quiet) {
239
- if (stoppedServer || stoppedGateway) {
388
+ if (stoppedServer || stoppedGateway || stoppedTunnel) {
240
389
  console.log('Agent Message is stopped.')
241
390
  } else {
242
391
  console.log('Agent Message is not running.')
@@ -250,77 +399,86 @@ async function printStatus(options) {
250
399
  const gatewayPid = readPidfile(paths.gatewayPidfile)
251
400
  const serverRunning = serverPid !== null && isPidAlive(serverPid)
252
401
  const gatewayRunning = gatewayPid !== null && isPidAlive(gatewayPid)
402
+ const tunnelPid = readPidfile(paths.tunnelPidfile)
403
+ const tunnelRunning = tunnelPid !== null && isPidAlive(tunnelPid)
253
404
  const apiHealthy = serverRunning ? await isHttpReady(`http://${options.apiHost}:${options.apiPort}/healthz`) : false
254
405
  const webHealthy = gatewayRunning ? await isHttpReady(`http://${options.webHost}:${options.webPort}`) : false
255
406
 
256
407
  console.log(`API server: ${serverRunning ? 'running' : 'stopped'}${apiHealthy ? ' (healthy)' : ''}`)
257
408
  console.log(`Web gateway: ${gatewayRunning ? 'running' : 'stopped'}${webHealthy ? ' (healthy)' : ''}`)
409
+ if (tunnelPid !== null) {
410
+ console.log(`Named tunnel: ${tunnelRunning ? 'running' : 'stopped'}`)
411
+ }
258
412
  console.log(`Runtime dir: ${paths.runtimeDir}`)
259
413
  console.log(`API URL: http://${options.apiHost}:${options.apiPort}`)
260
414
  console.log(`Web URL: http://${options.webHost}:${options.webPort}`)
261
415
  }
262
416
 
263
- function delegateToBundledCli(args) {
264
- const cliBinary = resolveBinaryPath('agent-message-cli')
265
- const delegatedArgs = buildDelegatedCliArgs(args)
266
- const result = spawnSync(cliBinary, delegatedArgs, {
267
- stdio: 'inherit',
268
- env: process.env,
269
- })
270
-
271
- if (result.error) {
272
- throw result.error
417
+ function buildBundledLaunchSpec() {
418
+ return {
419
+ serverCommand: resolveBinaryPath('agent-message-server'),
420
+ serverArgs: [],
421
+ gatewayCommand: process.execPath,
422
+ gatewayArgs: [bundleGatewayPath],
423
+ webDistDir: bundleWebDistDir,
273
424
  }
425
+ }
274
426
 
275
- if (result.signal) {
276
- process.kill(process.pid, result.signal)
277
- return
427
+ function buildDevLaunchSpec(paths) {
428
+ ensureDevSourcesReady()
429
+
430
+ if (!existsSync(join(sourceWebDir, 'node_modules'))) {
431
+ runForeground('npm', ['ci'], { cwd: sourceWebDir })
278
432
  }
433
+ runForeground('node', ['./scripts/generate-message-json-render-catalog-prompt.mjs'], { cwd: packageRoot })
434
+ runForeground('npm', ['run', 'build'], { cwd: sourceWebDir })
279
435
 
280
- process.exit(result.status ?? 1)
281
- }
436
+ const serverBinaryPath = join(paths.binDir, 'agent-message-server')
437
+ runForeground('go', ['build', '-o', serverBinaryPath, '.'], { cwd: sourceServerDir })
282
438
 
283
- function buildDelegatedCliArgs(args) {
284
- if (shouldSkipLocalServerOverride(args) || hasExplicitServerURL(args)) {
285
- return args
439
+ return {
440
+ serverCommand: serverBinaryPath,
441
+ serverArgs: [],
442
+ gatewayCommand: process.execPath,
443
+ gatewayArgs: [sourceGatewayPath],
444
+ webDistDir: sourceWebDistDir,
286
445
  }
446
+ }
287
447
 
288
- const localServerURL = readLocalServerURL()
289
- if (!localServerURL) {
290
- return args
448
+ function ensureDevSourcesReady() {
449
+ const requiredPaths = [sourceServerDir, sourceWebDir, sourceGatewayPath]
450
+ for (const target of requiredPaths) {
451
+ if (!existsSync(target)) {
452
+ throw new Error(`development mode requires a local checkout with ${target}`)
453
+ }
291
454
  }
292
-
293
- return ['--server-url', localServerURL, ...args]
294
455
  }
295
456
 
296
- function shouldSkipLocalServerOverride(args) {
297
- const [command] = args
298
- return command === undefined || command === 'config' || command === 'profile'
457
+ function ensureTunnelTargetMatchesDefaults(options) {
458
+ if (options.webHost !== DEFAULT_WEB_HOST || options.webPort !== DEFAULT_WEB_PORT) {
459
+ throw new Error(
460
+ `--with-tunnel requires the default web listener ${DEFAULT_WEB_HOST}:${DEFAULT_WEB_PORT} to match ${tunnelConfigPath}.`,
461
+ )
462
+ }
299
463
  }
300
464
 
301
- function hasExplicitServerURL(args) {
302
- return args.some((arg) => arg === '--server-url' || arg.startsWith('--server-url='))
303
- }
465
+ function delegateToBundledCli(args) {
466
+ const cliBinary = resolveBinaryPath('agent-message-cli')
467
+ const result = spawnSync(cliBinary, args, {
468
+ stdio: 'inherit',
469
+ env: process.env,
470
+ })
304
471
 
305
- function readLocalServerURL() {
306
- const paths = runtimePaths(join(os.homedir(), '.agent-message'))
307
- const serverPid = readPidfile(paths.serverPidfile)
308
- if (serverPid === null || !isPidAlive(serverPid)) {
309
- return null
472
+ if (result.error) {
473
+ throw result.error
310
474
  }
311
475
 
312
- try {
313
- const raw = readFileSync(paths.stackMetadataPath, 'utf8')
314
- const metadata = JSON.parse(raw)
315
- const apiHost = typeof metadata.apiHost === 'string' ? metadata.apiHost.trim() : ''
316
- const apiPort = metadata.apiPort
317
- if (!apiHost || !Number.isInteger(apiPort) || apiPort <= 0 || apiPort > 65535) {
318
- return null
319
- }
320
- return `http://${apiHost}:${apiPort}`
321
- } catch {
322
- return null
476
+ if (result.signal) {
477
+ process.kill(process.pid, result.signal)
478
+ return
323
479
  }
480
+
481
+ process.exit(result.status ?? 1)
324
482
  }
325
483
 
326
484
  function writeStackMetadata(path, options) {
@@ -355,6 +513,27 @@ function spawnDetached(command, args, env, logFile) {
355
513
  }
356
514
  }
357
515
 
516
+ function runForeground(command, args, { cwd }) {
517
+ const result = spawnSync(command, args, {
518
+ cwd,
519
+ env: process.env,
520
+ stdio: 'inherit',
521
+ })
522
+
523
+ if (result.error) {
524
+ throw result.error
525
+ }
526
+
527
+ if (result.signal) {
528
+ process.kill(process.pid, result.signal)
529
+ return
530
+ }
531
+
532
+ if ((result.status ?? 1) !== 0) {
533
+ throw new Error(`${command} ${args.join(' ')} failed with exit code ${result.status ?? 1}`)
534
+ }
535
+ }
536
+
358
537
  function readPidfile(pidfile) {
359
538
  if (!existsSync(pidfile)) {
360
539
  return null
@@ -409,6 +588,16 @@ async function waitForHttp(url, label) {
409
588
  throw new Error(`${label} did not become ready: ${url}`)
410
589
  }
411
590
 
591
+ async function waitForLogMessage(logFile, message, label) {
592
+ for (let attempt = 0; attempt < STARTUP_ATTEMPTS; attempt += 1) {
593
+ if (existsSync(logFile) && readFileSync(logFile, 'utf8').includes(message)) {
594
+ return
595
+ }
596
+ await sleep(STARTUP_DELAY_MS)
597
+ }
598
+ throw new Error(`${label} did not become ready: ${message}`)
599
+ }
600
+
412
601
  async function isHttpReady(url) {
413
602
  try {
414
603
  const controller = new AbortController()
@@ -4,8 +4,8 @@ import http from 'node:http'
4
4
  import { basename, extname, join, normalize, resolve } from 'node:path'
5
5
 
6
6
  const host = process.env.AGENT_GATEWAY_HOST ?? '127.0.0.1'
7
- const port = Number(process.env.AGENT_GATEWAY_PORT ?? '8788')
8
- const apiOrigin = process.env.AGENT_API_ORIGIN ?? 'http://127.0.0.1:18080'
7
+ const port = Number(process.env.AGENT_GATEWAY_PORT ?? '45788')
8
+ const apiOrigin = process.env.AGENT_API_ORIGIN ?? 'http://127.0.0.1:8080'
9
9
  const distDir = resolve(process.env.AGENT_WEB_DIST ?? join(process.cwd(), 'web', 'dist'))
10
10
  const indexPath = join(distDir, 'index.html')
11
11
 
@@ -87,6 +87,9 @@ async function proxyRequest(req, res) {
87
87
  upstream.status,
88
88
  Object.fromEntries(upstream.headers.entries()),
89
89
  )
90
+ if (upstream.headers.get('content-type')?.startsWith('text/event-stream')) {
91
+ res.flushHeaders()
92
+ }
90
93
 
91
94
  if (!upstream.body) {
92
95
  res.end()