@kitlangton/ghui 0.3.0 → 0.3.3

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 (55) hide show
  1. package/README.md +13 -3
  2. package/bin/ghui.js +6 -1
  3. package/dist/index.js +9456 -0
  4. package/package.json +7 -4
  5. package/src/App.tsx +0 -2779
  6. package/src/appCommands.ts +0 -409
  7. package/src/commands.ts +0 -73
  8. package/src/config.ts +0 -20
  9. package/src/date.ts +0 -20
  10. package/src/domain.ts +0 -149
  11. package/src/errors.ts +0 -10
  12. package/src/index.tsx +0 -50
  13. package/src/keyboard/opentuiAdapter.ts +0 -43
  14. package/src/keymap/all.ts +0 -116
  15. package/src/keymap/changedFilesModal.ts +0 -23
  16. package/src/keymap/closeModal.ts +0 -13
  17. package/src/keymap/commandPalette.ts +0 -16
  18. package/src/keymap/commentModal.ts +0 -73
  19. package/src/keymap/commentThreadModal.ts +0 -24
  20. package/src/keymap/detailView.ts +0 -30
  21. package/src/keymap/diffView.ts +0 -93
  22. package/src/keymap/filterMode.ts +0 -13
  23. package/src/keymap/helpers.ts +0 -24
  24. package/src/keymap/labelModal.ts +0 -16
  25. package/src/keymap/listNav.ts +0 -119
  26. package/src/keymap/mergeModal.ts +0 -23
  27. package/src/keymap/openRepositoryModal.ts +0 -13
  28. package/src/keymap/submitReviewModal.ts +0 -48
  29. package/src/keymap/themeModal.ts +0 -49
  30. package/src/mergeActions.ts +0 -88
  31. package/src/observability.ts +0 -46
  32. package/src/pullRequestCache.ts +0 -19
  33. package/src/pullRequestViews.ts +0 -43
  34. package/src/services/BrowserOpener.ts +0 -22
  35. package/src/services/Clipboard.ts +0 -46
  36. package/src/services/CommandRunner.ts +0 -91
  37. package/src/services/GitHubService.ts +0 -756
  38. package/src/services/MockGitHubService.ts +0 -182
  39. package/src/themeStore.ts +0 -60
  40. package/src/ui/CommandPalette.tsx +0 -176
  41. package/src/ui/DetailsPane.tsx +0 -656
  42. package/src/ui/FooterHints.tsx +0 -90
  43. package/src/ui/LoadingLogo.tsx +0 -75
  44. package/src/ui/PullRequestDiffPane.tsx +0 -249
  45. package/src/ui/PullRequestList.tsx +0 -194
  46. package/src/ui/colors.ts +0 -821
  47. package/src/ui/commentEditor.ts +0 -126
  48. package/src/ui/comments.tsx +0 -143
  49. package/src/ui/diff.ts +0 -650
  50. package/src/ui/diffStats.tsx +0 -25
  51. package/src/ui/modals.tsx +0 -964
  52. package/src/ui/primitives.tsx +0 -350
  53. package/src/ui/pullRequests.ts +0 -106
  54. package/src/ui/singleLineInput.ts +0 -26
  55. package/src/ui/spinner.ts +0 -1
