@typed-assistant/builder 0.0.22 → 0.0.24

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/.eslintrc.js CHANGED
@@ -1,7 +1,7 @@
1
1
  /** @type {import("eslint").Linter.Config} */
2
2
  module.exports = {
3
3
  root: true,
4
- extends: ["@typed-assistant/eslint-config/react-internal.js"],
5
- plugins: ['html'],
4
+ extends: ["@typed-assistant/eslint-config"],
5
+ plugins: ["html"],
6
6
  parserOptions: {},
7
7
  }
package/package.json CHANGED
@@ -1,10 +1,9 @@
1
1
  {
2
2
  "name": "@typed-assistant/builder",
3
- "version": "0.0.22",
3
+ "version": "0.0.24",
4
4
  "exports": {
5
5
  "./appProcess": "./src/appProcess.tsx",
6
6
  "./bunInstall": "./src/bunInstall.tsx",
7
- "./getSpawnText": "./src/getSpawnText.tsx",
8
7
  "./pullChanges": "./src/pullChanges.tsx"
9
8
  },
10
9
  "dependencies": {
@@ -14,7 +13,8 @@
14
13
  "@mdi/svg": "^7.3.67",
15
14
  "ignore": "^5.3.0",
16
15
  "react": "^18",
17
- "react-dom": "^18"
16
+ "react-dom": "^18",
17
+ "zod": "^3.22.4"
18
18
  },
19
19
  "devDependencies": {
20
20
  "@types/node": "^20.10.6",
@@ -24,10 +24,10 @@
24
24
  "home-assistant-js-websocket": "^8.2.0",
25
25
  "ts-toolbelt": "^9.6.0",
26
26
  "typescript": "^5.3.3",
27
- "@typed-assistant/eslint-config": "0.0.4",
28
- "@typed-assistant/typescript-config": "0.0.4",
29
- "@typed-assistant/utils": "0.0.7",
30
- "@typed-assistant/logger": "0.0.8"
27
+ "@typed-assistant/eslint-config": "0.0.5",
28
+ "@typed-assistant/logger": "0.0.9",
29
+ "@typed-assistant/typescript-config": "0.0.5",
30
+ "@typed-assistant/utils": "0.0.8"
31
31
  },
32
32
  "peerDependencies": {
33
33
  "home-assistant-js-websocket": "^8.2.0"
@@ -37,6 +37,6 @@
37
37
  "registry": "https://registry.npmjs.org/"
38
38
  },
39
39
  "scripts": {
40
- "lint": "eslint . --max-warnings 0"
40
+ "lint": "tsc --noEmit && eslint ."
41
41
  }
42
42
  }
@@ -1,15 +1,21 @@
1
- import { generateTypes } from "@typed-assistant/types/generateTypes"
2
1
  import { log } from "@typed-assistant/logger"
2
+ import { generateTypes } from "@typed-assistant/types/generateTypes"
3
+ import { getHassAPI, getSupervisorAPI } from "@typed-assistant/utils/getHassAPI"
4
+ import { withErrorHandling } from "@typed-assistant/utils/withErrorHandling"
3
5
  import type { Subprocess } from "bun"
6
+ import { $ } from "bun"
4
7
  import { readFileSync, watch } from "fs"
5
- import { join, relative } from "path"
6
8
  import ignore from "ignore"
7
- import { ONE_SECOND } from "@typed-assistant/utils/durations"
8
- import { getHassAPI, getSupervisorAPI } from "@typed-assistant/utils/getHassAPI"
9
- import { pullChanges } from "./pullChanges"
10
- import { withErrorHandling } from "@typed-assistant/utils/withErrorHandling"
9
+ import { join, relative } from "path"
10
+ import {
11
+ addKillListener,
12
+ callKillListeners,
13
+ callSoftKillListeners,
14
+ killSubprocess,
15
+ } from "./killProcess"
16
+ import { setupGitPoller } from "./setupGitPoller"
17
+ import { setupWebhook } from "./setupWebhook"
11
18
  import { startWebappServer } from "./setupWebserver"
12
- import { $ } from "bun"
13
19
 
14
20
  type Processes = Awaited<ReturnType<typeof buildAndStartAppProcess>>
15
21
 
@@ -30,12 +36,6 @@ async function startApp(appSourceFile: string) {
30
36
  })
