@aryasaatvik/motel 0.3.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 (99) hide show
  1. package/AGENTS.md +180 -0
  2. package/LICENSE +21 -0
  3. package/README.md +137 -0
  4. package/package.json +119 -0
  5. package/skills/motel-debug/SKILL.md +203 -0
  6. package/skills/motel-debug/references/effect.md +38 -0
  7. package/src/App.tsx +414 -0
  8. package/src/StartupGate.test.ts +80 -0
  9. package/src/StartupGate.tsx +263 -0
  10. package/src/cli.ts +205 -0
  11. package/src/config.ts +45 -0
  12. package/src/daemon.test.ts +547 -0
  13. package/src/daemon.ts +486 -0
  14. package/src/domain.test.ts +62 -0
  15. package/src/domain.ts +291 -0
  16. package/src/httpApi.ts +391 -0
  17. package/src/httpListPolicy.test.ts +76 -0
  18. package/src/httpListPolicy.ts +129 -0
  19. package/src/index.tsx +25 -0
  20. package/src/instructions.ts +72 -0
  21. package/src/launchAgent.test.ts +430 -0
  22. package/src/launchAgent.ts +462 -0
  23. package/src/localServer.ts +568 -0
  24. package/src/locator.ts +138 -0
  25. package/src/mcp.ts +433 -0
  26. package/src/motel.test.ts +176 -0
  27. package/src/motel.ts +88 -0
  28. package/src/motelClient.ts +353 -0
  29. package/src/opentui-jsx.d.ts +11 -0
  30. package/src/otlp.test.ts +65 -0
  31. package/src/otlp.ts +162 -0
  32. package/src/otlpProtobuf.ts +35 -0
  33. package/src/queryFilters.ts +39 -0
  34. package/src/registry.ts +115 -0
  35. package/src/runtime.ts +40 -0
  36. package/src/server.ts +11 -0
  37. package/src/services/AsyncIngest.ts +82 -0
  38. package/src/services/LogQueryService.ts +29 -0
  39. package/src/services/TelemetryQuery.ts +62 -0
  40. package/src/services/TelemetryStore.ts +2554 -0
  41. package/src/services/TraceQueryService.ts +43 -0
  42. package/src/services/ingestRpc.ts +39 -0
  43. package/src/services/queryRpc.ts +15 -0
  44. package/src/services/telemetryQueryWorker.ts +32 -0
  45. package/src/services/telemetryWorker.ts +59 -0
  46. package/src/startupBench.ts +19 -0
  47. package/src/startupLifecycle.ts +57 -0
  48. package/src/storybook/aiChatStory.tsx +244 -0
  49. package/src/storybook/fixtures/errorState.ts +44 -0
  50. package/src/storybook/fixtures/imagePaste.ts +34 -0
  51. package/src/storybook/fixtures/index.ts +62 -0
  52. package/src/storybook/fixtures/kitchenSink.ts +148 -0
  53. package/src/storybook/fixtures/rawPrompt.ts +15 -0
  54. package/src/storybook/fixtures/short.ts +27 -0
  55. package/src/storybook/fixtures/toolHeavy.ts +65 -0
  56. package/src/telemetry.test.ts +1053 -0
  57. package/src/ui/AiChatView.tsx +308 -0
  58. package/src/ui/AttrFilterModal.tsx +120 -0
  59. package/src/ui/ServiceLogs.tsx +115 -0
  60. package/src/ui/SpanContentView.tsx +184 -0
  61. package/src/ui/SpanDetail.tsx +215 -0
  62. package/src/ui/SpanDetailFull.tsx +224 -0
  63. package/src/ui/SpanDetailPane.tsx +90 -0
  64. package/src/ui/TraceDetailsPane.tsx +175 -0
  65. package/src/ui/TraceList.tsx +254 -0
  66. package/src/ui/Waterfall.tsx +389 -0
  67. package/src/ui/aiChatModel.test.ts +391 -0
  68. package/src/ui/aiChatModel.ts +773 -0
  69. package/src/ui/aiState.ts +59 -0
  70. package/src/ui/app/TraceListPane.tsx +39 -0
  71. package/src/ui/app/TraceWorkspace.tsx +452 -0
  72. package/src/ui/app/useAppLayout.ts +101 -0
  73. package/src/ui/app/useTraceScreenData.ts +590 -0
  74. package/src/ui/atoms.ts +131 -0
  75. package/src/ui/cachedLoader.test.ts +23 -0
  76. package/src/ui/cachedLoader.ts +60 -0
  77. package/src/ui/filterParser.test.ts +56 -0
  78. package/src/ui/filterParser.ts +45 -0
  79. package/src/ui/format.ts +128 -0
  80. package/src/ui/loaders.ts +101 -0
  81. package/src/ui/persistence.ts +41 -0
  82. package/src/ui/primitives.tsx +198 -0
  83. package/src/ui/state.ts +6 -0
  84. package/src/ui/theme.ts +160 -0
  85. package/src/ui/traceDetailsWidth.repro.test.ts +126 -0
  86. package/src/ui/traceSortNav.repro.seed.ts +62 -0
  87. package/src/ui/traceSortNav.repro.test.ts +231 -0
  88. package/src/ui/useAttrFilterPicker.ts +89 -0
  89. package/src/ui/useKeyboardNav.ts +1023 -0
  90. package/src/ui/waterfallFilter.test.ts +84 -0
  91. package/src/ui/waterfallFilter.ts +59 -0
  92. package/src/ui/waterfallModel.ts +130 -0
  93. package/src/ui/waterfallNav.repro.seed.ts +86 -0
  94. package/src/ui/waterfallNav.repro.test.ts +271 -0
  95. package/src/ui/waterfallNav.test.ts +453 -0
  96. package/src/ui/waterfallNav.ts +75 -0
  97. package/web/dist/assets/index-B01z9BaO.css +2 -0
  98. package/web/dist/assets/index-CYlBaY9O.js +22 -0
  99. package/web/dist/index.html +13 -0
