xedoc-cli 0.1.17 → 0.1.20

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/bin/xedoc.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { spawn } from "node:child_process"
3
- import { mkdir, readFile } from "node:fs/promises"
3
+ import { mkdir, readFile, rm, writeFile } from "node:fs/promises"
4
4
  import { homedir } from "node:os"
5
5
  import { dirname, join, resolve } from "node:path"
6
6
  import { fileURLToPath, pathToFileURL } from "node:url"
@@ -14,78 +14,54 @@ const packageJson = JSON.parse(
14
14
 
15
15
  const options = parseArgs(process.argv.slice(2))
16
16
  if (options.help) {
17
- printHelp()
17
+ if (options.command === "service") {
18
+ printServiceHelp()
19
+ } else {
20
+ printHelp()
21
+ }
18
22
  process.exit(0)
19
23
  }
20
24
  if (options.version) {
21
25
  console.log(packageJson.version)
22
26
  process.exit(0)
23
27
  }
28
+ if (options.command === "service") {
29
+ await handleServiceCommand(options)
30
+ process.exit(0)
31
+ }
32
+
33
+ const runtime = resolveRuntimeOptions(options)
34
+ await mkdir(runtime.appHome, { recursive: true, mode: 0o700 })
35
+ await mkdir(dirname(runtime.databasePath), { recursive: true, mode: 0o700 })
36
+ await mkdir(runtime.accountsHome, { recursive: true, mode: 0o700 })
24
37
 
25
- const appHome = resolveHomePath(
26
- options.home ?? process.env.XEDOC_HOME ?? "~/.xedoc",
27
- )
28
- await mkdir(appHome, { recursive: true, mode: 0o700 })
29
-
30
- const port = options.port ?? process.env.PORT ?? "6354"
31
- const host = options.host ?? process.env.HOST ?? "127.0.0.1"
32
- const accountsHome = resolveHomePath(
33
- options.accountsHome ??
34
- process.env.CODEX_ACCOUNTS_HOME ??
35
- join(appHome, "accounts"),
36
- )
37
- const sharedChatHome = resolveHomePath(
38
- options.sharedChatHome ??
39
- process.env.CODEX_SHARED_CHAT_HOME ??
40
- process.env.CODEX_HOME ??
41
- "~/.codex",
42
- )
43
- const workspaceRoot = resolveHomePath(
44
- options.workspaceRoot ?? process.env.CODEX_WORKSPACE_ROOT ?? homedir(),
45
- )
46
- const databasePath = join(workspaceRoot, ".xedoc", "xedoc.db")
47
- await mkdir(dirname(databasePath), { recursive: true, mode: 0o700 })
48
- const databaseUrl = sqliteDatabaseUrl(databasePath)
49
-
50
- const codexBin = require.resolve("@openai/codex/bin/codex.js")
51
- const env = {
52
- ...process.env,
53
- CODEX_ACCOUNTS_HOME: accountsHome,
54
- CODEX_ARGS: options.codexArgs ?? process.env.CODEX_ARGS ?? `${codexBin} app-server`,
55
- CODEX_COMMAND: options.codexCommand ?? process.env.CODEX_COMMAND ?? process.execPath,
56
- CODEX_SHARED_CHAT_HOME: sharedChatHome,
57
- CODEX_WORKSPACE_ROOT: workspaceRoot,
58
- DATABASE_URL: databaseUrl,
59
- HOST: host,
60
- NODE_ENV: "production",
61
- PORT: port,
62
- }
63
-
64
- await mkdir(accountsHome, { recursive: true, mode: 0o700 })
65
38
  if (!options.skipPrismaGenerate) {
66
- await runPrisma(["generate"], env)
39
+ await runPrisma(["generate"], runtime.env)
67
40
  }
68
41
  if (!options.skipSetup) {
69
- await setupSqliteDatabase(env)
42
+ await setupSqliteDatabase(runtime.env)
70
43
  }
71
44
 
72
- const url = `http://${host === "0.0.0.0" ? "localhost" : host}:${port}`
45
+ const url = `http://${runtime.host === "0.0.0.0" ? "localhost" : runtime.host}:${runtime.port}`
73
46
  console.log(`xedoc: ${url}`)
74
47
  console.log("Set the server password in your browser on first visit.")
75
- console.log(`Workspace root: ${workspaceRoot}`)
76
- console.log(`Shared chat store: ${sharedChatHome}`)
48
+ console.log(`Workspace root: ${runtime.workspaceRoot}`)
49
+ console.log(`Shared chat store: ${runtime.sharedChatHome}`)
77
50
  console.log("Press Ctrl+C to stop.")
78
51
 
79
- await runServer(env)
52
+ await runServer(runtime.env)
80
53
 
81
54
  function parseArgs(argv) {
82
55
  const parsed = {}
56
+ const positional = []
83
57
  for (let index = 0; index < argv.length; index += 1) {
84
58
  const arg = argv[index]
85
59
  if (arg === "--help" || arg === "-h") {
86
60
  parsed.help = true
87
61
  } else if (arg === "--version" || arg === "-v") {
88
62
  parsed.version = true
63
+ } else if (arg === "--no-start") {
64
+ parsed.noStart = true
89
65
  } else if (arg === "--skip-setup") {
90
66
  parsed.skipSetup = true
91
67
  } else if (arg === "--skip-prisma-generate") {
@@ -98,9 +74,19 @@ function parseArgs(argv) {
98
74
  }
99
75
  assignOption(parsed, name, value)
100
76
  } else {
101
- fail(`Unknown argument: ${arg}`)
77
+ positional.push(arg)
102
78
  }
103
79
  }
80
+ if (positional.length) {
81
+ if (positional[0] !== "service") {
82
+ fail(`Unknown command: ${positional[0]}`)
83
+ }
84
+ if (positional.length > 2) {
85
+ fail(`Unknown service argument: ${positional[2]}`)
86
+ }
87
+ parsed.command = "service"
88
+ parsed.serviceAction = positional[1]
89
+ }
104
90
  return parsed
105
91
  }
106
92
 
@@ -115,6 +101,9 @@ function assignOption(parsed, name, value) {
115
101
  case "--codex-command":
116
102
  parsed.codexCommand = value
117
103
  return
104
+ case "--forever-command":
105
+ parsed.foreverCommand = value
106
+ return
118
107
  case "--home":
119
108
  parsed.home = value
120
109
  return
@@ -124,6 +113,12 @@ function assignOption(parsed, name, value) {
124
113
  case "--port":
125
114
  parsed.port = value
126
115
  return
116
+ case "--service-name":
117
+ parsed.serviceName = value
118
+ return
119
+ case "--service-driver":
120
+ parsed.serviceDriver = value
121
+ return
127
122
  case "--shared-chat-home":
128
123
  parsed.sharedChatHome = value
129
124
  return
@@ -135,6 +130,505 @@ function assignOption(parsed, name, value) {
135
130
  }
136
131
  }
137
132
 
133
+ function resolveRuntimeOptions(options) {
134
+ const appHome = resolveHomePath(
135
+ options.home ?? process.env.XEDOC_HOME ?? "~/.xedoc",
136
+ )
137
+ const port = String(options.port ?? process.env.PORT ?? "6354")
138
+ const host = String(options.host ?? process.env.HOST ?? "127.0.0.1")
139
+ const accountsHome = resolveHomePath(
140
+ options.accountsHome ??
141
+ process.env.CODEX_ACCOUNTS_HOME ??
142
+ join(appHome, "accounts"),
143
+ )
144
+ const sharedChatHome = resolveHomePath(
145
+ options.sharedChatHome ??
146
+ process.env.CODEX_SHARED_CHAT_HOME ??
147
+ process.env.CODEX_HOME ??
148
+ "~/.codex",
149
+ )
150
+ const workspaceRoot = resolveHomePath(
151
+ options.workspaceRoot ?? process.env.CODEX_WORKSPACE_ROOT ?? homedir(),
152
+ )
153
+ const databasePath = join(workspaceRoot, ".xedoc", "xedoc.db")
154
+ const databaseUrl = sqliteDatabaseUrl(databasePath)
155
+ const codexBin = require.resolve("@openai/codex/bin/codex.js")
156
+ const codexArgs =
157
+ options.codexArgs ?? process.env.CODEX_ARGS ?? `${codexBin} app-server`
158
+ const codexCommand =
159
+ options.codexCommand ?? process.env.CODEX_COMMAND ?? process.execPath
160
+ const env = {
161
+ ...process.env,
162
+ CODEX_ACCOUNTS_HOME: accountsHome,
163
+ CODEX_ARGS: codexArgs,
164
+ CODEX_COMMAND: codexCommand,
165
+ CODEX_SHARED_CHAT_HOME: sharedChatHome,
166
+ CODEX_WORKSPACE_ROOT: workspaceRoot,
167
+ DATABASE_URL: databaseUrl,
168
+ HOST: host,
169
+ NODE_ENV: "production",
170
+ PORT: port,
171
+ }
172
+ return {
173
+ accountsHome,
174
+ appHome,
175
+ codexArgs,
176
+ codexCommand,
177
+ databasePath,
178
+ databaseUrl,
179
+ env,
180
+ host,
181
+ port,
182
+ sharedChatHome,
183
+ skipPrismaGenerate: !!options.skipPrismaGenerate,
184
+ skipSetup: !!options.skipSetup,
185
+ workspaceRoot,
186
+ }
187
+ }
188
+
189
+ async function handleServiceCommand(options) {
190
+ const driver = resolveServiceDriver(options.serviceDriver)
191
+ switch (options.serviceAction) {
192
+ case "install":
193
+ await installBackgroundService(options, driver)
194
+ return
195
+ case "uninstall":
196
+ await uninstallBackgroundService(options, driver)
197
+ return
198
+ case undefined:
199
+ printServiceHelp()
200
+ fail("Choose a service command: install or uninstall.")
201
+ return
202
+ default:
203
+ fail(`Unknown service command: ${options.serviceAction}`)
204
+ }
205
+ }
206
+
207
+ async function installBackgroundService(options, driver) {
208
+ switch (driver) {
209
+ case "systemd":
210
+ await installSystemdService(options)
211
+ return
212
+ case "launchd":
213
+ await installLaunchdService(options)
214
+ return
215
+ case "windows-task":
216
+ await installWindowsTaskService(options)
217
+ return
218
+ case "forever":
219
+ await installForeverService(options)
220
+ return
221
+ default:
222
+ fail(`Unsupported service driver: ${driver}`)
223
+ }
224
+ }
225
+
226
+ async function uninstallBackgroundService(options, driver) {
227
+ switch (driver) {
228
+ case "systemd":
229
+ await uninstallSystemdService(options)
230
+ return
231
+ case "launchd":
232
+ await uninstallLaunchdService(options)
233
+ return
234
+ case "windows-task":
235
+ await uninstallWindowsTaskService(options)
236
+ return
237
+ case "forever":
238
+ await uninstallForeverService(options)
239
+ return
240
+ default:
241
+ fail(`Unsupported service driver: ${driver}`)
242
+ }
243
+ }
244
+
245
+ function resolveServiceDriver(value) {
246
+ const driver = value?.trim() || "auto"
247
+ if (driver === "auto") {
248
+ if (process.platform === "linux") {
249
+ return "systemd"
250
+ }
251
+ if (process.platform === "darwin") {
252
+ return "launchd"
253
+ }
254
+ if (process.platform === "win32") {
255
+ return "windows-task"
256
+ }
257
+ fail(
258
+ `No native service driver is available for ${process.platform}. Try --service-driver forever.`,
259
+ )
260
+ }
261
+ if (
262
+ driver === "systemd" ||
263
+ driver === "launchd" ||
264
+ driver === "windows-task" ||
265
+ driver === "forever"
266
+ ) {
267
+ return driver
268
+ }
269
+ fail(
270
+ `Unknown service driver: ${driver}. Use auto, systemd, launchd, windows-task, or forever.`,
271
+ )
272
+ }
273
+
274
+ async function installSystemdService(options) {
275
+ requireLinuxSystemd()
276
+ const runtime = resolveRuntimeOptions(options)
277
+ const serviceBaseName = normalizeServiceBaseName(options.serviceName)
278
+ const serviceName = `${serviceBaseName}.service`
279
+ const unitPath = userSystemdUnitPath(serviceName)
280
+ await mkdir(dirname(unitPath), { recursive: true, mode: 0o700 })
281
+ await writeFile(unitPath, systemdUnitFile(runtime), { mode: 0o644 })
282
+ await runSystemctl(["daemon-reload"])
283
+ await runSystemctl(["enable", ...(options.noStart ? [] : ["--now"]), serviceName])
284
+
285
+ const url = `http://${runtime.host === "0.0.0.0" ? "localhost" : runtime.host}:${runtime.port}`
286
+ console.log(`Installed ${serviceName}.`)
287
+ console.log(options.noStart ? "Service is enabled but not started." : `Service is running at ${url}.`)
288
+ console.log(`Logs: systemctl --user status ${serviceName}`)
289
+ console.log(`Uninstall: xedoc service uninstall --service-name ${serviceBaseName}`)
290
+ }
291
+
292
+ async function uninstallSystemdService(options) {
293
+ requireLinuxSystemd()
294
+ const serviceBaseName = normalizeServiceBaseName(options.serviceName)
295
+ const serviceName = `${serviceBaseName}.service`
296
+ const unitPath = userSystemdUnitPath(serviceName)
297
+ await runSystemctl(["disable", "--now", serviceName]).catch((error) => {
298
+ console.warn(
299
+ `Could not stop or disable ${serviceName}: ${error instanceof Error ? error.message : error}`,
300
+ )
301
+ })
302
+ await rm(unitPath, { force: true })
303
+ await runSystemctl(["daemon-reload"])
304
+ console.log(`Uninstalled ${serviceName}.`)
305
+ }
306
+
307
+ async function installLaunchdService(options) {
308
+ requireDarwinLaunchd()
309
+ const runtime = resolveRuntimeOptions(options)
310
+ const label = normalizeServiceBaseName(options.serviceName)
311
+ const plistPath = launchdPlistPath(label)
312
+ await mkdir(dirname(plistPath), { recursive: true, mode: 0o700 })
313
+ await mkdir(serviceLogDirectory(runtime), { recursive: true, mode: 0o700 })
314
+ await writeFile(plistPath, launchdPlistFile(runtime, label), { mode: 0o644 })
315
+ const domain = launchdDomain()
316
+ if (!options.noStart) {
317
+ await runLaunchctl(["bootout", domain, plistPath]).catch(() => undefined)
318
+ await runLaunchctl(["bootstrap", domain, plistPath])
319
+ await runLaunchctl(["enable", `${domain}/${label}`]).catch(() => undefined)
320
+ await runLaunchctl(["kickstart", "-k", `${domain}/${label}`])
321
+ }
322
+ const url = `http://${runtime.host === "0.0.0.0" ? "localhost" : runtime.host}:${runtime.port}`
323
+ console.log(`Installed ${label}.`)
324
+ console.log(options.noStart ? "LaunchAgent is installed and will start at next login." : `Service is running at ${url}.`)
325
+ console.log(`Logs: tail -f ${join(serviceLogDirectory(runtime), `${label}.out.log`)}`)
326
+ console.log(`Uninstall: xedoc service uninstall --service-name ${label}`)
327
+ }
328
+
329
+ async function uninstallLaunchdService(options) {
330
+ requireDarwinLaunchd()
331
+ const label = normalizeServiceBaseName(options.serviceName)
332
+ const plistPath = launchdPlistPath(label)
333
+ await runLaunchctl(["bootout", launchdDomain(), plistPath]).catch((error) => {
334
+ console.warn(
335
+ `Could not stop ${label}: ${error instanceof Error ? error.message : error}`,
336
+ )
337
+ })
338
+ await rm(plistPath, { force: true })
339
+ console.log(`Uninstalled ${label}.`)
340
+ }
341
+
342
+ async function installWindowsTaskService(options) {
343
+ requireWindowsTaskScheduler()
344
+ const runtime = resolveRuntimeOptions(options)
345
+ const taskName = normalizeServiceBaseName(options.serviceName)
346
+ const commandPath = await writeWindowsServiceCommand(runtime, taskName)
347
+ await runSchtasks([
348
+ "/Create",
349
+ "/TN",
350
+ taskName,
351
+ "/SC",
352
+ "ONLOGON",
353
+ "/TR",
354
+ `cmd.exe /d /c ${windowsBatchQuote(commandPath)}`,
355
+ "/F",
356
+ ])
357
+ if (!options.noStart) {
358
+ await runSchtasks(["/Run", "/TN", taskName])
359
+ }
360
+ const url = `http://${runtime.host === "0.0.0.0" ? "localhost" : runtime.host}:${runtime.port}`
361
+ console.log(`Installed ${taskName}.`)
362
+ console.log(options.noStart ? "Task is installed but not started." : `Task is running at ${url}.`)
363
+ console.log(`Status: schtasks /Query /TN ${taskName}`)
364
+ console.log(`Uninstall: xedoc service uninstall --service-name ${taskName}`)
365
+ }
366
+
367
+ async function uninstallWindowsTaskService(options) {
368
+ requireWindowsTaskScheduler()
369
+ const runtime = resolveRuntimeOptions(options)
370
+ const taskName = normalizeServiceBaseName(options.serviceName)
371
+ await runSchtasks(["/End", "/TN", taskName]).catch(() => undefined)
372
+ await runSchtasks(["/Delete", "/TN", taskName, "/F"]).catch((error) => {
373
+ console.warn(
374
+ `Could not delete ${taskName}: ${error instanceof Error ? error.message : error}`,
375
+ )
376
+ })
377
+ await rm(windowsServiceCommandPath(runtime, taskName), { force: true })
378
+ console.log(`Uninstalled ${taskName}.`)
379
+ }
380
+
381
+ async function installForeverService(options) {
382
+ if (options.noStart) {
383
+ fail("--no-start is not supported with --service-driver forever.")
384
+ }
385
+ const runtime = resolveRuntimeOptions(options)
386
+ const serviceName = normalizeServiceBaseName(options.serviceName)
387
+ const logDirectory = serviceLogDirectory(runtime)
388
+ await mkdir(logDirectory, { recursive: true, mode: 0o700 })
389
+ await runForever(["stop", serviceName], options).catch(() => undefined)
390
+ await runForever([
391
+ "start",
392
+ "--uid",
393
+ serviceName,
394
+ "--append",
395
+ "--workingDir",
396
+ packageRoot,
397
+ "-l",
398
+ join(logDirectory, `${serviceName}.forever.log`),
399
+ "-o",
400
+ join(logDirectory, `${serviceName}.out.log`),
401
+ "-e",
402
+ join(logDirectory, `${serviceName}.err.log`),
403
+ "-c",
404
+ process.execPath,
405
+ join(packageRoot, "bin", "xedoc.mjs"),
406
+ ...runtimeServiceArgs(runtime),
407
+ ], options)
408
+ const url = `http://${runtime.host === "0.0.0.0" ? "localhost" : runtime.host}:${runtime.port}`
409
+ console.log(`Started ${serviceName} with forever at ${url}.`)
410
+ console.log("Note: forever keeps the process alive, but does not install OS boot integration.")
411
+ console.log(`Logs: forever logs ${serviceName}`)
412
+ console.log(`Uninstall: xedoc service uninstall --service-driver forever --service-name ${serviceName}`)
413
+ }
414
+
415
+ async function uninstallForeverService(options) {
416
+ const serviceName = normalizeServiceBaseName(options.serviceName)
417
+ await runForever(["stop", serviceName], options).catch((error) => {
418
+ console.warn(
419
+ `Could not stop ${serviceName}: ${error instanceof Error ? error.message : error}`,
420
+ )
421
+ })
422
+ console.log(`Stopped ${serviceName} in forever.`)
423
+ }
424
+
425
+ function requireLinuxSystemd() {
426
+ if (process.platform !== "linux") {
427
+ fail("The systemd service driver only supports Linux.")
428
+ }
429
+ }
430
+
431
+ function requireDarwinLaunchd() {
432
+ if (process.platform !== "darwin") {
433
+ fail("The launchd service driver only supports macOS.")
434
+ }
435
+ if (typeof process.getuid !== "function") {
436
+ fail("Could not determine the current macOS user id.")
437
+ }
438
+ }
439
+
440
+ function requireWindowsTaskScheduler() {
441
+ if (process.platform !== "win32") {
442
+ fail("The windows-task service driver only supports Windows.")
443
+ }
444
+ }
445
+
446
+ function normalizeServiceBaseName(value) {
447
+ const name = value?.trim() || "xedoc"
448
+ const normalized = name.endsWith(".service") ? name.slice(0, -".service".length) : name
449
+ if (!/^[A-Za-z0-9_.@-]+$/u.test(name)) {
450
+ fail("Service name may only contain letters, numbers, dots, underscores, @, and hyphens.")
451
+ }
452
+ if (!normalized) {
453
+ fail("Service name must not be empty.")
454
+ }
455
+ return normalized
456
+ }
457
+
458
+ function userSystemdUnitPath(serviceName) {
459
+ return join(homedir(), ".config", "systemd", "user", serviceName)
460
+ }
461
+
462
+ function launchdPlistPath(label) {
463
+ return join(homedir(), "Library", "LaunchAgents", `${label}.plist`)
464
+ }
465
+
466
+ function launchdDomain() {
467
+ return `gui/${process.getuid()}`
468
+ }
469
+
470
+ function serviceLogDirectory(runtime) {
471
+ return join(runtime.appHome, "logs")
472
+ }
473
+
474
+ function launchdPlistFile(runtime, label) {
475
+ const execArgs = [
476
+ process.execPath,
477
+ join(packageRoot, "bin", "xedoc.mjs"),
478
+ ...runtimeServiceArgs(runtime),
479
+ ]
480
+ const logDirectory = serviceLogDirectory(runtime)
481
+ return `<?xml version="1.0" encoding="UTF-8"?>
482
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
483
+ <plist version="1.0">
484
+ <dict>
485
+ <key>Label</key>
486
+ <string>${xmlEscape(label)}</string>
487
+ <key>ProgramArguments</key>
488
+ <array>
489
+ ${execArgs.map((arg) => ` <string>${xmlEscape(arg)}</string>`).join("\n")}
490
+ </array>
491
+ <key>WorkingDirectory</key>
492
+ <string>${xmlEscape(packageRoot)}</string>
493
+ <key>RunAtLoad</key>
494
+ <true/>
495
+ <key>KeepAlive</key>
496
+ <true/>
497
+ <key>StandardOutPath</key>
498
+ <string>${xmlEscape(join(logDirectory, `${label}.out.log`))}</string>
499
+ <key>StandardErrorPath</key>
500
+ <string>${xmlEscape(join(logDirectory, `${label}.err.log`))}</string>
501
+ </dict>
502
+ </plist>
503
+ `
504
+ }
505
+
506
+ function systemdUnitFile(runtime) {
507
+ const execArgs = [
508
+ process.execPath,
509
+ join(packageRoot, "bin", "xedoc.mjs"),
510
+ ...runtimeServiceArgs(runtime),
511
+ ]
512
+ return `[Unit]
513
+ Description=xedoc local Codex web UI
514
+ After=network-online.target
515
+
516
+ [Service]
517
+ Type=simple
518
+ ExecStart=${execArgs.map(systemdQuoteExecArg).join(" ")}
519
+ Restart=on-failure
520
+ RestartSec=5
521
+ Environment=NODE_ENV=production
522
+
523
+ [Install]
524
+ WantedBy=default.target
525
+ `
526
+ }
527
+
528
+ function runtimeServiceArgs(runtime) {
529
+ const args = [
530
+ "--home",
531
+ runtime.appHome,
532
+ "--host",
533
+ runtime.host,
534
+ "--port",
535
+ runtime.port,
536
+ "--workspace-root",
537
+ runtime.workspaceRoot,
538
+ "--accounts-home",
539
+ runtime.accountsHome,
540
+ "--shared-chat-home",
541
+ runtime.sharedChatHome,
542
+ "--codex-command",
543
+ runtime.codexCommand,
544
+ "--codex-args",
545
+ runtime.codexArgs,
546
+ ]
547
+ if (runtime.skipSetup) {
548
+ args.push("--skip-setup")
549
+ }
550
+ if (runtime.skipPrismaGenerate) {
551
+ args.push("--skip-prisma-generate")
552
+ }
553
+ return args
554
+ }
555
+
556
+ function systemdQuoteExecArg(value) {
557
+ return `"${String(value)
558
+ .replaceAll("\\", "\\\\")
559
+ .replaceAll('"', '\\"')
560
+ .replaceAll("%", "%%")}"`
561
+ }
562
+
563
+ function xmlEscape(value) {
564
+ return String(value)
565
+ .replaceAll("&", "&amp;")
566
+ .replaceAll("<", "&lt;")
567
+ .replaceAll(">", "&gt;")
568
+ .replaceAll('"', "&quot;")
569
+ .replaceAll("'", "&apos;")
570
+ }
571
+
572
+ async function writeWindowsServiceCommand(runtime, taskName) {
573
+ const commandPath = windowsServiceCommandPath(runtime, taskName)
574
+ await mkdir(dirname(commandPath), { recursive: true, mode: 0o700 })
575
+ const command = [
576
+ "@echo off",
577
+ `cd /d ${windowsBatchQuote(packageRoot)}`,
578
+ [
579
+ windowsBatchQuote(process.execPath),
580
+ windowsBatchQuote(join(packageRoot, "bin", "xedoc.mjs")),
581
+ ...runtimeServiceArgs(runtime).map(windowsBatchQuote),
582
+ ].join(" "),
583
+ "",
584
+ ].join("\r\n")
585
+ await writeFile(commandPath, command, { mode: 0o700 })
586
+ return commandPath
587
+ }
588
+
589
+ function windowsServiceCommandPath(runtime, taskName) {
590
+ return join(runtime.appHome, "service", `${taskName}.cmd`)
591
+ }
592
+
593
+ function windowsBatchQuote(value) {
594
+ return `"${String(value)
595
+ .replaceAll("%", "%%")
596
+ .replaceAll("^", "^^")
597
+ .replaceAll('"', '""')}"`
598
+ }
599
+
600
+ async function runSystemctl(args) {
601
+ await run("systemctl", ["--user", ...args], {
602
+ cwd: packageRoot,
603
+ env: process.env,
604
+ stdio: "inherit",
605
+ })
606
+ }
607
+
608
+ async function runLaunchctl(args) {
609
+ await run("launchctl", args, {
610
+ cwd: packageRoot,
611
+ env: process.env,
612
+ stdio: "inherit",
613
+ })
614
+ }
615
+
616
+ async function runSchtasks(args) {
617
+ await run("schtasks.exe", args, {
618
+ cwd: packageRoot,
619
+ env: process.env,
620
+ stdio: "inherit",
621
+ })
622
+ }
623
+
624
+ async function runForever(args, options) {
625
+ await run(options.foreverCommand ?? process.env.FOREVER_COMMAND ?? "forever", args, {
626
+ cwd: packageRoot,
627
+ env: process.env,
628
+ stdio: "inherit",
629
+ })
630
+ }
631
+
138
632
  async function runPrisma(args, env) {
139
633
  await run(process.execPath, [
140
634
  require.resolve("prisma/build/index.js"),
@@ -224,7 +718,9 @@ function printHelp() {
224
718
  console.log(`xedoc ${packageJson.version}
225
719
 
226
720
  Usage:
227
- npx xedoc [options]
721
+ npx xedoc-cli [options]
722
+ xedoc service install [options]
723
+ xedoc service uninstall [options]
228
724
 
229
725
  Options:
230
726
  --port <port> Web server port. Defaults to 6354.
@@ -233,10 +729,42 @@ Options:
233
729
  --accounts-home <path> Codex account state directory. Defaults to ~/.xedoc/accounts.
234
730
  --shared-chat-home <path> Shared Codex chat store. Defaults to ~/.codex.
235
731
  --skip-setup Do not create the SQLite database schema.
732
+ --skip-prisma-generate Do not regenerate Prisma Client.
236
733
  --codex-command <command> Codex command used for new accounts.
237
734
  --codex-args <args> Codex command arguments used for new accounts.
238
735
  --home <path> App data directory. Defaults to ~/.xedoc.
736
+ --service-driver <driver> auto, systemd, launchd, windows-task, or forever. Defaults to auto.
737
+ --service-name <name> Service name. Defaults to xedoc.
738
+ --forever-command <command> forever executable for --service-driver forever. Defaults to forever.
739
+ --no-start Enable service without starting it during install.
239
740
  --help Show this help.
240
741
  --version Print the package version.
241
742
  `)
242
743
  }
744
+
745
+ function printServiceHelp() {
746
+ console.log(`xedoc ${packageJson.version}
747
+
748
+ Usage:
749
+ xedoc service install [options]
750
+ xedoc service uninstall [options]
751
+
752
+ Service commands:
753
+ install Install the background service and start it unless --no-start is set.
754
+ uninstall Stop and remove the background service.
755
+
756
+ Options:
757
+ --service-driver <driver> auto, systemd, launchd, windows-task, or forever. Defaults to auto.
758
+ --service-name <name> Service name. Defaults to xedoc.
759
+ --forever-command <command> forever executable for --service-driver forever. Defaults to forever.
760
+ --no-start Enable service without starting it during install.
761
+ --port <port> Web server port. Defaults to 6354.
762
+ --host <host> Web server host. Defaults to 127.0.0.1.
763
+ --workspace-root <path> Directory tree visible in the app. Defaults to your home directory.
764
+ --accounts-home <path> Codex account state directory. Defaults to ~/.xedoc/accounts.
765
+ --shared-chat-home <path> Shared Codex chat store. Defaults to ~/.codex.
766
+ --home <path> App data directory. Defaults to ~/.xedoc.
767
+ --skip-setup Do not create the SQLite database schema.
768
+ --skip-prisma-generate Do not regenerate Prisma Client.
769
+ `)
770
+ }