@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.
- package/AGENTS.md +180 -0
- package/LICENSE +21 -0
- package/README.md +137 -0
- package/package.json +119 -0
- package/skills/motel-debug/SKILL.md +203 -0
- package/skills/motel-debug/references/effect.md +38 -0
- package/src/App.tsx +414 -0
- package/src/StartupGate.test.ts +80 -0
- package/src/StartupGate.tsx +263 -0
- package/src/cli.ts +205 -0
- package/src/config.ts +45 -0
- package/src/daemon.test.ts +547 -0
- package/src/daemon.ts +486 -0
- package/src/domain.test.ts +62 -0
- package/src/domain.ts +291 -0
- package/src/httpApi.ts +391 -0
- package/src/httpListPolicy.test.ts +76 -0
- package/src/httpListPolicy.ts +129 -0
- package/src/index.tsx +25 -0
- package/src/instructions.ts +72 -0
- package/src/launchAgent.test.ts +430 -0
- package/src/launchAgent.ts +462 -0
- package/src/localServer.ts +568 -0
- package/src/locator.ts +138 -0
- package/src/mcp.ts +433 -0
- package/src/motel.test.ts +176 -0
- package/src/motel.ts +88 -0
- package/src/motelClient.ts +353 -0
- package/src/opentui-jsx.d.ts +11 -0
- package/src/otlp.test.ts +65 -0
- package/src/otlp.ts +162 -0
- package/src/otlpProtobuf.ts +35 -0
- package/src/queryFilters.ts +39 -0
- package/src/registry.ts +115 -0
- package/src/runtime.ts +40 -0
- package/src/server.ts +11 -0
- package/src/services/AsyncIngest.ts +82 -0
- package/src/services/LogQueryService.ts +29 -0
- package/src/services/TelemetryQuery.ts +62 -0
- package/src/services/TelemetryStore.ts +2554 -0
- package/src/services/TraceQueryService.ts +43 -0
- package/src/services/ingestRpc.ts +39 -0
- package/src/services/queryRpc.ts +15 -0
- package/src/services/telemetryQueryWorker.ts +32 -0
- package/src/services/telemetryWorker.ts +59 -0
- package/src/startupBench.ts +19 -0
- package/src/startupLifecycle.ts +57 -0
- package/src/storybook/aiChatStory.tsx +244 -0
- package/src/storybook/fixtures/errorState.ts +44 -0
- package/src/storybook/fixtures/imagePaste.ts +34 -0
- package/src/storybook/fixtures/index.ts +62 -0
- package/src/storybook/fixtures/kitchenSink.ts +148 -0
- package/src/storybook/fixtures/rawPrompt.ts +15 -0
- package/src/storybook/fixtures/short.ts +27 -0
- package/src/storybook/fixtures/toolHeavy.ts +65 -0
- package/src/telemetry.test.ts +1053 -0
- package/src/ui/AiChatView.tsx +308 -0
- package/src/ui/AttrFilterModal.tsx +120 -0
- package/src/ui/ServiceLogs.tsx +115 -0
- package/src/ui/SpanContentView.tsx +184 -0
- package/src/ui/SpanDetail.tsx +215 -0
- package/src/ui/SpanDetailFull.tsx +224 -0
- package/src/ui/SpanDetailPane.tsx +90 -0
- package/src/ui/TraceDetailsPane.tsx +175 -0
- package/src/ui/TraceList.tsx +254 -0
- package/src/ui/Waterfall.tsx +389 -0
- package/src/ui/aiChatModel.test.ts +391 -0
- package/src/ui/aiChatModel.ts +773 -0
- package/src/ui/aiState.ts +59 -0
- package/src/ui/app/TraceListPane.tsx +39 -0
- package/src/ui/app/TraceWorkspace.tsx +452 -0
- package/src/ui/app/useAppLayout.ts +101 -0
- package/src/ui/app/useTraceScreenData.ts +590 -0
- package/src/ui/atoms.ts +131 -0
- package/src/ui/cachedLoader.test.ts +23 -0
- package/src/ui/cachedLoader.ts +60 -0
- package/src/ui/filterParser.test.ts +56 -0
- package/src/ui/filterParser.ts +45 -0
- package/src/ui/format.ts +128 -0
- package/src/ui/loaders.ts +101 -0
- package/src/ui/persistence.ts +41 -0
- package/src/ui/primitives.tsx +198 -0
- package/src/ui/state.ts +6 -0
- package/src/ui/theme.ts +160 -0
- package/src/ui/traceDetailsWidth.repro.test.ts +126 -0
- package/src/ui/traceSortNav.repro.seed.ts +62 -0
- package/src/ui/traceSortNav.repro.test.ts +231 -0
- package/src/ui/useAttrFilterPicker.ts +89 -0
- package/src/ui/useKeyboardNav.ts +1023 -0
- package/src/ui/waterfallFilter.test.ts +84 -0
- package/src/ui/waterfallFilter.ts +59 -0
- package/src/ui/waterfallModel.ts +130 -0
- package/src/ui/waterfallNav.repro.seed.ts +86 -0
- package/src/ui/waterfallNav.repro.test.ts +271 -0
- package/src/ui/waterfallNav.test.ts +453 -0
- package/src/ui/waterfallNav.ts +75 -0
- package/web/dist/assets/index-B01z9BaO.css +2 -0
- package/web/dist/assets/index-CYlBaY9O.js +22 -0
- 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("&", "&")
|
|
62
|
+
.replaceAll("<", "<")
|
|
63
|
+
.replaceAll(">", ">")
|
|
64
|
+
.replaceAll('"', """)
|
|
65
|
+
.replaceAll("'", "'")
|
|
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
|
+
}
|