@@ -0,0 +1,462 @@
1
+ import * as fs from "node:fs/promises"
2
+ import * as os from "node:os"
3
+ import * as path from "node:path"
4
+ import { Effect } from "effect"
5
+ import { createDaemonManager, type DaemonManager, type DaemonStatus } from "./daemon.js"
6
+ import { MOTEL_VERSION } from "./registry.js"
7
+
8
+ export const LAUNCH_AGENT_LABEL = "dev.motel.local-server"
9
+ export const LAUNCH_AGENT_INSTANCE_ID = "launchd-user-agent"
10
+
11
+ export type LaunchAgentSpec = {
12
+ readonly label: string
13
+ readonly domain: string
14
+ readonly target: string
15
+ readonly plistPath: string
16
+ readonly workingDirectory: string
17
+ readonly logPath: string
18
+ readonly programArguments: readonly string[]
19
+ readonly environment: Readonly<Record<string, string>>
20
+ readonly runAtLoad: true
21
+ readonly keepAlive: true
22
+ readonly processType: "Background"
23
+ }
24
+
25
+ type PlistObject = Readonly<Record<string, unknown>>
26
+
27
+ export type LaunchAgentInspection =
28
+ | { readonly kind: "missing" }
29
+ | { readonly kind: "malformed"; readonly message: string }
30
+ | { readonly kind: "valid"; readonly value: PlistObject }
31
+
32
+ export type LaunchAgentComparison =
33
+ | { readonly kind: "equivalent" }
34
+ | { readonly kind: "divergent"; readonly fields: readonly string[] }
35
+ | { readonly kind: "malformed"; readonly message: string }
36
+
37
+ export type CommandResult = {
38
+ readonly exitCode: number
39
+ readonly stdout: string
40
+ readonly stderr: string
41
+ }
42
+
43
+ export type LaunchAgentOperations = {
44
+ readonly platform: NodeJS.Platform
45
+ readonly exists: (file: string) => Promise<boolean>
46
+ readonly readFile: (file: string) => Promise<string>
47
+ readonly mkdir: (directory: string) => Promise<void>
48
+ readonly writeFile: (file: string, contents: string) => Promise<void>
49
+ readonly rename: (from: string, to: string) => Promise<void>
50
+ readonly unlink: (file: string) => Promise<void>
51
+ readonly run: (command: string, args: readonly string[]) => Promise<CommandResult>
52
+ readonly getDaemonStatus: () => Promise<DaemonStatus>
53
+ readonly version: string
54
+ }
55
+
56
+ export class LaunchAgentError extends Error {
57
+ readonly _tag = "LaunchAgentError"
58
+ }
59
+
60
+ const xmlEscape = (value: string) => value
61
+ .replaceAll("&", "&amp;")
62
+ .replaceAll("<", "&lt;")
63
+ .replaceAll(">", "&gt;")
64
+ .replaceAll('"', "&quot;")
65
+ .replaceAll("'", "&apos;")
66
+
67
+ const xmlString = (value: string) => `<string>${xmlEscape(value)}</string>`
68
+
69
+ const xmlArray = (values: readonly string[]) => `<array>\n${values.map((value) => ` ${xmlString(value)}`).join("\n")}\n </array>`
70
+
71
+ const xmlEnvironment = (environment: Readonly<Record<string, string>>) => `<dict>\n${Object.entries(environment).sort(([a], [b]) => a.localeCompare(b)).map(([key, value]) => ` <key>${xmlEscape(key)}</key>\n ${xmlString(value)}`).join("\n")}\n </dict>`
72
+
73
+ export const buildLaunchAgentSpec = (options: { readonly home?: string; readonly stateDirectory?: string } = {}): LaunchAgentSpec => {
74
+ const home = options.home ?? os.homedir()
75
+ const workingDirectory = options.stateDirectory ?? path.join(home, ".local", "state", "motel")
76
+ const bunDirectory = path.join(home, ".bun", "bin")
77
+ const label = LAUNCH_AGENT_LABEL
78
+ return {
79
+ label,
80
+ domain: `gui/${process.getuid?.() ?? 0}`,
81
+ target: `gui/${process.getuid?.() ?? 0}/${label}`,
82
+ plistPath: path.join(home, "Library", "LaunchAgents", `${label}.plist`),
83
+ workingDirectory,
84
+ logPath: path.join(workingDirectory, "launchd.log"),
85
+ programArguments: [path.join(bunDirectory, "bun"), path.join(bunDirectory, "motel"), "server"],
86
+ environment: {
87
+ HOME: home,
88
+ MOTEL_DAEMON_INSTANCE_ID: LAUNCH_AGENT_INSTANCE_ID,
89
+ MOTEL_OTEL_BASE_URL: "http://127.0.0.1:27686",
90
+ MOTEL_OTEL_DB_PATH: path.join(workingDirectory, "telemetry.sqlite"),
91
+ MOTEL_OTEL_HOST: "127.0.0.1",
92
+ MOTEL_OTEL_PORT: "27686",
93
+ MOTEL_OTEL_QUERY_URL: "http://127.0.0.1:27686",
94
+ MOTEL_RUNTIME_DIR: workingDirectory,
95
+ PATH: `${bunDirectory}:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin`,
96
+ },
97
+ runAtLoad: true,
98
+ keepAlive: true,
99
+ processType: "Background",
100
+ }
101
+ }
102
+
103
+ /** A custom runtime is an isolated daemon and must never control the global LaunchAgent. */
104
+ export type LaunchAgentRuntimeOptions = {
105
+ readonly runtimeDirectory?: string
106
+ readonly databasePath?: string
107
+ readonly baseUrl?: string
108
+ readonly queryUrl?: string
109
+ readonly host?: string
110
+ readonly port?: number
111
+ readonly stateHome?: string
112
+ readonly environment?: NodeJS.ProcessEnv
113
+ }
114
+
115
+ const positivePort = (value: string | undefined, defaultValue: number) => {
116
+ const parsed = Number.parseInt(value ?? "", 10)
117
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : defaultValue
118
+ }
119
+
120
+ const normalizedUrl = (value: string) => {
121
+ const url = new URL(value)
122
+ return `${url.protocol}//${url.host}`
123
+ }
124
+
125
+ /**
126
+ * Only the exact global daemon identity may control the global LaunchAgent.
127
+ * Any explicit state, database, URL, host, or port override is an isolated daemon.
128
+ */
129
+ export const usesLaunchAgentRuntime = (options: LaunchAgentRuntimeOptions = {}): boolean => {
130
+ const environment = options.environment ?? process.env
131
+ const spec = buildLaunchAgentSpec()
132
+ const stateHome = options.stateHome ?? (environment.XDG_STATE_HOME?.trim() || path.join(os.homedir(), ".local", "state"))
133
+ const runtimeDirectory = options.runtimeDirectory ?? (environment.MOTEL_RUNTIME_DIR?.trim() || path.join(stateHome, "motel"))
134
+ const databasePath = options.databasePath ?? (environment.MOTEL_OTEL_DB_PATH?.trim() || path.join(runtimeDirectory, "telemetry.sqlite"))
135
+ const baseUrl = options.baseUrl ?? (environment.MOTEL_OTEL_BASE_URL?.trim() || options.queryUrl || environment.MOTEL_OTEL_QUERY_URL?.trim() || "http://127.0.0.1:27686")
136
+ const base = new URL(baseUrl)
137
+ const host = options.host ?? (environment.MOTEL_OTEL_HOST?.trim() || base.hostname)
138
+ const port = options.port ?? positivePort(environment.MOTEL_OTEL_PORT, positivePort(base.port, 80))
139
+ return path.resolve(runtimeDirectory) === path.resolve(spec.workingDirectory)
140
+ && path.resolve(databasePath) === path.resolve(spec.environment.MOTEL_OTEL_DB_PATH)
141
+ && normalizedUrl(baseUrl) === normalizedUrl(spec.environment.MOTEL_OTEL_BASE_URL)
142
+ && normalizedUrl(options.queryUrl ?? (environment.MOTEL_OTEL_QUERY_URL?.trim() || baseUrl)) === normalizedUrl(spec.environment.MOTEL_OTEL_QUERY_URL)
143
+ && host === spec.environment.MOTEL_OTEL_HOST
144
+ && port === Number(spec.environment.MOTEL_OTEL_PORT)
145
+ }
146
+
147
+ export const renderLaunchAgentPlist = (spec: LaunchAgentSpec) => `<?xml version="1.0" encoding="UTF-8"?>
148
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
149
+ <plist version="1.0">
150
+ <dict>
151
+ <key>Label</key>
152
+ ${xmlString(spec.label)}
153
+ <key>ProgramArguments</key>
154
+ ${xmlArray(spec.programArguments)}
155
+ <key>WorkingDirectory</key>
156
+ ${xmlString(spec.workingDirectory)}
157
+ <key>EnvironmentVariables</key>
158
+ ${xmlEnvironment(spec.environment)}
159
+ <key>StandardOutPath</key>
160
+ ${xmlString(spec.logPath)}
161
+ <key>StandardErrorPath</key>
162
+ ${xmlString(spec.logPath)}
163
+ <key>RunAtLoad</key>
164
+ <true/>
165
+ <key>KeepAlive</key>
166
+ <true/>
167
+ <key>ProcessType</key>
168
+ ${xmlString(spec.processType)}
169
+ </dict>
170
+ </plist>
171
+ `
172
+
173
+ const asRecord = (value: unknown): PlistObject | null =>
174
+ typeof value === "object" && value !== null && !Array.isArray(value) ? value as PlistObject : null
175
+
176
+ export const inspectLaunchAgentJson = (source: string): LaunchAgentInspection => {
177
+ try {
178
+ const parsed = JSON.parse(source) as unknown
179
+ const value = asRecord(parsed)
180
+ return value === null ? { kind: "malformed", message: "plist must be a dictionary." } : { kind: "valid", value }
181
+ } catch (error) {
182
+ return { kind: "malformed", message: error instanceof Error ? error.message : String(error) }
183
+ }
184
+ }
185
+
186
+ const sameStringArray = (actual: unknown, expected: readonly string[]) =>
187
+ Array.isArray(actual) && actual.length === expected.length && actual.every((value, index) => value === expected[index])
188
+
189
+ export const compareLaunchAgent = (inspection: LaunchAgentInspection, spec: LaunchAgentSpec): LaunchAgentComparison => {
190
+ if (inspection.kind === "missing") return { kind: "divergent", fields: ["plist"] }
191
+ if (inspection.kind === "malformed") return inspection
192
+ const value = inspection.value
193
+ const fields: string[] = []
194
+ if (value.Label !== spec.label) fields.push("Label")
195
+ if (!sameStringArray(value.ProgramArguments, spec.programArguments)) fields.push("ProgramArguments")
196
+ if (value.WorkingDirectory !== spec.workingDirectory) fields.push("WorkingDirectory")
197
+ if (value.StandardOutPath !== spec.logPath) fields.push("StandardOutPath")
198
+ if (value.StandardErrorPath !== spec.logPath) fields.push("StandardErrorPath")
199
+ if (value.RunAtLoad !== true) fields.push("RunAtLoad")
200
+ if (value.KeepAlive !== true) fields.push("KeepAlive")
201
+ if (value.ProcessType !== spec.processType) fields.push("ProcessType")
202
+ const environment = asRecord(value.EnvironmentVariables)
203
+ if (environment === null || Object.entries(spec.environment).some(([key, expected]) => environment[key] !== expected)) fields.push("EnvironmentVariables")
204
+ return fields.length === 0 ? { kind: "equivalent" } : { kind: "divergent", fields }
205
+ }
206
+
207
+ const defaultOperations = (): LaunchAgentOperations => {
208
+ const daemon = createDaemonManager()
209
+ return {
210
+ exists: async (file) => fs.access(file).then(() => true).catch(() => false),
211
+ readFile: (file) => fs.readFile(file, "utf8"),
212
+ mkdir: (directory) => fs.mkdir(directory, { recursive: true }).then(() => undefined),
213
+ writeFile: (file, contents) => fs.writeFile(file, contents, "utf8"),
214
+ rename: fs.rename,
215
+ unlink: (file) => fs.rm(file, { force: true }),
216
+ run: async (command, args) => {
217
+ const process = Bun.spawn({ cmd: [command, ...args], stdout: "pipe", stderr: "pipe" })
218
+ const [exitCode, stdout, stderr] = await Promise.all([process.exited, new Response(process.stdout).text(), new Response(process.stderr).text()])
219
+ return { exitCode, stdout, stderr }
220
+ },
221
+ getDaemonStatus: () => Effect.runPromise(daemon.getStatus),
222
+ version: MOTEL_VERSION,
223
+ platform: process.platform,
224
+ }
225
+ }
226
+
227
+ const required = async (operations: LaunchAgentOperations, command: string, args: readonly string[]) => {
228
+ const result = await operations.run(command, args)
229
+ if (result.exitCode !== 0) throw new LaunchAgentError(`${command} ${args.join(" ")} failed: ${result.stderr.trim() || result.stdout.trim() || `exit ${result.exitCode}`}`)
230
+ return result
231
+ }
232
+
233
+ const optional = async (operations: LaunchAgentOperations, command: string, args: readonly string[]) => operations.run(command, args)
234
+
235
+ const readInspection = async (operations: LaunchAgentOperations, spec: LaunchAgentSpec): Promise<LaunchAgentInspection> => {
236
+ if (!await operations.exists(spec.plistPath)) return { kind: "missing" }
237
+ const result = await optional(operations, "plutil", ["-convert", "json", "-o", "-", spec.plistPath])
238
+ if (result.exitCode !== 0) return { kind: "malformed", message: result.stderr.trim() || result.stdout.trim() || "plutil could not parse the plist." }
239
+ return inspectLaunchAgentJson(result.stdout)
240
+ }
241
+
242
+ const writeAtomically = async (operations: LaunchAgentOperations, spec: LaunchAgentSpec) => {
243
+ const directory = path.dirname(spec.plistPath)
244
+ const temporary = path.join(directory, `.${spec.label}.${crypto.randomUUID()}.plist`)
245
+ await operations.mkdir(directory)
246
+ try {
247
+ await operations.writeFile(temporary, renderLaunchAgentPlist(spec))
248
+ await operations.rename(temporary, spec.plistPath)
249
+ } catch (error) {
250
+ await operations.unlink(temporary).catch(() => undefined)
251
+ throw error
252
+ }
253
+ }
254
+
255
+ export type LaunchAgentStatus = {
256
+ readonly installed: boolean
257
+ readonly configuration: "missing" | "equivalent" | "divergent" | "malformed"
258
+ readonly configurationDetails: readonly string[]
259
+ readonly manager: "loaded" | "not-loaded" | "unknown"
260
+ readonly running: boolean
261
+ readonly health: DaemonStatus
262
+ readonly registryIdentity: "verified" | "unverified" | "missing"
263
+ readonly version: { readonly cli: string; readonly server: string | null; readonly drift: boolean | null }
264
+ }
265
+
266
+ const launchctlState = (result: CommandResult): LaunchAgentStatus["manager"] => {
267
+ if (result.exitCode === 0) return "loaded"
268
+ if (result.exitCode === 113 || /\bESRCH\b|Could not find service|No such process/i.test(`${result.stdout}\n${result.stderr}`)) return "not-loaded"
269
+ return "unknown"
270
+ }
271
+
272
+ const requireKnownLaunchctlState = (state: LaunchAgentStatus["manager"]) => {
273
+ if (state === "unknown") throw new LaunchAgentError("Unable to determine Motel LaunchAgent state from launchctl. Refusing to mutate the service until `motel service status` can inspect it reliably.")
274
+ return state
275
+ }
276
+
277
+ export type LaunchAgentManager = {
278
+ readonly available: boolean
279
+ readonly inspect: Effect.Effect<LaunchAgentInspection, LaunchAgentError>
280
+ readonly status: Effect.Effect<LaunchAgentStatus, LaunchAgentError>
281
+ readonly install: (replace: boolean) => Effect.Effect<LaunchAgentStatus, LaunchAgentError>
282
+ readonly uninstall: Effect.Effect<{ readonly removed: boolean }, LaunchAgentError>
283
+ readonly start: Effect.Effect<LaunchAgentStatus, LaunchAgentError>
284
+ readonly stop: Effect.Effect<LaunchAgentStatus, LaunchAgentError>
285
+ readonly restart: Effect.Effect<LaunchAgentStatus, LaunchAgentError>
286
+ }
287
+
288
+ export const createLaunchAgentManager = (spec = buildLaunchAgentSpec(), supplied?: Partial<LaunchAgentOperations>): LaunchAgentManager => {
289
+ const operations = { ...defaultOperations(), ...supplied }
290
+ const available = operations.platform === "darwin"
291
+ const macOnly = <A>(effect: Effect.Effect<A, LaunchAgentError>) =>
292
+ available ? effect : Effect.fail(new LaunchAgentError("Motel service management is available only on macOS LaunchAgent hosts."))
293
+ const requireExecutables = async () => {
294
+ for (const executable of spec.programArguments.slice(0, 2)) {
295
+ if (!await operations.exists(executable)) {
296
+ throw new LaunchAgentError(
297
+ `Cannot install Motel service: required executable is missing at ${executable}. Install Bun at ~/.bun/bin/bun and run \`bun link\` from the maintained Motel checkout so ~/.bun/bin/motel exists.`,
298
+ )
299
+ }
300
+ }
301
+ }
302
+ const inspect = Effect.tryPromise({ try: () => readInspection(operations, spec), catch: (error) => error instanceof LaunchAgentError ? error : new LaunchAgentError(error instanceof Error ? error.message : String(error)) })
303
+ const status = Effect.tryPromise({
304
+ try: async (): Promise<LaunchAgentStatus> => {
305
+ const inspection = await readInspection(operations, spec)
306
+ const comparison = compareLaunchAgent(inspection, spec)
307
+ const managerResult = await optional(operations, "launchctl", ["print", spec.target])
308
+ const health = await operations.getDaemonStatus()
309
+ return {
310
+ installed: comparison.kind === "equivalent",
311
+ configuration: inspection.kind === "missing" ? "missing" : comparison.kind,
312
+ configurationDetails: comparison.kind === "divergent" ? comparison.fields : comparison.kind === "malformed" ? [comparison.message] : [],
313
+ manager: launchctlState(managerResult),
314
+ running: health.running,
315
+ health,
316
+ registryIdentity: health.registryPid === null ? "missing" : health.managed ? "verified" : "unverified",
317
+ version: { cli: operations.version, server: health.version, drift: health.version === null ? null : health.version !== operations.version },
318
+ }
319
+ },
320
+ catch: (error) => error instanceof LaunchAgentError ? error : new LaunchAgentError(error instanceof Error ? error.message : String(error)),
321
+ })
322
+ const install = (replace: boolean) => Effect.tryPromise({
323
+ try: async () => {
324
+ const inspection = await readInspection(operations, spec)
325
+ const comparison = compareLaunchAgent(inspection, spec)
326
+ if (comparison.kind === "malformed" && !replace) throw new LaunchAgentError(`Existing service plist is malformed: ${comparison.message}. Re-run with --replace to replace it.`)
327
+ if (comparison.kind === "divergent" && inspection.kind !== "missing" && !replace) throw new LaunchAgentError(`Existing service plist diverges in ${comparison.fields.join(", ")}. Re-run with --replace to replace it.`)
328
+ const manager = requireKnownLaunchctlState(launchctlState(await optional(operations, "launchctl", ["print", spec.target])))
329
+ if (comparison.kind === "equivalent") {
330
+ if (manager === "loaded") return Effect.runPromise(status)
331
+ await requireExecutables()
332
+ await required(operations, "launchctl", ["bootstrap", spec.domain, spec.plistPath])
333
+ await required(operations, "launchctl", ["enable", spec.target])
334
+ await required(operations, "launchctl", ["kickstart", "-k", spec.target])
335
+ return Effect.runPromise(status)
336
+ }
337
+ await requireExecutables()
338
+ if (manager === "loaded" && !replace) throw new LaunchAgentError("Motel has a loaded LaunchAgent job that does not match the requested install state. Re-run with --replace to boot it out before installing.")
339
+ if (manager === "loaded") await required(operations, "launchctl", ["bootout", spec.target])
340
+ await operations.mkdir(spec.workingDirectory)
341
+ await writeAtomically(operations, spec)
342
+ await required(operations, "launchctl", ["bootstrap", spec.domain, spec.plistPath])
343
+ await required(operations, "launchctl", ["enable", spec.target])
344
+ await required(operations, "launchctl", ["kickstart", "-k", spec.target])
345
+ return Effect.runPromise(status)
346
+ },
347
+ catch: (error) => error instanceof LaunchAgentError ? error : new LaunchAgentError(error instanceof Error ? error.message : String(error)),
348
+ })
349
+ const start = Effect.tryPromise({
350
+ try: async () => {
351
+ const inspection = await readInspection(operations, spec)
352
+ if (compareLaunchAgent(inspection, spec).kind !== "equivalent") throw new LaunchAgentError("Cannot start an absent, malformed, or divergent Motel service.")
353
+ const manager = requireKnownLaunchctlState(launchctlState(await optional(operations, "launchctl", ["print", spec.target])))
354
+ if (manager === "not-loaded") await required(operations, "launchctl", ["bootstrap", spec.domain, spec.plistPath])
355
+ await required(operations, "launchctl", ["kickstart", "-k", spec.target])
356
+ return Effect.runPromise(status)
357
+ },
358
+ catch: (error) => error instanceof LaunchAgentError ? error : new LaunchAgentError(error instanceof Error ? error.message : String(error)),
359
+ })
360
+ const stop = Effect.tryPromise({
361
+ try: async () => {
362
+ const inspection = await readInspection(operations, spec)
363
+ if (compareLaunchAgent(inspection, spec).kind !== "equivalent") throw new LaunchAgentError("Cannot stop an absent, malformed, or divergent Motel service.")
364
+ const manager = requireKnownLaunchctlState(launchctlState(await optional(operations, "launchctl", ["print", spec.target])))
365
+ if (manager === "loaded") await required(operations, "launchctl", ["bootout", spec.target])
366
+ return Effect.runPromise(status)
367
+ },
368
+ catch: (error) => error instanceof LaunchAgentError ? error : new LaunchAgentError(error instanceof Error ? error.message : String(error)),
369
+ })
370
+ const restart = Effect.tryPromise({
371
+ try: async () => {
372
+ const inspection = await readInspection(operations, spec)
373
+ if (compareLaunchAgent(inspection, spec).kind !== "equivalent") throw new LaunchAgentError("Cannot restart an absent, malformed, or divergent Motel service.")
374
+ const manager = requireKnownLaunchctlState(launchctlState(await optional(operations, "launchctl", ["print", spec.target])))
375
+ if (manager === "not-loaded") await required(operations, "launchctl", ["bootstrap", spec.domain, spec.plistPath])
376
+ await required(operations, "launchctl", ["kickstart", "-k", spec.target])
377
+ return Effect.runPromise(status)
378
+ },
379
+ catch: (error) => error instanceof LaunchAgentError ? error : new LaunchAgentError(error instanceof Error ? error.message : String(error)),
380
+ })
381
+ const uninstall = Effect.tryPromise({
382
+ try: async () => {
383
+ const hasPlist = await operations.exists(spec.plistPath)
384
+ const manager = requireKnownLaunchctlState(launchctlState(await optional(operations, "launchctl", ["print", spec.target])))
385
+ if (manager === "loaded") await required(operations, "launchctl", ["bootout", spec.target])
386
+ if (hasPlist) {
387
+ await operations.unlink(spec.plistPath)
388
+ return { removed: true }
389
+ }
390
+ return { removed: manager === "loaded" }
391
+ },
392
+ catch: (error) => error instanceof LaunchAgentError ? error : new LaunchAgentError(error instanceof Error ? error.message : String(error)),
393
+ })
394
+ return {
395
+ available,
396
+ inspect: macOnly(inspect),
397
+ status: macOnly(status),
398
+ install: (replace) => macOnly(install(replace)),
399
+ uninstall: macOnly(uninstall),
400
+ start: macOnly(start),
401
+ stop: macOnly(stop),
402
+ restart: macOnly(restart),
403
+ }
404
+ }
405
+
406
+ export const isInstalledLaunchAgent = (manager = createLaunchAgentManager()) => manager.status.pipe(Effect.map((status) => status.installed))
407
+
408
+ export type MotelLifecycle = {
409
+ readonly start: Effect.Effect<DaemonStatus | LaunchAgentStatus, unknown, never>
410
+ readonly status: Effect.Effect<DaemonStatus | LaunchAgentStatus, unknown, never>
411
+ readonly stop: Effect.Effect<DaemonStatus | LaunchAgentStatus, unknown, never>
412
+ readonly restart: Effect.Effect<DaemonStatus | LaunchAgentStatus, unknown, never>
413
+ }
414
+
415
+ /**
416
+ * Routes the public daemon lifecycle before it reaches the detached manager.
417
+ * A KeepAlive-managed launchd child is never directly signalled by Motel.
418
+ */
419
+ export const createMotelLifecycle = (options: { readonly service?: LaunchAgentManager; readonly daemon?: DaemonManager } & LaunchAgentRuntimeOptions = {}): MotelLifecycle => {
420
+ const service = options.service ?? createLaunchAgentManager()
421
+ const daemon = options.daemon ?? createDaemonManager()
422
+ const serviceEnabled = service.available && usesLaunchAgentRuntime(options)
423
+ const route = (
424
+ status: LaunchAgentStatus,
425
+ supervised: Effect.Effect<DaemonStatus | LaunchAgentStatus, unknown, never>,
426
+ detached: Effect.Effect<DaemonStatus | LaunchAgentStatus, unknown, never>,
427
+ ) => {
428
+ if (status.manager === "unknown") return Effect.fail(new LaunchAgentError("Unable to determine Motel LaunchAgent state. Refusing detached lifecycle routing until `motel service status` can inspect it reliably."))
429
+ if (status.configuration === "missing" && status.manager === "not-loaded") return detached
430
+ if (!status.installed) {
431
+ return Effect.fail(new LaunchAgentError(
432
+ status.manager === "loaded"
433
+ ? "Motel has a loaded LaunchAgent that does not match the expected service definition. Run `motel service uninstall` or repair it with `motel service install --replace`."
434
+ : "Motel has a present LaunchAgent definition that does not match the expected service contract. Inspect `motel service status` and use `motel service install --replace` if replacement is intended.",
435
+ ))
436
+ }
437
+ return supervised
438
+ }
439
+ return {
440
+ status: serviceEnabled
441
+ ? service.status.pipe(Effect.flatMap((status): Effect.Effect<DaemonStatus | LaunchAgentStatus, unknown, never> => {
442
+ if (status.manager === "unknown") return Effect.fail(new LaunchAgentError("Unable to determine Motel LaunchAgent state. Refusing detached lifecycle routing until `motel service status` can inspect it reliably."))
443
+ return status.configuration === "missing" && status.manager === "not-loaded" ? daemon.getStatus : Effect.succeed(status)
444
+ })) as Effect.Effect<DaemonStatus | LaunchAgentStatus, unknown, never>
445
+ : daemon.getStatus,
446
+ start: serviceEnabled
447
+ ? service.status.pipe(Effect.flatMap((status): Effect.Effect<DaemonStatus | LaunchAgentStatus, unknown, never> => route(status, service.start, daemon.ensure))) as Effect.Effect<DaemonStatus | LaunchAgentStatus, unknown, never>
448
+ : daemon.ensure,
449
+ stop: serviceEnabled
450
+ ? service.status.pipe(Effect.flatMap((status): Effect.Effect<DaemonStatus | LaunchAgentStatus, unknown, never> => route(status, service.stop, daemon.stop))) as Effect.Effect<DaemonStatus | LaunchAgentStatus, unknown, never>
451
+ : daemon.stop,
452
+ restart: serviceEnabled
453
+ ? service.status.pipe(Effect.flatMap((status): Effect.Effect<DaemonStatus | LaunchAgentStatus, unknown, never> => route(status, service.restart, Effect.gen(function*() {
454
+ yield* daemon.stop
455
+ return yield* daemon.ensure
456
+ })))) as Effect.Effect<DaemonStatus | LaunchAgentStatus, unknown, never>
457
+ : Effect.gen(function*() {
458
+ yield* daemon.stop
459
+ return yield* daemon.ensure
460
+ }),
461
+ }
462
+ }