@@ -1,23 +0,0 @@
1
- import { context } from "@ghui/keymap"
2
-
3
- export interface MergeModalCtx {
4
- readonly availableActionCount: number
5
- readonly closeModal: () => void
6
- readonly confirmMerge: () => void
7
- readonly moveSelection: (delta: -1 | 1) => void
8
- }
9
-
10
- const Merge = context<MergeModalCtx>()
11
-
12
- export const mergeModalKeymap = Merge(
13
- { id: "merge-modal.cancel", title: "Cancel", keys: ["escape"], run: (s) => s.closeModal() },
14
- {
15
- id: "merge-modal.confirm",
16
- title: "Merge pull request",
17
- keys: ["return"],
18
- enabled: (s) => s.availableActionCount > 0 ? true : "No merge actions available.",
19
- run: (s) => s.confirmMerge(),
20
- },
21
- { id: "merge-modal.up", title: "Up", keys: ["k", "up"], run: (s) => s.moveSelection(-1) },
22
- { id: "merge-modal.down", title: "Down", keys: ["j", "down"], run: (s) => s.moveSelection(1) },
23
- )
@@ -1,13 +0,0 @@
1
- import { context } from "@ghui/keymap"
2
-
3
- export interface OpenRepositoryModalCtx {
4
- readonly closeModal: () => void
5
- readonly openFromInput: () => void
6
- }
7
-
8
- const OpenRepo = context<OpenRepositoryModalCtx>()
9
-
10
- export const openRepositoryModalKeymap = OpenRepo(
11
- { id: "open-repo.close", title: "Cancel", keys: ["escape"], run: (s) => s.closeModal() },
12
- { id: "open-repo.open", title: "Open repository", keys: ["return"], run: (s) => s.openFromInput() },
13
- )
@@ -1,48 +0,0 @@
1
- import { context } from "@ghui/keymap"
2
-
3
- export interface SubmitReviewModalCtx {
4
- readonly closeModal: () => void
5
- readonly submit: () => void
6
- readonly insertNewline: () => void
7
- readonly moveActionSelection: (delta: -1 | 1) => void
8
- readonly moveLeft: () => void
9
- readonly moveRight: () => void
10
- readonly moveUp: () => void
11
- readonly moveDown: () => void
12
- readonly moveLineStart: () => void
13
- readonly moveLineEnd: () => void
14
- readonly moveWordBackward: () => void
15
- readonly moveWordForward: () => void
16
- readonly backspace: () => void
17
- readonly deleteForward: () => void
18
- readonly deleteWordBackward: () => void
19
- readonly deleteWordForward: () => void
20
- readonly deleteToLineStart: () => void
21
- readonly deleteToLineEnd: () => void
22
- }
23
-
24
- const SubmitReview = context<SubmitReviewModalCtx>()
25
-
26
- export const submitReviewModalKeymap = SubmitReview(
27
- { id: "submit-review.escape", title: "Cancel", keys: ["escape"], run: (s) => s.closeModal() },
28
- { id: "submit-review.submit", title: "Submit", keys: ["ctrl+s", "return"], run: (s) => s.submit() },
29
- { id: "submit-review.newline", title: "Insert newline", keys: ["shift+return"], run: (s) => s.insertNewline() },
30
- { id: "submit-review.next-action", title: "Next action", keys: ["tab"], run: (s) => s.moveActionSelection(1) },
31
- { id: "submit-review.previous-action", title: "Previous action", keys: ["shift+tab"], run: (s) => s.moveActionSelection(-1) },
32
-
33
- { id: "submit-review.move-left", title: "Cursor left", keys: ["left", "ctrl+b"], run: (s) => s.moveLeft() },
34
- { id: "submit-review.move-right", title: "Cursor right", keys: ["right", "ctrl+f"], run: (s) => s.moveRight() },
35
- { id: "submit-review.move-up", title: "Cursor up", keys: ["up"], run: (s) => s.moveUp() },
36
- { id: "submit-review.move-down", title: "Cursor down", keys: ["down"], run: (s) => s.moveDown() },
37
- { id: "submit-review.line-start", title: "Line start", keys: ["home", "ctrl+a"], run: (s) => s.moveLineStart() },
38
- { id: "submit-review.line-end", title: "Line end", keys: ["end", "ctrl+e"], run: (s) => s.moveLineEnd() },
39
- { id: "submit-review.word-back", title: "Word backward", keys: ["meta+b", "meta+left"], run: (s) => s.moveWordBackward() },
40
- { id: "submit-review.word-forward", title: "Word forward", keys: ["meta+f", "meta+right"], run: (s) => s.moveWordForward() },
41
-
42
- { id: "submit-review.backspace", title: "Backspace", keys: ["backspace"], run: (s) => s.backspace() },
43
- { id: "submit-review.delete", title: "Delete", keys: ["delete", "ctrl+d"], run: (s) => s.deleteForward() },
44
- { id: "submit-review.delete-word-back", title: "Delete word backward", keys: ["ctrl+w", "meta+backspace"], run: (s) => s.deleteWordBackward() },
45
- { id: "submit-review.delete-word-forward", title: "Delete word forward", keys: ["meta+delete"], run: (s) => s.deleteWordForward() },
46
- { id: "submit-review.delete-to-line-start", title: "Delete to line start", keys: ["ctrl+u"], run: (s) => s.deleteToLineStart() },
47
- { id: "submit-review.delete-to-line-end", title: "Delete to line end", keys: ["ctrl+k"], run: (s) => s.deleteToLineEnd() },
48
- )
@@ -1,49 +0,0 @@
1
- import { context } from "@ghui/keymap"
2
-
3
- export interface ThemeModalCtx {
4
- readonly filterMode: boolean
5
- readonly hasFilteredResults: boolean
6
- readonly closeWithoutSaving: () => void
7
- readonly clearFilter: () => void
8
- readonly enterFilterMode: () => void
9
- readonly confirmSelection: () => void
10
- readonly moveSelection: (delta: -1 | 1) => void
11
- }
12
-
13
- const Theme = context<ThemeModalCtx>()
14
-
15
- export const themeModalKeymap = Theme(
16
- {
17
- id: "theme-modal.escape",
18
- title: "Cancel",
19
- keys: ["escape"],
20
- run: (s) => {
21
- if (s.filterMode) s.clearFilter()
22
- else s.closeWithoutSaving()
23
- },
24
- },
25
- { id: "theme-modal.filter", title: "Filter themes", keys: ["/"], run: (s) => s.enterFilterMode() },
26
- {
27
- id: "theme-modal.confirm",
28
- title: "Apply theme",
29
- keys: ["return"],
30
- enabled: (s) => s.filterMode && !s.hasFilteredResults ? "No matching themes." : true,
31
- run: (s) => s.confirmSelection(),
32
- },
33
- { id: "theme-modal.up-arrow", title: "Up", keys: ["up", "ctrl+p", "ctrl+k"], run: (s) => s.moveSelection(-1) },
34
- { id: "theme-modal.down-arrow", title: "Down", keys: ["down", "ctrl+n", "ctrl+j"], run: (s) => s.moveSelection(1) },
35
- {
36
- id: "theme-modal.up-letter",
37
- title: "Up",
38
- keys: ["k"],
39
- when: (s) => !s.filterMode,
40
- run: (s) => s.moveSelection(-1),
41
- },
42
- {
43
- id: "theme-modal.down-letter",
44
- title: "Down",
45
- keys: ["j"],
46
- when: (s) => !s.filterMode,
47
- run: (s) => s.moveSelection(1),
48
- },
49
- )
@@ -1,88 +0,0 @@
1
- import type { PullRequestItem, PullRequestMergeAction, PullRequestMergeInfo, PullRequestState } from "./domain.js"
2
-
3
- export interface MergeActionDefinition {
4
- readonly action: PullRequestMergeAction
5
- readonly title: string
6
- readonly description: string
7
- readonly cliArgs: readonly string[]
8
- readonly pastTense: string
9
- readonly danger?: boolean
10
- readonly refreshOnSuccess?: boolean
11
- readonly optimisticState?: PullRequestState
12
- readonly optimisticAutoMergeEnabled?: boolean
13
- readonly isAvailable: (info: PullRequestMergeInfo) => boolean
14
- }
15
-
16
- const isCleanlyMergeable = (info: PullRequestMergeInfo) =>
17
- info.state === "open" &&
18
- !info.isDraft &&
19
- info.mergeable === "mergeable" &&
20
- info.reviewStatus !== "changes" &&
21
- info.reviewStatus !== "review" &&
22
- info.checkStatus !== "pending" &&
23
- info.checkStatus !== "failing"
24
-
25
- const mergeActionDefinitions = {
26
- squash: {
27
- action: "squash",
28
- title: "Squash merge now",
29
- description: "Merge this pull request and delete the branch.",
30
- cliArgs: ["--squash", "--delete-branch"],
31
- pastTense: "Merged",
32
- refreshOnSuccess: true,
33
- optimisticState: "merged",
34
- isAvailable: isCleanlyMergeable,
35
- },
36
- auto: {
37
- action: "auto",
38
- title: "Enable auto-merge",
39
- description: "Squash merge automatically after GitHub requirements pass.",
40
- cliArgs: ["--squash", "--auto", "--delete-branch"],
41
- pastTense: "Enabled auto-merge",
42
- optimisticAutoMergeEnabled: true,
43
- isAvailable: (info) => info.state === "open" && !info.autoMergeEnabled && !info.isDraft && info.mergeable !== "conflicting",
44
- },
45
- "disable-auto": {
46
- action: "disable-auto",
47
- title: "Disable auto-merge",
48
- description: "Cancel the pending GitHub auto-merge request.",
49
- cliArgs: ["--disable-auto"],
50
- pastTense: "Disabled auto-merge",
51
- optimisticAutoMergeEnabled: false,
52
- isAvailable: (info) => info.state === "open" && info.autoMergeEnabled,
53
- },
54
- admin: {
55
- action: "admin",
56
- title: "Admin override merge",
57
- description: "Bypass unmet merge requirements with --admin.",
58
- cliArgs: ["--squash", "--admin", "--delete-branch"],
59
- pastTense: "Admin merged",
60
- danger: true,
61
- refreshOnSuccess: true,
62
- optimisticState: "merged",
63
- isAvailable: (info) => info.state === "open" && !info.isDraft && info.mergeable !== "conflicting",
64
- },
65
- } as const satisfies Record<PullRequestMergeAction, MergeActionDefinition>
66
-
67
- export const mergeActions: readonly MergeActionDefinition[] = Object.values(mergeActionDefinitions)
68
-
69
- export const availableMergeActions = (info: PullRequestMergeInfo | null): readonly MergeActionDefinition[] => {
70
- if (!info) return []
71
- return mergeActions.filter((action) => action.isAvailable(info))
72
- }
73
-
74
- export const getMergeActionDefinition = (action: PullRequestMergeAction): MergeActionDefinition =>
75
- mergeActionDefinitions[action]
76
-
77
- export const mergeInfoFromPullRequest = (pullRequest: PullRequestItem): PullRequestMergeInfo => ({
78
- repository: pullRequest.repository,
79
- number: pullRequest.number,
80
- title: pullRequest.title,
81
- state: pullRequest.state,
82
- isDraft: pullRequest.reviewStatus === "draft",
83
- mergeable: "unknown",
84
- reviewStatus: pullRequest.reviewStatus,
85
- checkStatus: pullRequest.checkStatus,
86
- checkSummary: pullRequest.checkSummary,
87
- autoMergeEnabled: pullRequest.autoMergeEnabled,
88
- })
@@ -1,46 +0,0 @@
1
- import { Config, Effect, Layer } from "effect"
2
- import { FetchHttpClient } from "effect/unstable/http"
3
- import { OtlpLogger, OtlpSerialization, OtlpTracer } from "effect/unstable/observability"
4
-
5
- const observabilityConfig = Config.all({
6
- endpoint: Config.string("GHUI_OTLP_ENDPOINT").pipe(
7
- Config.withDefault(""),
8
- Config.map((value) => value.trim()),
9
- ),
10
- motelPort: Config.string("GHUI_MOTEL_PORT").pipe(
11
- Config.withDefault(""),
12
- Config.map((value) => value.trim()),
13
- ),
14
- })
15
-
16
- const resource = {
17
- serviceName: "ghui",
18
- serviceVersion: "local",
19
- }
20
-
21
- export const Observability = {
22
- layer: Layer.unwrap(Effect.gen(function*() {
23
- const { endpoint, motelPort } = yield* observabilityConfig
24
- const baseUrl = endpoint || (motelPort ? `http://127.0.0.1:${motelPort}` : null)
25
-
26
- return baseUrl === null
27
- ? Layer.empty
28
- : Layer.merge(
29
- OtlpTracer.layer({
30
- url: `${baseUrl}/v1/traces`,
31
- exportInterval: "500 millis",
32
- shutdownTimeout: "1 second",
33
- resource,
34
- }),
35
- OtlpLogger.layer({
36
- url: `${baseUrl}/v1/logs`,
37
- exportInterval: "500 millis",
38
- shutdownTimeout: "1 second",
39
- resource,
40
- }),
41
- ).pipe(
42
- Layer.provide(OtlpSerialization.layerJson),
43
- Layer.provide(FetchHttpClient.layer),
44
- )
45
- })),
46
- } as const
@@ -1,19 +0,0 @@
1
- import type { PullRequestItem } from "./domain.js"
2
-
3
- export const mergeCachedDetails = (fresh: readonly PullRequestItem[], cached: readonly PullRequestItem[] | undefined) => {
4
- if (!cached) return fresh
5
- const cachedByUrl = new Map(cached.map((pullRequest) => [pullRequest.url, pullRequest]))
6
- return fresh.map((pullRequest) => {
7
- const cachedPullRequest = cachedByUrl.get(pullRequest.url)
8
- if (!cachedPullRequest?.detailLoaded || cachedPullRequest.headRefOid !== pullRequest.headRefOid) return pullRequest
9
- return {
10
- ...pullRequest,
11
- body: cachedPullRequest.body,
12
- labels: cachedPullRequest.labels,
13
- additions: cachedPullRequest.additions,
14
- deletions: cachedPullRequest.deletions,
15
- changedFiles: cachedPullRequest.changedFiles,
16
- detailLoaded: true,
17
- } satisfies PullRequestItem
18
- })
19
- }
@@ -1,43 +0,0 @@
1
- import { pullRequestQueueLabels, pullRequestQueueModes, type PullRequestQueueMode, type PullRequestUserQueueMode } from "./domain.js"
2
-
3
- export type PullRequestView =
4
- | { readonly _tag: "Repository"; readonly repository: string }
5
- | { readonly _tag: "Queue"; readonly mode: PullRequestUserQueueMode; readonly repository: string | null }
6
-
7
- export const initialPullRequestView = (): PullRequestView => ({ _tag: "Queue", mode: "authored", repository: null })
8
-
9
- export const viewMode = (view: PullRequestView): PullRequestQueueMode => view._tag === "Repository" ? "repository" : view.mode
10
-
11
- export const viewRepository = (view: PullRequestView) => view.repository
12
-
13
- export const viewCacheKey = (view: PullRequestView) => view._tag === "Repository" ? `repository:${view.repository}` : view.mode
14
-
15
- export const viewEquals = (left: PullRequestView, right: PullRequestView) =>
16
- left._tag === right._tag && viewMode(left) === viewMode(right) && left.repository === right.repository
17
-
18
- export const activePullRequestViews = (view: PullRequestView): readonly PullRequestView[] => {
19
- const repository = viewRepository(view)
20
- return [
21
- ...(repository ? [{ _tag: "Repository" as const, repository }] : []),
22
- ...pullRequestQueueModes.map((mode) => ({ _tag: "Queue" as const, mode, repository })),
23
- ]
24
- }
25
-
26
- export const nextView = (view: PullRequestView, views: readonly PullRequestView[], delta: 1 | -1) => {
27
- const index = Math.max(0, views.findIndex((candidate) => viewEquals(candidate, view)))
28
- return views[(index + delta + views.length) % views.length]!
29
- }
30
-
31
- export const viewLabel = (view: PullRequestView) => view._tag === "Repository" ? view.repository : pullRequestQueueLabels[view.mode]
32
-
33
- export const parseRepositoryInput = (input: string) => {
34
- const trimmed = input.trim()
35
- const urlMatch = trimmed.match(/^(?:https?:\/\/)?(?:www\.)?github\.com\/([^/\s]+)\/([^/\s?#]+)(?:[/?#].*)?$/i)
36
- const shorthandMatch = trimmed.match(/^([^/\s]+)\/([^/\s]+)$/)
37
- const match = urlMatch ?? shorthandMatch
38
- if (!match) return null
39
- const owner = match[1]!
40
- const repo = match[2]!.replace(/\.git$/i, "")
41
- if (!/^[A-Za-z0-9_.-]+$/.test(owner) || !/^[A-Za-z0-9_.-]+$/.test(repo)) return null
42
- return `${owner}/${repo}`
43
- }
@@ -1,22 +0,0 @@
1
- import { Context, Effect, Layer } from "effect"
2
- import type { PullRequestItem } from "../domain.js"
3
- import { CommandRunner, type CommandError } from "./CommandRunner.js"
4
-
5
- export class BrowserOpener extends Context.Service<BrowserOpener, {
6
- readonly openPullRequest: (pullRequest: PullRequestItem) => Effect.Effect<void, CommandError>
7
- }>()("ghui/BrowserOpener") {
8
- static readonly layerNoDeps = Layer.effect(
9
- BrowserOpener,
10
- Effect.gen(function*() {
11
- const command = yield* CommandRunner
12
-
13
- const openPullRequest = Effect.fn("BrowserOpener.openPullRequest")(function*(pullRequest: PullRequestItem) {
14
- yield* command.run("gh", ["pr", "view", String(pullRequest.number), "--repo", pullRequest.repository, "--web"])
15
- })
16
-
17
- return BrowserOpener.of({ openPullRequest })
18
- }),
19
- )
20
-
21
- static readonly layer = BrowserOpener.layerNoDeps.pipe(Layer.provide(CommandRunner.layer))
22
- }
@@ -1,46 +0,0 @@
1
- import { Context, Effect, Layer, Schema } from "effect"
2
- import { CommandRunner } from "./CommandRunner.js"
3
-
4
- export class ClipboardError extends Schema.TaggedErrorClass<ClipboardError>()("ClipboardError", {
5
- detail: Schema.String,
6
- }) {}
7
-
8
- const clipboardCommands: readonly (readonly [string, ...readonly string[]])[] =
9
- process.platform === "darwin" ? [["pbcopy"]]
10
- : process.platform === "linux" ? [
11
- ...(process.env.WAYLAND_DISPLAY ? [["wl-copy"] as const] : []),
12
- ["xclip", "-selection", "clipboard"] as const,
13
- ["xsel", "--clipboard", "--input"] as const,
14
- ]
15
- : []
16
-
17
- const installHint = process.platform === "linux" ? " Install wl-clipboard, xclip, or xsel." : ""
18
- const unavailableDetail = `Clipboard is not available.${installHint}`
19
-
20
- export class Clipboard extends Context.Service<Clipboard, {
21
- readonly copy: (text: string) => Effect.Effect<void, ClipboardError>
22
- }>()("ghui/Clipboard") {
23
- static readonly layerNoDeps = Layer.effect(
24
- Clipboard,
25
- Effect.gen(function*() {
26
- const command = yield* CommandRunner
27
-
28
- const copy = Effect.fn("Clipboard.copy")(function*(text: string) {
29
- if (clipboardCommands.length === 0) {
30
- return yield* new ClipboardError({ detail: unavailableDetail })
31
- }
32
- let lastDetail = ""
33
- for (const [cmd, ...args] of clipboardCommands) {
34
- const result = yield* command.run(cmd, args, { stdin: text }).pipe(Effect.result)
35
- if (result._tag === "Success") return
36
- lastDetail = result.failure.detail
37
- }
38
- return yield* new ClipboardError({ detail: lastDetail || unavailableDetail })
39
- })
40
-
41
- return Clipboard.of({ copy })
42
- }),
43
- )
44
-
45
- static readonly layer = Clipboard.layerNoDeps.pipe(Layer.provide(CommandRunner.layer))
46
- }
@@ -1,91 +0,0 @@
1
- import { Context, Effect, Layer, Schema } from "effect"
2
-
3
- export interface CommandResult {
4
- readonly stdout: string
5
- readonly stderr: string
6
- readonly exitCode: number
7
- }
8
-
9
- export interface RunOptions {
10
- readonly stdin?: string
11
- }
12
-
13
- export class CommandError extends Schema.TaggedErrorClass<CommandError>()("CommandError", {
14
- command: Schema.String,
15
- args: Schema.Array(Schema.String),
16
- detail: Schema.String,
17
- cause: Schema.Defect,
18
- }) {}
19
-
20
- export class JsonParseError extends Schema.TaggedErrorClass<JsonParseError>()("JsonParseError", {
21
- command: Schema.String,
22
- args: Schema.Array(Schema.String),
23
- stdout: Schema.String,
24
- cause: Schema.Defect,
25
- }) {}
26
-
27
- const readStream = async (stream: ReadableStream | null | undefined) => {
28
- if (!stream) return ""
29
- return Bun.readableStreamToText(stream)
30
- }
31
-
32
- export class CommandRunner extends Context.Service<CommandRunner, {
33
- readonly run: (command: string, args: readonly string[], options?: RunOptions) => Effect.Effect<CommandResult, CommandError>
34
- readonly runSchema: <S extends Schema.Top>(schema: S, command: string, args: readonly string[]) =>
35
- Effect.Effect<S["Type"], CommandError | JsonParseError | Schema.SchemaError, S["DecodingServices"]>
36
- }>()("ghui/CommandRunner") {
37
- static readonly layer = Layer.effect(
38
- CommandRunner,
39
- Effect.gen(function*() {
40
- const runProcess = Effect.fn("CommandRunner.runProcess")((command: string, args: readonly string[], stdin: string | undefined) =>
41
- Effect.tryPromise({
42
- async try() {
43
- const proc = Bun.spawn({
44
- cmd: [command, ...args],
45
- stdin: stdin === undefined ? "ignore" : "pipe",
46
- stdout: "pipe",
47
- stderr: "pipe",
48
- })
49
- if (stdin !== undefined && proc.stdin) {
50
- proc.stdin.write(stdin)
51
- proc.stdin.end()
52
- }
53
-
54
- const [exitCode, stdout, stderr] = await Promise.all([proc.exited, readStream(proc.stdout), readStream(proc.stderr)])
55
- return { stdout, stderr, exitCode }
56
- },
57
- catch: (cause) => new CommandError({ command, args: [...args], detail: `Failed to run ${command}`, cause }),
58
- })
59
- )
60
-
61
- const run = Effect.fn("CommandRunner.run")(function*(command: string, args: readonly string[], options?: RunOptions) {
62
- const result = yield* runProcess(command, args, options?.stdin).pipe(Effect.withSpan("ghui.command.runProcess", {
63
- attributes: {
64
- "process.command": command,
65
- "process.argv.count": args.length,
66
- },
67
- }))
68
- if (result.exitCode !== 0) {
69
- const detail = result.stderr.trim() || result.stdout.trim() || `exit code ${result.exitCode}`
70
- return yield* new CommandError({ command, args: [...args], detail, cause: detail })
71
- }
72
- return result
73
- })
74
-
75
- const runJson = Effect.fn("CommandRunner.runJson")(function*<A>(command: string, args: readonly string[]) {
76
- const result = yield* run(command, args)
77
- return yield* Effect.try({
78
- try: () => JSON.parse(result.stdout) as A,
79
- catch: (cause) => new JsonParseError({ command, args: [...args], stdout: result.stdout, cause }),
80
- })
81
- })
82
-
83
- const runSchema = Effect.fn("CommandRunner.runSchema")(function*<S extends Schema.Top>(schema: S, command: string, args: readonly string[]) {
84
- const value = yield* runJson<unknown>(command, args)
85
- return yield* Schema.decodeUnknownEffect(schema)(value)
86
- })
87
-
88
- return CommandRunner.of({ run, runSchema })
89
- }),
90
- )
91
- }