@kitlangton/ghui 0.2.1 → 0.3.2
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/README.md +1 -4
- package/bin/ghui.js +6 -1
- package/dist/index.js +9419 -0
- package/package.json +7 -4
- package/src/App.tsx +0 -2604
- package/src/appCommands.ts +0 -384
- package/src/commands.ts +0 -73
- package/src/config.ts +0 -28
- package/src/date.ts +0 -20
- package/src/domain.ts +0 -139
- package/src/errors.ts +0 -10
- package/src/index.tsx +0 -50
- package/src/keyboard/opentuiAdapter.ts +0 -43
- package/src/keymap/all.ts +0 -103
- package/src/keymap/closeModal.ts +0 -13
- package/src/keymap/commandPalette.ts +0 -16
- package/src/keymap/commentModal.ts +0 -73
- package/src/keymap/commentThreadModal.ts +0 -24
- package/src/keymap/detailView.ts +0 -30
- package/src/keymap/diffView.ts +0 -91
- package/src/keymap/filterMode.ts +0 -13
- package/src/keymap/helpers.ts +0 -24
- package/src/keymap/labelModal.ts +0 -16
- package/src/keymap/listNav.ts +0 -119
- package/src/keymap/mergeModal.ts +0 -23
- package/src/keymap/openRepositoryModal.ts +0 -13
- package/src/keymap/themeModal.ts +0 -49
- package/src/mergeActions.ts +0 -88
- package/src/observability.ts +0 -46
- package/src/pullRequestCache.ts +0 -19
- package/src/pullRequestViews.ts +0 -45
- package/src/services/BrowserOpener.ts +0 -22
- package/src/services/Clipboard.ts +0 -46
- package/src/services/CommandRunner.ts +0 -91
- package/src/services/GitHubService.ts +0 -741
- package/src/services/MockGitHubService.ts +0 -181
- package/src/themeStore.ts +0 -60
- package/src/ui/CommandPalette.tsx +0 -216
- package/src/ui/DetailsPane.tsx +0 -656
- package/src/ui/FooterHints.tsx +0 -90
- package/src/ui/LoadingLogo.tsx +0 -75
- package/src/ui/PullRequestDiffPane.tsx +0 -248
- package/src/ui/PullRequestList.tsx +0 -210
- package/src/ui/colors.ts +0 -814
- package/src/ui/commentEditor.ts +0 -126
- package/src/ui/comments.tsx +0 -143
- package/src/ui/diff.ts +0 -650
- package/src/ui/diffStats.tsx +0 -25
- package/src/ui/modals.tsx +0 -614
- package/src/ui/primitives.tsx +0 -205
- package/src/ui/pullRequests.ts +0 -106
- package/src/ui/singleLineInput.ts +0 -26
- package/src/ui/spinner.ts +0 -1
package/src/keymap/themeModal.ts
DELETED
|
@@ -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"], run: (s) => s.moveSelection(-1) },
|
|
34
|
-
{ id: "theme-modal.down-arrow", title: "Down", keys: ["down"], 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
|
-
)
|
package/src/mergeActions.ts
DELETED
|
@@ -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
|
-
})
|
package/src/observability.ts
DELETED
|
@@ -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
|
package/src/pullRequestCache.ts
DELETED
|
@@ -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
|
-
}
|
package/src/pullRequestViews.ts
DELETED
|
@@ -1,45 +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 = (repository: string | null): PullRequestView => repository
|
|
8
|
-
? { _tag: "Repository", repository }
|
|
9
|
-
: { _tag: "Queue", mode: "authored", repository: null }
|
|
10
|
-
|
|
11
|
-
export const viewMode = (view: PullRequestView): PullRequestQueueMode => view._tag === "Repository" ? "repository" : view.mode
|
|
12
|
-
|
|
13
|
-
export const viewRepository = (view: PullRequestView) => view.repository
|
|
14
|
-
|
|
15
|
-
export const viewCacheKey = (view: PullRequestView) => view._tag === "Repository" ? `repository:${view.repository}` : view.mode
|
|
16
|
-
|
|
17
|
-
export const viewEquals = (left: PullRequestView, right: PullRequestView) =>
|
|
18
|
-
left._tag === right._tag && viewMode(left) === viewMode(right) && left.repository === right.repository
|
|
19
|
-
|
|
20
|
-
export const activePullRequestViews = (view: PullRequestView): readonly PullRequestView[] => {
|
|
21
|
-
const repository = viewRepository(view)
|
|
22
|
-
return [
|
|
23
|
-
...(repository ? [{ _tag: "Repository" as const, repository }] : []),
|
|
24
|
-
...pullRequestQueueModes.map((mode) => ({ _tag: "Queue" as const, mode, repository })),
|
|
25
|
-
]
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export const nextView = (view: PullRequestView, views: readonly PullRequestView[], delta: 1 | -1) => {
|
|
29
|
-
const index = Math.max(0, views.findIndex((candidate) => viewEquals(candidate, view)))
|
|
30
|
-
return views[(index + delta + views.length) % views.length]!
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
export const viewLabel = (view: PullRequestView) => view._tag === "Repository" ? view.repository : pullRequestQueueLabels[view.mode]
|
|
34
|
-
|
|
35
|
-
export const parseRepositoryInput = (input: string) => {
|
|
36
|
-
const trimmed = input.trim()
|
|
37
|
-
const urlMatch = trimmed.match(/^(?:https?:\/\/)?(?:www\.)?github\.com\/([^/\s]+)\/([^/\s?#]+)(?:[/?#].*)?$/i)
|
|
38
|
-
const shorthandMatch = trimmed.match(/^([^/\s]+)\/([^/\s]+)$/)
|
|
39
|
-
const match = urlMatch ?? shorthandMatch
|
|
40
|
-
if (!match) return null
|
|
41
|
-
const owner = match[1]!
|
|
42
|
-
const repo = match[2]!.replace(/\.git$/i, "")
|
|
43
|
-
if (!/^[A-Za-z0-9_.-]+$/.test(owner) || !/^[A-Za-z0-9_.-]+$/.test(repo)) return null
|
|
44
|
-
return `${owner}/${repo}`
|
|
45
|
-
}
|
|
@@ -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
|
-
}
|