@playfast/muxmaxing-config 0.1.1 → 0.1.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 +2 -2
- package/package.json +1 -3
- package/src/index.ts +3 -51
- package/src/protocol.ts +1 -23
- package/src/worker-entry.ts +3 -13
- package/src/fixtures/sample.config.ts +0 -35
- package/src/worker-entry.test.ts +0 -159
package/README.md
CHANGED
|
@@ -13,7 +13,7 @@ host-side in a Bun Worker, only for repos the user has explicitly approved.
|
|
|
13
13
|
import { defineConfig } from '@playfast/muxmaxing-config'
|
|
14
14
|
import { Effect, Schema as S } from 'effect'
|
|
15
15
|
|
|
16
|
-
const TokenJson = S.parseJson(S.Struct({
|
|
16
|
+
const TokenJson = S.parseJson(S.Struct({ token: S.String, slug: S.String }))
|
|
17
17
|
|
|
18
18
|
export default defineConfig({
|
|
19
19
|
hostInput: S.Struct({ dopplerToken: S.String, tokenSlug: S.String }),
|
|
@@ -25,7 +25,7 @@ export default defineConfig({
|
|
|
25
25
|
`doppler configs tokens create mux-${unit.id} --project my-app --config dev --max-age 24h --json`,
|
|
26
26
|
)
|
|
27
27
|
const token = yield* S.decode(TokenJson)(minted.stdout)
|
|
28
|
-
return { dopplerToken: token.
|
|
28
|
+
return { dopplerToken: token.token, tokenSlug: token.slug }
|
|
29
29
|
}),
|
|
30
30
|
|
|
31
31
|
// Runs when the unit is up: `ctx.unit.bash` execs INSIDE the unit.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@playfast/muxmaxing-config",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Author API for muxmaxing.config.ts — a repo-committed hook bridge between the host device and muxmaxing work units: typed host-input collection plus Effect-native lifecycle hooks (machine created, new terminal, removed) with bash bridged into the unit.",
|
|
6
6
|
"keywords": [
|
|
@@ -33,8 +33,6 @@
|
|
|
33
33
|
"clean": "rm -rf dist .tsbuildinfo",
|
|
34
34
|
"check": "tsc --noEmit",
|
|
35
35
|
"build": "tsc -p tsconfig.build.json",
|
|
36
|
-
"test": "vitest run",
|
|
37
|
-
"test:watch": "vitest",
|
|
38
36
|
"lint": "oxlint src",
|
|
39
37
|
"lint:fix": "oxlint --fix src"
|
|
40
38
|
},
|
package/src/index.ts
CHANGED
|
@@ -1,27 +1,12 @@
|
|
|
1
1
|
import { type Cause, Data, type Effect, Schema as S } from 'effect'
|
|
2
2
|
|
|
3
|
-
// The author API for a repo-committed `muxmaxing.config.ts` — a HOOK BRIDGE between the
|
|
4
|
-
// host device (the machine running mux) and a work unit (the pod/VM muxmaxing created for
|
|
5
|
-
// the repo). The author declares a typed `hostInput` payload, collects it ON THE HOST
|
|
6
|
-
// (read host env, exec host CLIs — e.g. mint an ephemeral Doppler token with the user's
|
|
7
|
-
// own login), and acts on unit lifecycle moments with it (`ctx.bash` execs IN the unit).
|
|
8
|
-
//
|
|
9
|
-
// Hooks are Effect-native: each returns an `Effect`, never a Promise. They run host-side
|
|
10
|
-
// in a Bun Worker — a THREAD boundary, not a security sandbox — so muxmaxing only runs
|
|
11
|
-
// them for repos the user has explicitly approved.
|
|
12
|
-
|
|
13
|
-
/** The captured streams + exit code of a bridged bash execution (unit or host). A
|
|
14
|
-
* non-zero exit is NOT an error — it's a successful run whose `exitCode` the hook
|
|
15
|
-
* interprets; only a transport failure (the command could not RUN) fails the Effect. */
|
|
16
3
|
export interface BashResult {
|
|
17
4
|
readonly stdout: string
|
|
18
5
|
readonly stderr: string
|
|
19
6
|
readonly exitCode: number
|
|
20
7
|
}
|
|
21
8
|
|
|
22
|
-
|
|
23
|
-
* `.d.ts` can describe the `extends` base under `isolatedDeclarations` (which forbids
|
|
24
|
-
* an inferred expression in an extends clause) — the reform-db errors pattern. */
|
|
9
|
+
// isolatedDeclarations forbids an inferred expression in an extends clause.
|
|
25
10
|
type TaggedErrorClass<Tag extends string, A extends Record<string, unknown>> = new (
|
|
26
11
|
args: A,
|
|
27
12
|
) => Cause.YieldableError & { readonly _tag: Tag } & Readonly<A>
|
|
@@ -29,48 +14,33 @@ type TaggedErrorClass<Tag extends string, A extends Record<string, unknown>> = n
|
|
|
29
14
|
const HookBashErrorBase: TaggedErrorClass<'HookBashError', { readonly reason: string }> =
|
|
30
15
|
Data.TaggedError('HookBashError')<{ readonly reason: string }>
|
|
31
16
|
|
|
32
|
-
/** Transport failure of a bridged bash call — the command could not be executed at all
|
|
33
|
-
* (unit unreachable, host spawn failed, bridge torn down). */
|
|
34
17
|
export class HookBashError extends HookBashErrorBase {
|
|
35
|
-
//
|
|
36
|
-
// print the generic "An error has occurred" — the reform-db errors gotcha).
|
|
18
|
+
// Cause.pretty would otherwise print the generic "An error has occurred".
|
|
37
19
|
override get message(): string {
|
|
38
20
|
return this.reason
|
|
39
21
|
}
|
|
40
22
|
}
|
|
41
23
|
|
|
42
|
-
/** The host-device side of the bridge: exec on the machine running mux + its env. */
|
|
43
24
|
export interface HostBridge {
|
|
44
|
-
/** Run a bash command ON THE HOST DEVICE. */
|
|
45
25
|
readonly bash: (command: string) => Effect.Effect<BashResult, HookBashError>
|
|
46
|
-
/** The host process env (hooks share the host process — no sandbox). */
|
|
47
26
|
readonly env: Readonly<Record<string, string | undefined>>
|
|
48
27
|
}
|
|
49
28
|
|
|
50
|
-
/** The work unit a hook invocation is about. */
|
|
51
29
|
export interface UnitInfo {
|
|
52
30
|
readonly id: string
|
|
53
|
-
/** The unit-side folder the repo is checked out under (bash cwd for unit execs). */
|
|
54
31
|
readonly workspaceFolder: string
|
|
55
32
|
}
|
|
56
33
|
|
|
57
|
-
/** The work-unit side of the bridge: the unit's identity + exec INSIDE it — the mirror
|
|
58
|
-
* of `HostBridge`, so hooks read as `ctx.host.bash(...)` / `ctx.unit.bash(...)`. */
|
|
59
34
|
export interface UnitBridge extends UnitInfo {
|
|
60
|
-
/** Run a bash command INSIDE the work unit. */
|
|
61
35
|
readonly bash: (command: string) => Effect.Effect<BashResult, HookBashError>
|
|
62
36
|
}
|
|
63
37
|
|
|
64
|
-
/** Ctx for `collectHostDeviceInput` — host-only (the payload doesn't exist yet). */
|
|
65
38
|
export interface CollectCtx {
|
|
66
39
|
readonly host: HostBridge
|
|
67
40
|
readonly unit: UnitInfo
|
|
68
|
-
/** Surface a line in the unit's creation pane. */
|
|
69
41
|
readonly log: (line: string) => Effect.Effect<void>
|
|
70
42
|
}
|
|
71
43
|
|
|
72
|
-
/** Ctx for unit-lifecycle hooks: the decoded `hostInput` + both sides of the bridge
|
|
73
|
-
* (`ctx.unit.bash` execs in the unit, `ctx.host.bash` on the host device). */
|
|
74
44
|
export interface HookCtx<A> {
|
|
75
45
|
readonly hostInput: A
|
|
76
46
|
readonly host: HostBridge
|
|
@@ -78,8 +48,6 @@ export interface HookCtx<A> {
|
|
|
78
48
|
readonly log: (line: string) => Effect.Effect<void>
|
|
79
49
|
}
|
|
80
50
|
|
|
81
|
-
/** Ctx for `onWorkUnitRemoved` — the unit may already be gone, so there is no unit
|
|
82
|
-
* bash; cleanup acts on the host (e.g. revoke the token minted at collect time). */
|
|
83
51
|
export interface RemovedCtx<A> {
|
|
84
52
|
readonly hostInput: A
|
|
85
53
|
readonly host: HostBridge
|
|
@@ -87,36 +55,20 @@ export interface RemovedCtx<A> {
|
|
|
87
55
|
readonly log: (line: string) => Effect.Effect<void>
|
|
88
56
|
}
|
|
89
57
|
|
|
90
|
-
|
|
91
|
-
* ENCODED side `I` must be plain JSON — it crosses the worker boundary and is cached
|
|
92
|
-
* (memory-only) by the engine between hooks.
|
|
93
|
-
*
|
|
94
|
-
* `ExternalApi` postfix: this shape is authored by EXTERNAL repos (their
|
|
95
|
-
* `muxmaxing.config.ts`), where optional hook fields are the ergonomic contract. */
|
|
58
|
+
// ExternalApi: optional hooks are the ergonomic contract for external repos.
|
|
96
59
|
export interface MuxmaxingConfigExternalApi<A, I> {
|
|
97
|
-
/** The Schema of the payload that crosses host → hooks (encoded side: plain JSON). */
|
|
98
60
|
readonly hostInput: S.Schema<A, I, never>
|
|
99
|
-
/** Runs ON THE HOST when the unit machine comes up; produces the `hostInput` every
|
|
100
|
-
* later hook receives. Collected ONCE per unit and cached by the engine. */
|
|
101
61
|
readonly collectHostDeviceInput: (ctx: CollectCtx) => Effect.Effect<A, unknown>
|
|
102
|
-
/** Fired when the unit is up and reachable (after devcontainer lifecycle hooks). */
|
|
103
62
|
readonly onWorkUnitMachineCreated?: (ctx: HookCtx<A>) => Effect.Effect<void, unknown>
|
|
104
|
-
/** Fired when a terminal opens in the unit. */
|
|
105
63
|
readonly onNewTerminal?: (ctx: HookCtx<A>) => Effect.Effect<void, unknown>
|
|
106
|
-
/** Fired at unit teardown — host-side cleanup (revoke what collect minted). */
|
|
107
64
|
readonly onWorkUnitRemoved?: (ctx: RemovedCtx<A>) => Effect.Effect<void, unknown>
|
|
108
65
|
}
|
|
109
66
|
|
|
110
|
-
/** The public name for the config contract. */
|
|
111
67
|
export type MuxmaxingConfig<A, I> = MuxmaxingConfigExternalApi<A, I>
|
|
112
68
|
|
|
113
|
-
/** Identity with types — the `defineConfig` a repo's `muxmaxing.config.ts` default-exports. */
|
|
114
69
|
export const defineConfig = <A, I>(config: MuxmaxingConfig<A, I>): MuxmaxingConfig<A, I> =>
|
|
115
70
|
config
|
|
116
71
|
|
|
117
|
-
/** Runtime narrowing for the worker entry: the default export of a fetched config module,
|
|
118
|
-
* checked structurally (schema + functions). The `hostInput` Schema remains the real
|
|
119
|
-
* gate for the DATA; this predicate gates the SHAPE. */
|
|
120
72
|
export const isMuxmaxingConfig = (
|
|
121
73
|
candidate: unknown,
|
|
122
74
|
): candidate is MuxmaxingConfig<unknown, unknown> => {
|
package/src/protocol.ts
CHANGED
|
@@ -1,15 +1,6 @@
|
|
|
1
1
|
import { Schema as S } from 'effect'
|
|
2
2
|
|
|
3
|
-
//
|
|
4
|
-
// hook worker. Every message is encoded before `postMessage` and decoded on receipt via
|
|
5
|
-
// these schemas — plain JSON only (structured clone fast path, no class prototypes on
|
|
6
|
-
// the wire). One worker serves exactly ONE `RunHook`; `Bash` requests stream back from
|
|
7
|
-
// the worker while its hook runs, each answered by a `BashReply`.
|
|
8
|
-
//
|
|
9
|
-
// Annotated `TaggedStruct` consts (not `S.TaggedClass`) so the declarations survive
|
|
10
|
-
// `isolatedDeclarations` — the same reason reform-db's errors annotate their base.
|
|
11
|
-
|
|
12
|
-
/** Which hook the worker should run. `collect` produces the hostInput; the rest consume it. */
|
|
3
|
+
// TaggedStruct consts (not TaggedClass) so declarations survive isolatedDeclarations.
|
|
13
4
|
export const HookName: S.Literal<['collect', 'machineCreated', 'newTerminal', 'removed']> =
|
|
14
5
|
S.Literal('collect', 'machineCreated', 'newTerminal', 'removed')
|
|
15
6
|
export type HookName = typeof HookName.Type
|
|
@@ -18,10 +9,6 @@ export const UnitRef: S.Struct<{ id: typeof S.String; workspaceFolder: typeof S.
|
|
|
18
9
|
S.Struct({ id: S.String, workspaceFolder: S.String })
|
|
19
10
|
export type UnitRef = typeof UnitRef.Type
|
|
20
11
|
|
|
21
|
-
// ── engine → worker ───────────────────────────────────────────────────────────
|
|
22
|
-
|
|
23
|
-
/** The single invocation a worker serves. `configPath` is the materialised config module
|
|
24
|
-
* to import; `hostInput` is the ENCODED payload (`null` for `collect`, which creates it). */
|
|
25
12
|
export const RunHookRequest: S.TaggedStruct<
|
|
26
13
|
'RunHook',
|
|
27
14
|
{
|
|
@@ -48,7 +35,6 @@ export const BashFailed: S.TaggedStruct<'BashFailed', { reason: typeof S.String
|
|
|
48
35
|
S.TaggedStruct('BashFailed', { reason: S.String })
|
|
49
36
|
export type BashFailed = typeof BashFailed.Type
|
|
50
37
|
|
|
51
|
-
/** The engine's answer to one `Bash` request, correlated by `id`. */
|
|
52
38
|
export const BashReply: S.TaggedStruct<
|
|
53
39
|
'BashReply',
|
|
54
40
|
{ id: typeof S.Number; result: S.Union<[typeof BashOk, typeof BashFailed]> }
|
|
@@ -61,10 +47,6 @@ export const EngineToWorker: S.Union<[typeof RunHookRequest, typeof BashReply]>
|
|
|
61
47
|
)
|
|
62
48
|
export type EngineToWorker = typeof EngineToWorker.Type
|
|
63
49
|
|
|
64
|
-
// ── worker → engine ───────────────────────────────────────────────────────────
|
|
65
|
-
|
|
66
|
-
/** A bridged bash execution request: `unit` execs inside the work unit, `host` on the
|
|
67
|
-
* host device. The engine replies with a `BashReply` carrying the same `id`. */
|
|
68
50
|
export const BashRequest: S.TaggedStruct<
|
|
69
51
|
'Bash',
|
|
70
52
|
{
|
|
@@ -79,20 +61,16 @@ export const BashRequest: S.TaggedStruct<
|
|
|
79
61
|
})
|
|
80
62
|
export type BashRequest = typeof BashRequest.Type
|
|
81
63
|
|
|
82
|
-
/** A line for the unit's creation pane (the hook's `ctx.log`). */
|
|
83
64
|
export const LogMessage: S.TaggedStruct<'Log', { line: typeof S.String }> = S.TaggedStruct(
|
|
84
65
|
'Log',
|
|
85
66
|
{ line: S.String },
|
|
86
67
|
)
|
|
87
68
|
export type LogMessage = typeof LogMessage.Type
|
|
88
69
|
|
|
89
|
-
/** The hook finished. `hostInput` carries the freshly ENCODED payload for `collect`
|
|
90
|
-
* (the engine caches it, memory-only) and is `null` for every other hook. */
|
|
91
70
|
export const HookDone: S.TaggedStruct<'HookDone', { hostInput: S.NullOr<typeof S.Unknown> }> =
|
|
92
71
|
S.TaggedStruct('HookDone', { hostInput: S.NullOr(S.Unknown) })
|
|
93
72
|
export type HookDone = typeof HookDone.Type
|
|
94
73
|
|
|
95
|
-
/** The hook (or the config module itself) failed — best-effort reporting, never retried. */
|
|
96
74
|
export const HookFailed: S.TaggedStruct<'HookFailed', { reason: typeof S.String }> =
|
|
97
75
|
S.TaggedStruct('HookFailed', { reason: S.String })
|
|
98
76
|
export type HookFailed = typeof HookFailed.Type
|
package/src/worker-entry.ts
CHANGED
|
@@ -18,19 +18,12 @@ import {
|
|
|
18
18
|
type UnitInfo,
|
|
19
19
|
} from './index'
|
|
20
20
|
|
|
21
|
-
// The per-invocation hook worker: serves exactly ONE `RunHook`, bridging the hook's
|
|
22
|
-
// `ctx.bash`/`ctx.host.bash` back to the engine over postMessage (the engine owns the
|
|
23
|
-
// actual pod-exec / host-spawn). When the hook settles, the worker reports `HookDone`
|
|
24
|
-
// (or `HookFailed`), drops its message listener so the event loop drains, and exits;
|
|
25
|
-
// the engine's scope finalizer additionally calls `worker.terminate()`.
|
|
26
|
-
|
|
27
21
|
declare const self: Worker
|
|
28
22
|
|
|
29
23
|
class WorkerHookError extends Data.TaggedError('WorkerHookError')<{
|
|
30
24
|
readonly reason: string
|
|
31
25
|
}> {
|
|
32
|
-
//
|
|
33
|
-
// hiding the actual failure from the HookFailed report (the reform-db errors gotcha).
|
|
26
|
+
// Cause.pretty would otherwise print the generic "An error has occurred".
|
|
34
27
|
override get message(): string {
|
|
35
28
|
return this.reason
|
|
36
29
|
}
|
|
@@ -43,8 +36,6 @@ const send = (message: WorkerToEngine): void => {
|
|
|
43
36
|
const describe = (cause: unknown): string =>
|
|
44
37
|
cause instanceof Error ? cause.message : String(cause)
|
|
45
38
|
|
|
46
|
-
// One in-flight resolver per bridged bash `id` — the engine answers each `Bash` with a
|
|
47
|
-
// correlated `BashReply`. Hooks run their bash calls through `Effect.async` below.
|
|
48
39
|
const pendingBash = new Map<number, (reply: BashReply) => void>()
|
|
49
40
|
|
|
50
41
|
const makeBridgedBash =
|
|
@@ -136,7 +127,7 @@ const runRequested = Effect.fn('runRequested')(
|
|
|
136
127
|
const host: HostBridge = { bash: makeBridgedBash(idRef, 'host'), env: Bun.env }
|
|
137
128
|
const unitBridge = { ...unit, bash: makeBridgedBash(idRef, 'unit') }
|
|
138
129
|
|
|
139
|
-
//
|
|
130
|
+
// Undefined consuming hooks are intentional silent no-ops.
|
|
140
131
|
const runOptional = <Ctx>(
|
|
141
132
|
hookName: string,
|
|
142
133
|
hook: ((ctx: Ctx) => Effect.Effect<void, unknown>) | undefined,
|
|
@@ -207,8 +198,7 @@ const handleRunHook = (request: RunHookRequest): void => {
|
|
|
207
198
|
Effect.catchAllCause((cause) =>
|
|
208
199
|
Effect.sync(() => send(HookFailed.make({ reason: Cause.pretty(cause) }))),
|
|
209
200
|
),
|
|
210
|
-
// Drop the listener so the event loop drains and the worker exits
|
|
211
|
-
// the engine's scope finalizer also terminates it (belt and suspenders).
|
|
201
|
+
// Drop the listener so the event loop drains and the worker exits.
|
|
212
202
|
Effect.ensuring(
|
|
213
203
|
Effect.sync(() => {
|
|
214
204
|
self.onmessage = null
|
|
@@ -1,35 +0,0 @@
|
|
|
1
|
-
import { Effect, Schema as S, String as Str } from 'effect'
|
|
2
|
-
import { defineConfig, type MuxmaxingConfig } from '@playfast/muxmaxing-config'
|
|
3
|
-
|
|
4
|
-
// The worker-entry test fixture — shaped like a real repo's `muxmaxing.config.ts`
|
|
5
|
-
// (imports the package by name, exercises host bash, unit bash, log, and hostInput).
|
|
6
|
-
// The explicit annotation is only for THIS package's `isolatedDeclarations` check —
|
|
7
|
-
// a real repo's config just default-exports `defineConfig({...})`.
|
|
8
|
-
|
|
9
|
-
interface SampleInput {
|
|
10
|
-
readonly token: string
|
|
11
|
-
readonly slug: string
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
const config: MuxmaxingConfig<SampleInput, SampleInput> = defineConfig({
|
|
15
|
-
hostInput: S.Struct({ token: S.String, slug: S.String }),
|
|
16
|
-
|
|
17
|
-
collectHostDeviceInput: ({ host, unit }) =>
|
|
18
|
-
Effect.gen(function* () {
|
|
19
|
-
const minted = yield* host.bash(`fake-mint ${unit.id}`)
|
|
20
|
-
return { token: Str.trim(minted.stdout), slug: `slug-${unit.id}` }
|
|
21
|
-
}),
|
|
22
|
-
|
|
23
|
-
onWorkUnitMachineCreated: (ctx) =>
|
|
24
|
-
Effect.gen(function* () {
|
|
25
|
-
yield* ctx.log('configuring')
|
|
26
|
-
const configured = yield* ctx.unit.bash(`configure ${ctx.hostInput.token}`)
|
|
27
|
-
if (configured.exitCode !== 0) {
|
|
28
|
-
return yield* Effect.fail(`configure exited ${configured.exitCode}`)
|
|
29
|
-
}
|
|
30
|
-
}),
|
|
31
|
-
|
|
32
|
-
onWorkUnitRemoved: (ctx) => Effect.asVoid(ctx.host.bash(`revoke ${ctx.hostInput.slug}`)),
|
|
33
|
-
})
|
|
34
|
-
|
|
35
|
-
export default config
|
package/src/worker-entry.test.ts
DELETED
|
@@ -1,159 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest'
|
|
2
|
-
import { Schema as S } from 'effect'
|
|
3
|
-
import { fileURLToPath } from 'node:url'
|
|
4
|
-
import {
|
|
5
|
-
BashReply,
|
|
6
|
-
EngineToWorker,
|
|
7
|
-
type HookName,
|
|
8
|
-
RunHookRequest,
|
|
9
|
-
WorkerToEngine,
|
|
10
|
-
} from './protocol'
|
|
11
|
-
|
|
12
|
-
// Drives the REAL worker entry end-to-end: spawn a Bun Worker on `worker-entry.ts`,
|
|
13
|
-
// point it at the fixture config (which imports the package by name, like a real repo),
|
|
14
|
-
// answer its bridged Bash requests with canned results, and assert the final message.
|
|
15
|
-
// Needs the Bun runtime (`bun --bun run vitest`) for the `Worker` global.
|
|
16
|
-
|
|
17
|
-
const workerUrl = new URL('./worker-entry.ts', import.meta.url)
|
|
18
|
-
// fileURLToPath so the SPACE in this repo's path survives (URL.pathname would %20 it).
|
|
19
|
-
const fixtureConfigPath = fileURLToPath(new URL('./fixtures/sample.config.ts', import.meta.url))
|
|
20
|
-
|
|
21
|
-
const encodeToWorker = S.encodeSync(EngineToWorker)
|
|
22
|
-
const decodeFromWorker = S.decodeUnknownSync(WorkerToEngine)
|
|
23
|
-
|
|
24
|
-
interface DrivenHook {
|
|
25
|
-
/** Every worker→engine message, in arrival order (Bash/Log/HookDone/HookFailed). */
|
|
26
|
-
readonly messages: ReadonlyArray<WorkerToEngine>
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
/** Run ONE hook through a fresh worker, answering each `Bash` via `answerBash`. */
|
|
30
|
-
const driveHook = (input: {
|
|
31
|
-
readonly hook: HookName
|
|
32
|
-
readonly hostInput: unknown
|
|
33
|
-
readonly answerBash: (request: { target: string; command: string }) => BashReply['result']
|
|
34
|
-
}): Promise<DrivenHook> =>
|
|
35
|
-
new Promise((resolve, reject) => {
|
|
36
|
-
const worker = new Worker(workerUrl)
|
|
37
|
-
const seen: Array<WorkerToEngine> = []
|
|
38
|
-
const finish = (outcome: DrivenHook): void => {
|
|
39
|
-
worker.terminate()
|
|
40
|
-
resolve(outcome)
|
|
41
|
-
}
|
|
42
|
-
const timer = setTimeout(() => {
|
|
43
|
-
worker.terminate()
|
|
44
|
-
reject(new Error(`hook ${input.hook} timed out; saw ${JSON.stringify(seen)}`))
|
|
45
|
-
}, 15_000)
|
|
46
|
-
worker.onmessage = (event: MessageEvent): void => {
|
|
47
|
-
const message = decodeFromWorker(event.data)
|
|
48
|
-
seen.push(message)
|
|
49
|
-
if (message._tag === 'Bash') {
|
|
50
|
-
worker.postMessage(
|
|
51
|
-
encodeToWorker(
|
|
52
|
-
BashReply.make({
|
|
53
|
-
id: message.id,
|
|
54
|
-
result: input.answerBash({ target: message.target, command: message.command }),
|
|
55
|
-
}),
|
|
56
|
-
),
|
|
57
|
-
)
|
|
58
|
-
return
|
|
59
|
-
}
|
|
60
|
-
if (message._tag === 'HookDone' || message._tag === 'HookFailed') {
|
|
61
|
-
clearTimeout(timer)
|
|
62
|
-
finish({ messages: seen })
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
worker.postMessage(
|
|
66
|
-
encodeToWorker(
|
|
67
|
-
RunHookRequest.make({
|
|
68
|
-
hook: input.hook,
|
|
69
|
-
configPath: fixtureConfigPath,
|
|
70
|
-
hostInput: input.hostInput,
|
|
71
|
-
unit: { id: 'unit-1', workspaceFolder: '/workspace/repo' },
|
|
72
|
-
}),
|
|
73
|
-
),
|
|
74
|
-
)
|
|
75
|
-
})
|
|
76
|
-
|
|
77
|
-
const okBash = (stdout: string): BashReply['result'] => ({
|
|
78
|
-
_tag: 'BashOk',
|
|
79
|
-
stdout,
|
|
80
|
-
stderr: '',
|
|
81
|
-
exitCode: 0,
|
|
82
|
-
})
|
|
83
|
-
|
|
84
|
-
describe('worker-entry', () => {
|
|
85
|
-
it('collect: runs collectHostDeviceInput on the host bridge and returns encoded hostInput', async () => {
|
|
86
|
-
const driven = await driveHook({
|
|
87
|
-
hook: 'collect',
|
|
88
|
-
hostInput: null,
|
|
89
|
-
answerBash: (request) => {
|
|
90
|
-
expect(request.target).toBe('host')
|
|
91
|
-
expect(request.command).toBe('fake-mint unit-1')
|
|
92
|
-
return okBash('tok-123\n')
|
|
93
|
-
},
|
|
94
|
-
})
|
|
95
|
-
const done = driven.messages.at(-1)
|
|
96
|
-
expect(done?._tag).toBe('HookDone')
|
|
97
|
-
if (done?._tag === 'HookDone') {
|
|
98
|
-
expect(done.hostInput).toEqual({ token: 'tok-123', slug: 'slug-unit-1' })
|
|
99
|
-
}
|
|
100
|
-
})
|
|
101
|
-
|
|
102
|
-
it('machineCreated: decodes hostInput, logs, and bashes into the unit', async () => {
|
|
103
|
-
const driven = await driveHook({
|
|
104
|
-
hook: 'machineCreated',
|
|
105
|
-
hostInput: { token: 'tok-123', slug: 'slug-unit-1' },
|
|
106
|
-
answerBash: (request) => {
|
|
107
|
-
expect(request.target).toBe('unit')
|
|
108
|
-
expect(request.command).toBe('configure tok-123')
|
|
109
|
-
return okBash('')
|
|
110
|
-
},
|
|
111
|
-
})
|
|
112
|
-
const tags = driven.messages.map((message) => message._tag)
|
|
113
|
-
expect(tags).toEqual(['Log', 'Bash', 'HookDone'])
|
|
114
|
-
})
|
|
115
|
-
|
|
116
|
-
it('newTerminal: a hook the config leaves undefined is a silent no-op success', async () => {
|
|
117
|
-
const driven = await driveHook({
|
|
118
|
-
hook: 'newTerminal',
|
|
119
|
-
hostInput: { token: 'tok-123', slug: 'slug-unit-1' },
|
|
120
|
-
answerBash: () => okBash(''),
|
|
121
|
-
})
|
|
122
|
-
expect(driven.messages.map((message) => message._tag)).toEqual(['HookDone'])
|
|
123
|
-
})
|
|
124
|
-
|
|
125
|
-
it('removed: cleanup runs on the HOST bridge with the cached hostInput', async () => {
|
|
126
|
-
const driven = await driveHook({
|
|
127
|
-
hook: 'removed',
|
|
128
|
-
hostInput: { token: 'tok-123', slug: 'slug-unit-1' },
|
|
129
|
-
answerBash: (request) => {
|
|
130
|
-
expect(request.target).toBe('host')
|
|
131
|
-
expect(request.command).toBe('revoke slug-unit-1')
|
|
132
|
-
return okBash('')
|
|
133
|
-
},
|
|
134
|
-
})
|
|
135
|
-
expect(driven.messages.at(-1)?._tag).toBe('HookDone')
|
|
136
|
-
})
|
|
137
|
-
|
|
138
|
-
it('malformed hostInput fails the hook (Schema is the gate), reported as HookFailed', async () => {
|
|
139
|
-
const driven = await driveHook({
|
|
140
|
-
hook: 'machineCreated',
|
|
141
|
-
hostInput: { token: 42 },
|
|
142
|
-
answerBash: () => okBash(''),
|
|
143
|
-
})
|
|
144
|
-
expect(driven.messages.at(-1)?._tag).toBe('HookFailed')
|
|
145
|
-
})
|
|
146
|
-
|
|
147
|
-
it('a failing unit bash surfaces the hook error as HookFailed', async () => {
|
|
148
|
-
const driven = await driveHook({
|
|
149
|
-
hook: 'machineCreated',
|
|
150
|
-
hostInput: { token: 'tok-123', slug: 'slug-unit-1' },
|
|
151
|
-
answerBash: () => ({ _tag: 'BashFailed', reason: 'pod unreachable' }),
|
|
152
|
-
})
|
|
153
|
-
const failed = driven.messages.at(-1)
|
|
154
|
-
expect(failed?._tag).toBe('HookFailed')
|
|
155
|
-
if (failed?._tag === 'HookFailed') {
|
|
156
|
-
expect(failed.reason).toContain('pod unreachable')
|
|
157
|
-
}
|
|
158
|
-
})
|
|
159
|
-
})
|