31
37
  }
32
38
 
33
- async function kill(process: Subprocess) {
34
- log(`💀 Killing process: ${process.pid}`)
35
- process.kill()
36
- await process.exited
37
- }
38
-
39
39
  let settingUp = { current: false }
40
40
  async function killAndRestartApp(
41
41
  entryFile: string,
@@ -45,7 +45,7 @@ async function killAndRestartApp(
45
45
  if (settingUp.current) return subprocesses
46
46
  log("♻️ Restarting app...")
47
47
  settingUp.current = true
48
- if (subprocesses.app) await kill(subprocesses.app)
48
+ if (subprocesses.app) await killSubprocess(subprocesses.app)
49
49
  const newSubprocesses = await buildAndStartAppProcess(entryFile, options)
50
50
  settingUp.current = false
51
51
  return newSubprocesses
@@ -62,7 +62,7 @@ const checkProcesses = async (
62
62
 
63
63
  if (matches.length > 1) {
64
64
  multipleProcessesErrorCount++
65
- if (multipleProcessesErrorCount > 3) {
65
+ if (multipleProcessesErrorCount > 5) {
66
66
  const message = `🚨 Multiple processes detected. Restarting TypedAssistant addon...`
67
67
  log(message)
68
68
  onProcessError?.(message)
@@ -74,7 +74,7 @@ const checkProcesses = async (
74
74
 
75
75
  if (matches.length === 0) {
76
76
  noProcessesErrorCount++
77
- if (noProcessesErrorCount > 3) {
77
+ if (noProcessesErrorCount > 5) {
78
78
  const message = `🚨 No processes detected. Restarting TypedAssistant addon...`
79
79
  log(message)
80
80
  onProcessError?.(message)
@@ -87,7 +87,7 @@ const checkProcesses = async (
87
87
  setTimeout(() => checkProcesses(entryFile, { onProcessError }), 5000)
88
88
  }
89
89
 
90
- export async function setupWatcher({
90
+ export async function setup({
91
91
  entryFile,
92
92
  mdiPaths,
93
93
  onProcessError,
@@ -95,65 +95,50 @@ export async function setupWatcher({
95
95
  entryFile: string
96
96
  } & Parameters<typeof generateTypes>[0] &
97
97
  Parameters<typeof checkProcesses>[1]) {
98
- const { data: addonInfo, error: addonInfoError } = await getAddonInfo()
99
- if (addonInfoError) {
100
- log(`🚨 Failed to get addon info: ${addonInfoError}`)
101
- }
102
- await setupGitSync()
98
+ const addonInfo = await getAddonInfo()
99
+ const basePath = addonInfo?.data.ingress_entry ?? ""
100
+ const directoryToWatch = join(process.cwd(), "./src")
101
+
103
102
  checkProcesses(entryFile, { onProcessError })
103
+ await setupGitSync()
104
104
 
105
105
  let subprocesses = await buildAndStartAppProcess(entryFile, {
106
106
  mdiPaths: mdiPaths,
107
107
  })
108
-
109
- const directory = join(process.cwd(), "./src")
110
- log("👀 Watching directory:", directory)
111
- const watcher = watch(
112
- directory,
113
- { recursive: true },
114
- async function onFileChange(event, filename) {
115
- if (!filename) return
116
- if (shouldIgnoreFileOrFolder(filename)) return
117
- log(`⚠️ Change to ${filename} detected.`)
118
- if (filename.endsWith("process.tsx")) {
119
- await restartAddon()
120
- } else {
121
- subprocesses = await killAndRestartApp(
122
- entryFile,
123
- { mdiPaths },
124
- subprocesses,
125
- )
126
- }
127
- },
128
- )
129
-
130
108
  startWebappServer({
131
- basePath: addonInfo?.data.ingress_entry ?? "",
109
+ basePath,
132
110
  getSubprocesses: () => subprocesses,
133
111
  })
134
-
135
- process.on("SIGINT", () => {
136
- console.log("👋 Closing watcher...")
137
- watcher.close()
112
+ setupWatcher({
113
+ directoryToWatch,
114
+ entryFile,
115
+ mdiPaths,
116
+ onSubprocessChange: (newSubprocesses) => {
117
+ subprocesses = newSubprocesses
118
+ },
119
+ getSubprocesses: () => subprocesses,
138
120
  })
139
121
 
140
122
  return subprocesses
141
123
  }
142
124
 
143
- const setupGitSync = async ({
144
- gitPullPollDuration,
145
- }: {
146
- /** Duration in seconds */
147
- gitPullPollDuration?: number
148
- } = {}) => {
149
- const duration = gitPullPollDuration ?? 30
150
- const { error } = await pullChanges()
151
- if (error) return
152
- log(` ⏳ Pulling changes again in ${duration} seconds...`)
153
-
154
- setTimeout(() => {
155
- setupGitSync({ gitPullPollDuration })
156
- }, duration * ONE_SECOND)
125
+ const setupGitSync = async () => {
126
+ if (
127
+ !process.env.GITHUB_TOKEN ||
128
+ !process.env.GITHUB_USERNAME ||
129
+ !process.env.GITHUB_REPO
130
+ ) {
131
+ log(
132
+ "⚠️ Cannot sync with Github without Github token, username, and repo details. Add these in the add-on configuration.",
133
+ )
134
+ return { error: {} }
135
+ }
136
+ if (process.env.HASS_EXTERNAL_URL) {
137
+ await setupWebhook()
138
+ return
139
+ }
140
+ log("⚠️ No HASS_EXTERNAL_URL found. Setting up git poller...")
141
+ await setupGitPoller()
157
142
  }
158
143
 
159
144
  const ig = ignore().add(
@@ -169,20 +154,66 @@ const restartAddon = async () => {
169
154
  return
170
155
  }
171
156
  log("♻️ Restarting addon...")
172
- await getHassAPI(`http://supervisor/addons/self/restart`, { method: "POST" })
157
+ await getSupervisorAPI(`/addons/self/restart`, { method: "POST" })
173
158
  }
174
159
 
175
160
  const getAddonInfo = async () => {
176
161
  log("🔍 Getting addon info...")
177
162
 
178
- return withErrorHandling(getSupervisorAPI)<{
163
+ const { data, error } = await withErrorHandling(getSupervisorAPI)<{
179
164
  data: { ingress_entry: string }
180
165
  }>("/addons/self/info")
166
+
167
+ if (error) log(`🚨 Failed to get addon info: ${error}`)
168
+
169
+ return data
181
170
  }
182
171
 
183
- process.on("SIGINT", () => {
184
- setTimeout(() => {
185
- console.log("👋 ...")
186
- process.exit(0)
187
- }, 0)
172
+ function setupWatcher({
173
+ directoryToWatch,
174
+ entryFile,
175
+ mdiPaths,
176
+ onSubprocessChange,
177
+ getSubprocesses,
178
+ }: {
179
+ directoryToWatch: string
180
+ onSubprocessChange: (newSubprosses: {
181
+ app: Subprocess<"ignore", "pipe", "pipe">
182
+ }) => void
183
+ entryFile: string
184
+ mdiPaths: string[] | undefined
185
+ getSubprocesses: () => {
186
+ app: Subprocess<"ignore", "pipe", "pipe">
187
+ }
188
+ }) {
189
+ log("👀 Watching directory:", directoryToWatch)
190
+ const watcher = watch(
191
+ directoryToWatch,
192
+ { recursive: true },
193
+ async function onFileChange(event, filename) {
194
+ if (!filename) return
195
+ if (shouldIgnoreFileOrFolder(filename)) return
196
+ log(`⚠️ Change to ${filename} detected.`)
197
+ if (filename.endsWith("process.tsx")) {
198
+ await restartAddon()
199
+ } else {
200
+ onSubprocessChange(
201
+ await killAndRestartApp(entryFile, { mdiPaths }, getSubprocesses()),
202
+ )
203
+ }
204
+ },
205
+ )
206
+
207
+ addKillListener(() => {
208
+ if (watcher) watcher.close()
209
+ })
210
+
211
+ return watcher
212
+ }
213
+
214
+ process.on("SIGINT", async () => {
215
+ log("👋 Exiting...")
216
+ await callSoftKillListeners()
217
+ await callKillListeners()
218
+ process.exit(0)
188
219
  })
@@ -1,21 +1,11 @@
1
1
  import { log } from "@typed-assistant/logger"
2
+ import { $ } from "bun"
2
3
 
3
4
  export async function bunInstall() {
4
5
  log("🏗️ Running bun install...")
5
- const proc = Bun.spawn([
6
- "bun",
7
- "install",
8
- "--frozen-lockfile",
9
- "--cache-dir=.bun-cache",
10
- ])
11
- await proc.exited
12
- const bunInstallText = await Bun.readableStreamToText(proc.stdout)
13
- if (proc.exitCode === 0) return { error: null }
14
- return {
15
- error: {
16
- signalCode: proc.signalCode,
17
- exitCode: proc.exitCode,
18
- text: bunInstallText,
19
- },
20
- }
6
+ return $`bun install --frozen-lockfile --cache-dir=.bun-cache`
7
+ .text()
8
+ .catch((error) => {
9
+ log(`🚨 Failed to run bun install: ${error}`)
10
+ })
21
11
  }
@@ -0,0 +1,32 @@
1
+ import { log } from "@typed-assistant/logger"
2
+ import type { Subprocess } from "bun"
3
+ import { $ } from "bun"
4
+
5
+ const killListeners: (() => void | Promise<void>)[] = []
6
+ const softKillListeners: (() => void | Promise<void>)[] = []
7
+
8
+ export const addKillListener = (listener: () => void | Promise<void>) => {
9
+ killListeners.push(listener)
10
+ }
11
+ export const addSoftKillListener = (listener: () => void | Promise<void>) => {
12
+ softKillListeners.push(listener)
13
+ }
14
+
15
+ export async function callKillListeners() {
16
+ log("👋 Calling kill listeners!")
17
+ await Promise.all(killListeners.map((listener) => listener()))
18
+ log("👋 Called all kill listeners!")
19
+ }
20
+
21
+ export async function callSoftKillListeners() {
22
+ log("👋 Calling soft kill listeners!")
23
+ await Promise.all(softKillListeners.map((listener) => listener()))
24
+ log("👋 Called all soft kill listeners!")
25
+ }
26
+
27
+ export async function killSubprocess(subprocess: Subprocess) {
28
+ log(`💀 Killing process: ${subprocess.pid}`)
29
+ await callSoftKillListeners()
30
+ subprocess.kill()
31
+ await subprocess.exited
32
+ }
@@ -1,20 +1,10 @@
1
1
  import { log } from "@typed-assistant/logger"
2
- import { getSpawnText } from "./getSpawnText"
2
+ import { $ } from "bun"
3
3
  import { bunInstall } from "./bunInstall"
4
4
 
5
5
  export const pullChanges = async () => {
6
- if (
7
- !process.env.GITHUB_TOKEN ||
8
- !process.env.GITHUB_USERNAME ||
9
- !process.env.GITHUB_REPO
10
- ) {
11
- log(
12
- "⚠️ Cannot pull changes without GITHUB_TOKEN, GITHUB_USERNAME, and GITHUB_REPO environment variables.",
13
- )
14
- return { error: {} }
15
- }
16
6
  log("⬇️ Pulling changes...")
17
- const gitPullText = await getSpawnText(["git", "pull"])
7
+ const gitPullText = await $`git pull`.text()
18
8
  const packageJSONUpdated = /package.json/.test(gitPullText)
19
9
  const nothingNew = /Already up to date./.test(gitPullText)
20
10
  if (nothingNew) {
@@ -25,8 +15,7 @@ export const pullChanges = async () => {
25
15
  }
26
16
  if (packageJSONUpdated) {
27
17
  log(" 📦 package.json updated.")
28
- const { error } = await bunInstall()
29
- if (error) throw new Error(error.text)
18
+ await bunInstall()
30
19
  }
31
20
  return {}
32
21
  }
@@ -0,0 +1,19 @@
1
+ import { log } from "@typed-assistant/logger"
2
+ import { ONE_SECOND } from "@typed-assistant/utils/durations"
3
+ import { pullChanges } from "./pullChanges"
4
+
5
+ export const setupGitPoller = async ({
6
+ gitPullPollDuration,
7
+ }: {
8
+ /** Duration in seconds */
9
+ gitPullPollDuration?: number
10
+ } = {}) => {
11
+ const duration = gitPullPollDuration ?? 30
12
+ const { error } = await pullChanges()
13
+ if (error) return
14
+ log(` ⏳ Pulling changes again in ${duration} seconds...`)
15
+
16
+ setTimeout(() => {
17
+ setupGitPoller({ gitPullPollDuration })
18
+ }, duration * ONE_SECOND)
19
+ }
@@ -0,0 +1,159 @@
1
+ import { log } from "@typed-assistant/logger"
2
+ import { withErrorHandling } from "@typed-assistant/utils/withErrorHandling"
3
+ import { z } from "zod"
4
+
5
+ const webhookUrl = `${process.env.HASS_EXTERNAL_URL}/webhook`
6
+
7
+ const commonOptions = {
8
+ headers: {
9
+ Accept: "application/vnd.github+json",
10
+ Authorization: `Bearer ${process.env.GITHUB_TOKEN}`,
11
+ "X-GitHub-Api-Version": "2022-11-28",
12
+ },
13
+ }
14
+
15
+ const listRepoWebhooks = async () =>
16
+ withErrorHandling(() =>
17
+ fetch(
18
+ `https://api.github.com/repos/${process.env.GITHUB_USERNAME}/${process.env.GITHUB_REPO}/hooks`,
19
+ { ...commonOptions },
20
+ )
21
+ .then(handleFetchError)
22
+ .then((d) => d?.json())
23
+ .then(z.array(Webhook).parse),
24
+ )()
25
+
26
+ const deleteRepoWebhook = async (id: number) =>
27
+ withErrorHandling(() =>
28
+ fetch(
29
+ `https://api.github.com/repos/${process.env.GITHUB_USERNAME}/${process.env.GITHUB_REPO}/hooks/${id}`,
30
+ { ...commonOptions, method: "DELETE" },
31
+ ),
32
+ )()
33
+
34
+ const deleteAllRepoWebhooks = async () => {
35
+ const { data: webhooks, error } = await listRepoWebhooks()
36
+
37
+ if (error) {
38
+ log("🚨 Failed fetching webhooks", error.message)
39
+ return
40
+ }
41
+
42
+ await Promise.all(
43
+ webhooks.map(async (webhook) => {
44
+ await deleteRepoWebhook(webhook.id)
45
+ log("🚮 Webhook deleted: ", webhook.config.url)
46
+ }),
47
+ )
48
+ }
49
+
50
+ const createRepoWebhook = async () =>
51
+ withErrorHandling(() =>
52
+ fetch(
53
+ `https://api.github.com/repos/${process.env.GITHUB_USERNAME}/${process.env.GITHUB_REPO}/hooks`,
54
+ {
55
+ ...commonOptions,
56
+ method: "POST",
57
+ body: JSON.stringify({
58
+ name: "web",
59
+ active: true,
60
+ config: {
61
+ url: webhookUrl,
62
+ content_type: "json",
63
+ insecure_ssl: "0",
64
+ },
65
+ events: ["push"],
66
+ }),
67
+ },
68
+ )
69
+ .then(handleFetchError)
70
+ .then((d) => d.json())
71
+ .then(Webhook.parse),
72
+ )()
73
+
74
+ const Webhook = z.object({
75
+ type: z.literal("Repository"),
76
+ id: z.number(),
77
+ name: z.literal("web"),
78
+ active: z.boolean(),
79
+ events: z.array(z.string()),
80
+ config: z.object({
81
+ content_type: z.string(),
82
+ insecure_ssl: z.enum(["0", "1"]),
83
+ url: z.string(),
84
+ }),
85
+ updated_at: z.string(),
86
+ created_at: z.string(),
87
+ url: z.string(),
88
+ test_url: z.string(),
89
+ ping_url: z.string(),
90
+ deliveries_url: z.string(),
91
+ last_response: z.object({
92
+ code: z.number().nullable(),
93
+ status: z.string().nullable(),
94
+ message: z.string().nullable(),
95
+ }),
96
+ })
97
+
98
+ type Webhook = z.infer<typeof Webhook>
99
+
100
+ const handleFetchError = async (d: Response): Promise<Response> => {
101
+ if (!d.ok)
102
+ throw new Error(
103
+ d.status +
104
+ " " +
105
+ d.statusText +
106
+ (d.headers.get("Content-Type")?.includes("application/json")
107
+ ? `:\n${JSON.stringify(await d.json(), null, 2)}`
108
+ : ""),
109
+ )
110
+ return d
111
+ }
112
+
113
+ const retryTimeout = 2000
114
+ let retries = 0
115
+ export const setupWebhook = async (): Promise<void> => {
116
+ const { data: webhooks, error } = await listRepoWebhooks()
117
+
118
+ if (error) {
119
+ if (retries < 5) {
120
+ retries++
121
+ log(
122
+ `🔁 Failed fetching webhooks. Retrying setup in ${retryTimeout / 1000}s...`,
123
+ )
124
+ setTimeout(setupWebhook, retryTimeout)
125
+ return
126
+ }
127
+ log("🚨 Failed fetching webhooks. Giving up.", error.message)
128
+ return
129
+ }
130
+
131
+ const webhookAlreadyExists = webhooks.some(
132
+ async (webhook) => webhook.config.url === webhookUrl,
133
+ )
134
+
135
+ if (webhookAlreadyExists) {
136
+ log("🪝 Webhook already set up")
137
+ return
138
+ }
139
+
140
+ const { data: webhook, error: createError } = await createRepoWebhook()
141
+
142
+ if (createError) {
143
+ if (retries < 5) {
144
+ retries++
145
+ log(
146
+ `🔁 Failed creating webhook. Retrying setup in ${retryTimeout / 1000}s...`,
147
+ )
148
+ setTimeout(setupWebhook, retryTimeout)
149
+ return
150
+ }
151
+ log("🚨 Failed creating webhook. Giving up.", createError.message)
152
+ return
153
+ }
154
+
155
+ log("🪝 Webhook created: ", webhook.config.url)
156
+ }
157
+
158
+ // await setupWebhook()
159
+ // await deleteAllRepoWebhooks()
@@ -6,6 +6,7 @@ import { Elysia, t } from "elysia"
6
6
  import { watch } from "fs"
7
7
  import { basename, join } from "path"
8
8
  import type { List, String } from "ts-toolbelt"
9
+ import { addKillListener, addSoftKillListener } from "./killProcess"
9
10
 
10
11
  const indexHtmlFilePath = `${import.meta.dir}/webserver/index.html` as const
11
12
  const cssFile = `${import.meta.dir}/webserver/input.css` as const
@@ -159,10 +160,9 @@ export const startWebappServer = async ({
159
160
  }
160
161
  })
161
162
 
162
- process.on("SIGINT", () => {
163
- console.log("👋 Closing log watcher...")
163
+ addKillListener(async () => {
164
164
  watcher.close()
165
- server.stop()
165
+ await server.stop()
166
166
  })
167
167
 
168
168
  // eslint-disable-next-line no-constant-condition
@@ -170,7 +170,8 @@ export const startWebappServer = async ({
170
170
  const stdoutReader = getReader(getSubprocesses().app.stdout)
171
171
  const { value } = await stdoutReader.read()
172
172
 
173
- const convertedMessage = convert.toHtml(decoder.decode(value))
173
+ const newLocal = decoder.decode(value)
174
+ const convertedMessage = convert.toHtml(newLocal)
174
175
  if (convertedMessage !== "") {
175
176
  lastMessage = convertedMessage
176
177
  }
@@ -1,5 +1,5 @@
1
1
  import { useEffect, useState } from "react"
2
- import { app } from "./api"
2
+ import type { app } from "./api"
3
3
 
4
4
  export function useWS({
5
5
  onMessage,
package/tsconfig.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "extends": "@typed-assistant/typescript-config/react-app.json",
2
+ "extends": "@typed-assistant/typescript-config/react.json",
3
3
  "compilerOptions": {
4
4
  "paths": {
5
5
  "@elysiajs/eden": ["./node_modules/@elysiajs/eden/"],
@@ -1,14 +0,0 @@
1
- export async function getSpawnText(...args: Parameters<typeof Bun.spawn>) {
2
- const { exitCode, exited, stderr, stdout } = Bun.spawn(...args)
3
- await exited
4
- if (exitCode === 0 || exitCode === null)
5
- return await Bun.readableStreamToText(stdout)
6
- if (!exitCode) return ""
7
- throw new Error(
8
- `Failed to run command: "${args.join(
9
- " ",
10
- )}". Exit code: ${exitCode}. Stderr: ${
11
- stderr ? await Bun.readableStreamToText(stderr) : "None"
12
- }`,
13
- )
14
- }