@rigkit/provider-cmux 0.1.8
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 +63 -0
- package/package.json +35 -0
- package/src/capabilities.ts +56 -0
- package/src/host.ts +301 -0
- package/src/index.test.ts +692 -0
- package/src/index.ts +752 -0
- package/src/provider.ts +103 -0
- package/src/version.ts +1 -0
package/README.md
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# @rigkit/provider-cmux
|
|
2
|
+
|
|
3
|
+
Small SDK and Rigkit provider facade for opening workspaces in local `cmux`.
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
import { createCmuxClient } from "@rigkit/provider-cmux";
|
|
7
|
+
|
|
8
|
+
const cmux = createCmuxClient();
|
|
9
|
+
|
|
10
|
+
await cmux.newWorkspace({
|
|
11
|
+
name: "playground",
|
|
12
|
+
command: "echo hello world",
|
|
13
|
+
focus: true,
|
|
14
|
+
});
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Commands are printed to stderr before execution:
|
|
18
|
+
|
|
19
|
+
```text
|
|
20
|
+
$ cmux new-workspace --name playground --command 'echo hello world' --focus true
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
`cmux new-workspace` and `cmux ssh` are socket commands. With cmux's default socket control mode (`cmuxOnly`), run rigkit from a terminal inside cmux so cmux sets `CMUX_SOCKET_PATH` and accepts the process.
|
|
24
|
+
|
|
25
|
+
If you intentionally enable external socket control in cmux, opt in explicitly:
|
|
26
|
+
|
|
27
|
+
```ts
|
|
28
|
+
const cmux = createCmuxClient({ allowExternalAutomation: true });
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
With `allowExternalAutomation`, the SDK can run `open -a cmux` and retry a workspace command while cmux starts.
|
|
32
|
+
|
|
33
|
+
Config-defined operations can request the typed `cmux.open` host capability through the provider facade:
|
|
34
|
+
|
|
35
|
+
```ts
|
|
36
|
+
import { workflow } from "@rigkit/sdk";
|
|
37
|
+
import { cmux } from "@rigkit/provider-cmux";
|
|
38
|
+
|
|
39
|
+
export default workflow("site", {
|
|
40
|
+
providers: {
|
|
41
|
+
cmux: cmux.provider(),
|
|
42
|
+
},
|
|
43
|
+
})
|
|
44
|
+
.sequence("site")
|
|
45
|
+
.operation("open", {
|
|
46
|
+
requiredHostCapabilities: [cmux.capabilities.open],
|
|
47
|
+
run: async ({ providers }) => {
|
|
48
|
+
await providers.cmux.open({
|
|
49
|
+
name: "site",
|
|
50
|
+
ssh: {
|
|
51
|
+
host: "vm-ssh.freestyle.sh",
|
|
52
|
+
username: "vm_123",
|
|
53
|
+
auth: { type: "token", token: "token_123" },
|
|
54
|
+
},
|
|
55
|
+
cwd: "/workspace/site",
|
|
56
|
+
command: "pnpm dev",
|
|
57
|
+
url: "http://localhost:3000",
|
|
58
|
+
});
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Local hosts can import `@rigkit/provider-cmux/host` to register the trusted `cmux.open` handler. The Rigkit CLI registers this handler automatically.
|
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@rigkit/provider-cmux",
|
|
3
|
+
"version": "0.1.8",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/freestyle-sh/rigkit.git",
|
|
8
|
+
"directory": "packages/provider-cmux"
|
|
9
|
+
},
|
|
10
|
+
"exports": {
|
|
11
|
+
".": "./src/index.ts",
|
|
12
|
+
"./host": "./src/host.ts",
|
|
13
|
+
"./package.json": "./package.json"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"src",
|
|
17
|
+
"README.md"
|
|
18
|
+
],
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"@rigkit/engine": "0.1.8",
|
|
21
|
+
"@rigkit/sdk": "0.1.8"
|
|
22
|
+
},
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"@types/bun": "latest",
|
|
25
|
+
"typescript": "latest"
|
|
26
|
+
},
|
|
27
|
+
"publishConfig": {
|
|
28
|
+
"access": "public"
|
|
29
|
+
},
|
|
30
|
+
"scripts": {
|
|
31
|
+
"build": "tsc --noEmit",
|
|
32
|
+
"typecheck": "tsc --noEmit",
|
|
33
|
+
"test": "bun test"
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
export const CMUX_OPEN_CAPABILITY_ID = "cmux.open";
|
|
2
|
+
|
|
3
|
+
export const CMUX_OPEN_SCHEMA_HASH =
|
|
4
|
+
"sha256:ed7f74b4fd1101ff87281c0269f9884f85783098d9a727fdfe05491efba2dd28";
|
|
5
|
+
|
|
6
|
+
export const CMUX_OPEN_CAPABILITY = {
|
|
7
|
+
id: CMUX_OPEN_CAPABILITY_ID,
|
|
8
|
+
schemaHash: CMUX_OPEN_SCHEMA_HASH,
|
|
9
|
+
} as const;
|
|
10
|
+
|
|
11
|
+
export type CmuxOpenSshInput = string | {
|
|
12
|
+
kind?: "ssh";
|
|
13
|
+
destination?: string;
|
|
14
|
+
host?: string;
|
|
15
|
+
port?: number;
|
|
16
|
+
username?: string;
|
|
17
|
+
auth?: { type: "token"; token: string } | { type: "privateKey"; privateKey: string };
|
|
18
|
+
command?: string;
|
|
19
|
+
identity?: string;
|
|
20
|
+
sshOptions?: readonly string[];
|
|
21
|
+
remoteCommandArgs?: readonly string[];
|
|
22
|
+
initialCommand?: string;
|
|
23
|
+
terminalStartupCommand?: string;
|
|
24
|
+
autoConnect?: boolean;
|
|
25
|
+
skipDaemonBootstrap?: boolean;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export type CmuxRemoteReadyOptions = {
|
|
29
|
+
timeoutMs?: number;
|
|
30
|
+
intervalMs?: number;
|
|
31
|
+
requireProxy?: boolean;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export type CmuxOpenInput = {
|
|
35
|
+
name: string;
|
|
36
|
+
ssh?: CmuxOpenSshInput;
|
|
37
|
+
cwd?: string;
|
|
38
|
+
command?: string;
|
|
39
|
+
url?: string;
|
|
40
|
+
focus?: boolean;
|
|
41
|
+
waitForRemoteReady?: boolean | CmuxRemoteReadyOptions;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export type CmuxOpenResult = {
|
|
45
|
+
sessionId: string;
|
|
46
|
+
workspaceId: string;
|
|
47
|
+
workspaceRef?: string;
|
|
48
|
+
terminalPaneId?: string;
|
|
49
|
+
terminalSurfaceId?: string;
|
|
50
|
+
browserPaneId?: string;
|
|
51
|
+
browserSurfaceId?: string;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export type CmuxOpenSession = CmuxOpenResult & {
|
|
55
|
+
closed: Promise<void>;
|
|
56
|
+
};
|
package/src/host.ts
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createCmuxClient,
|
|
3
|
+
formatShellCommand,
|
|
4
|
+
type CmuxBrowserOpenOptions,
|
|
5
|
+
type CmuxClient,
|
|
6
|
+
type CmuxClientOptions,
|
|
7
|
+
type CmuxNewPaneOptions,
|
|
8
|
+
type CmuxNewWorkspaceOptions,
|
|
9
|
+
type CmuxPane,
|
|
10
|
+
type CmuxPortsKickOptions,
|
|
11
|
+
type CmuxSendOptions,
|
|
12
|
+
type CmuxSshOptions,
|
|
13
|
+
type CmuxWaitForRemoteOptions,
|
|
14
|
+
type CmuxWorkspace,
|
|
15
|
+
} from "./index.ts";
|
|
16
|
+
import { defineHostCapability, type HostCapabilityHandler } from "@rigkit/sdk/host";
|
|
17
|
+
import {
|
|
18
|
+
CMUX_OPEN_CAPABILITY,
|
|
19
|
+
type CmuxOpenInput,
|
|
20
|
+
type CmuxOpenResult,
|
|
21
|
+
type CmuxOpenSshInput,
|
|
22
|
+
type CmuxRemoteReadyOptions,
|
|
23
|
+
} from "./capabilities.ts";
|
|
24
|
+
|
|
25
|
+
export type CmuxHostCapabilityHandler = HostCapabilityHandler;
|
|
26
|
+
|
|
27
|
+
export type CmuxOpenClient = Pick<
|
|
28
|
+
CmuxClient,
|
|
29
|
+
| "newWorkspace"
|
|
30
|
+
| "ssh"
|
|
31
|
+
| "newPane"
|
|
32
|
+
| "send"
|
|
33
|
+
| "portsKick"
|
|
34
|
+
| "browserOpen"
|
|
35
|
+
| "selectWorkspace"
|
|
36
|
+
| "waitForRemoteReady"
|
|
37
|
+
>;
|
|
38
|
+
|
|
39
|
+
export type CmuxOpenHostOptions = {
|
|
40
|
+
client?: CmuxOpenClient;
|
|
41
|
+
clientOptions?: CmuxClientOptions;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const freestyleTokenSshOptions = [
|
|
45
|
+
"StrictHostKeyChecking=no",
|
|
46
|
+
"UserKnownHostsFile=/dev/null",
|
|
47
|
+
"LogLevel=ERROR",
|
|
48
|
+
"IdentitiesOnly=yes",
|
|
49
|
+
"IdentityFile=/dev/null",
|
|
50
|
+
"ControlMaster=no",
|
|
51
|
+
] as const;
|
|
52
|
+
|
|
53
|
+
export function createCmuxOpenHostCapability(
|
|
54
|
+
options: CmuxOpenHostOptions = {},
|
|
55
|
+
): CmuxHostCapabilityHandler {
|
|
56
|
+
return defineHostCapability(CMUX_OPEN_CAPABILITY.id, {
|
|
57
|
+
schemaHash: CMUX_OPEN_CAPABILITY.schemaHash,
|
|
58
|
+
handle: async (params) => await openCmux(params, options),
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export const cmuxHostCapabilities = [createCmuxOpenHostCapability()] as const;
|
|
63
|
+
|
|
64
|
+
export async function openCmux(
|
|
65
|
+
params: unknown,
|
|
66
|
+
options: CmuxOpenHostOptions = {},
|
|
67
|
+
): Promise<CmuxOpenResult> {
|
|
68
|
+
const input = parseCmuxOpenInput(params);
|
|
69
|
+
const cmux = options.client ?? createCmuxClient(options.clientOptions);
|
|
70
|
+
const command = commandForInput(input);
|
|
71
|
+
let workspace: CmuxWorkspace;
|
|
72
|
+
let terminalPane: CmuxPane | undefined;
|
|
73
|
+
|
|
74
|
+
if (input.ssh) {
|
|
75
|
+
workspace = await cmux.ssh({
|
|
76
|
+
...cmuxSshOptionsForInput(input.ssh),
|
|
77
|
+
name: input.name,
|
|
78
|
+
noFocus: input.focus === false,
|
|
79
|
+
});
|
|
80
|
+
} else {
|
|
81
|
+
const workspaceOptions: CmuxNewWorkspaceOptions = {
|
|
82
|
+
name: input.name,
|
|
83
|
+
cwd: input.cwd,
|
|
84
|
+
command: input.command,
|
|
85
|
+
focus: input.focus,
|
|
86
|
+
};
|
|
87
|
+
workspace = await cmux.newWorkspace(workspaceOptions);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const workspaceId = workspace.id ?? workspace.handle;
|
|
91
|
+
|
|
92
|
+
if (input.ssh && command) {
|
|
93
|
+
const paneOptions: CmuxNewPaneOptions = {
|
|
94
|
+
workspace: workspaceId,
|
|
95
|
+
type: "terminal",
|
|
96
|
+
direction: "down",
|
|
97
|
+
focus: true,
|
|
98
|
+
};
|
|
99
|
+
terminalPane = await cmux.newPane(paneOptions);
|
|
100
|
+
const sendOptions: CmuxSendOptions = {
|
|
101
|
+
workspace: workspaceId,
|
|
102
|
+
surface: terminalPane.surface,
|
|
103
|
+
text: command,
|
|
104
|
+
};
|
|
105
|
+
await cmux.send(sendOptions);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const waitOptions = remoteReadyOptionsForInput(input);
|
|
109
|
+
if (input.ssh && waitOptions) {
|
|
110
|
+
await cmux.waitForRemoteReady(workspaceId, waitOptions);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (input.ssh && terminalPane?.surface) {
|
|
114
|
+
const kickOptions: CmuxPortsKickOptions = {
|
|
115
|
+
workspace: workspaceId,
|
|
116
|
+
surface: terminalPane.surface,
|
|
117
|
+
reason: "command",
|
|
118
|
+
};
|
|
119
|
+
await cmux.portsKick(kickOptions);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
let browserPane: CmuxPane | undefined;
|
|
123
|
+
if (input.url) {
|
|
124
|
+
const browserOptions: CmuxBrowserOpenOptions = {
|
|
125
|
+
workspace: workspaceId,
|
|
126
|
+
url: input.url,
|
|
127
|
+
focus: input.focus !== false,
|
|
128
|
+
};
|
|
129
|
+
browserPane = await cmux.browserOpen(browserOptions);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (input.focus !== false) {
|
|
133
|
+
await cmux.selectWorkspace(workspaceId);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
sessionId: workspaceId,
|
|
138
|
+
workspaceId,
|
|
139
|
+
...(workspace.ref ? { workspaceRef: workspace.ref } : {}),
|
|
140
|
+
...(terminalPane?.pane ? { terminalPaneId: terminalPane.pane } : {}),
|
|
141
|
+
...(terminalPane?.surface ? { terminalSurfaceId: terminalPane.surface } : {}),
|
|
142
|
+
...(browserPane?.pane ? { browserPaneId: browserPane.pane } : {}),
|
|
143
|
+
...(browserPane?.surface ? { browserSurfaceId: browserPane.surface } : {}),
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function parseCmuxOpenInput(value: unknown): CmuxOpenInput {
|
|
148
|
+
if (!isRecord(value)) throw new Error(`cmux.open requires an object input`);
|
|
149
|
+
const name = requiredString(value, "name");
|
|
150
|
+
return {
|
|
151
|
+
name,
|
|
152
|
+
...(value.ssh !== undefined ? { ssh: parseSshInput(value.ssh) } : {}),
|
|
153
|
+
...optionalStringField(value, "cwd"),
|
|
154
|
+
...optionalStringField(value, "command"),
|
|
155
|
+
...optionalStringField(value, "url"),
|
|
156
|
+
...optionalBooleanField(value, "focus"),
|
|
157
|
+
...(value.waitForRemoteReady !== undefined
|
|
158
|
+
? { waitForRemoteReady: parseRemoteReadyOptions(value.waitForRemoteReady) }
|
|
159
|
+
: {}),
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function parseSshInput(value: unknown): CmuxOpenSshInput {
|
|
164
|
+
if (typeof value === "string" && value.trim()) return value.trim();
|
|
165
|
+
if (!isRecord(value)) throw new Error(`cmux.open ssh must be a string or object`);
|
|
166
|
+
if (value.kind !== undefined && value.kind !== "ssh") {
|
|
167
|
+
throw new Error(`cmux.open ssh.kind must be "ssh"`);
|
|
168
|
+
}
|
|
169
|
+
return {
|
|
170
|
+
...optionalStringField(value, "destination"),
|
|
171
|
+
...optionalStringField(value, "host"),
|
|
172
|
+
...optionalNumberField(value, "port"),
|
|
173
|
+
...optionalStringField(value, "username"),
|
|
174
|
+
...(value.auth !== undefined ? { auth: parseSshAuth(value.auth) } : {}),
|
|
175
|
+
...optionalStringField(value, "command"),
|
|
176
|
+
...optionalStringField(value, "identity"),
|
|
177
|
+
...optionalStringArrayField(value, "sshOptions"),
|
|
178
|
+
...optionalStringArrayField(value, "remoteCommandArgs"),
|
|
179
|
+
...optionalStringField(value, "initialCommand"),
|
|
180
|
+
...optionalStringField(value, "terminalStartupCommand"),
|
|
181
|
+
...optionalBooleanField(value, "autoConnect"),
|
|
182
|
+
...optionalBooleanField(value, "skipDaemonBootstrap"),
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function parseSshAuth(value: unknown): Exclude<Extract<CmuxOpenSshInput, object>["auth"], undefined> {
|
|
187
|
+
if (!isRecord(value)) throw new Error(`cmux.open ssh.auth must be an object`);
|
|
188
|
+
if (value.type === "token") {
|
|
189
|
+
return { type: "token", token: requiredString(value, "token") };
|
|
190
|
+
}
|
|
191
|
+
if (value.type === "privateKey") {
|
|
192
|
+
return { type: "privateKey", privateKey: requiredString(value, "privateKey") };
|
|
193
|
+
}
|
|
194
|
+
throw new Error(`cmux.open ssh.auth.type must be "token" or "privateKey"`);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function parseRemoteReadyOptions(value: unknown): boolean | CmuxRemoteReadyOptions {
|
|
198
|
+
if (typeof value === "boolean") return value;
|
|
199
|
+
if (!isRecord(value)) throw new Error(`cmux.open waitForRemoteReady must be a boolean or object`);
|
|
200
|
+
return {
|
|
201
|
+
...optionalNumberField(value, "timeoutMs"),
|
|
202
|
+
...optionalNumberField(value, "intervalMs"),
|
|
203
|
+
...optionalBooleanField(value, "requireProxy"),
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function cmuxSshOptionsForInput(ssh: CmuxOpenSshInput): CmuxSshOptions {
|
|
208
|
+
if (typeof ssh === "string") return { destination: ssh };
|
|
209
|
+
|
|
210
|
+
const destination = ssh.destination ?? sshDestination(ssh);
|
|
211
|
+
const sshOptions = [
|
|
212
|
+
...(ssh.auth?.type === "token" ? freestyleTokenSshOptions : []),
|
|
213
|
+
...(ssh.sshOptions ?? []),
|
|
214
|
+
];
|
|
215
|
+
return {
|
|
216
|
+
destination,
|
|
217
|
+
port: ssh.port,
|
|
218
|
+
identity: ssh.identity,
|
|
219
|
+
sshOptions,
|
|
220
|
+
remoteCommandArgs: ssh.remoteCommandArgs,
|
|
221
|
+
initialCommand: ssh.initialCommand,
|
|
222
|
+
terminalStartupCommand: ssh.terminalStartupCommand ?? ssh.command,
|
|
223
|
+
autoConnect: ssh.autoConnect,
|
|
224
|
+
skipDaemonBootstrap: ssh.skipDaemonBootstrap,
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function sshDestination(ssh: Extract<CmuxOpenSshInput, object>): string {
|
|
229
|
+
if (!ssh.host) throw new Error(`cmux.open ssh.host is required when ssh.destination is omitted`);
|
|
230
|
+
if (!ssh.username) throw new Error(`cmux.open ssh.username is required when ssh.destination is omitted`);
|
|
231
|
+
if (ssh.auth?.type === "token") return `${ssh.username},${ssh.auth.token}@${ssh.host}`;
|
|
232
|
+
return `${ssh.username}@${ssh.host}`;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function commandForInput(input: CmuxOpenInput): string | undefined {
|
|
236
|
+
if (!input.command) return undefined;
|
|
237
|
+
const prefix = input.cwd ? `${formatShellCommand(["cd", input.cwd])} && ` : "";
|
|
238
|
+
return `${prefix}${input.command}\n`;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function remoteReadyOptionsForInput(input: CmuxOpenInput): CmuxWaitForRemoteOptions | false {
|
|
242
|
+
if (input.waitForRemoteReady === false) return false;
|
|
243
|
+
if (input.waitForRemoteReady === true || input.waitForRemoteReady === undefined) {
|
|
244
|
+
return input.url ? {} : false;
|
|
245
|
+
}
|
|
246
|
+
return input.waitForRemoteReady;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function requiredString(record: Record<string, unknown>, key: string): string {
|
|
250
|
+
const value = record[key];
|
|
251
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
252
|
+
throw new Error(`cmux.open ${key} must be a non-empty string`);
|
|
253
|
+
}
|
|
254
|
+
return value.trim();
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function optionalStringField(
|
|
258
|
+
record: Record<string, unknown>,
|
|
259
|
+
key: string,
|
|
260
|
+
expected?: string,
|
|
261
|
+
): Record<string, string> {
|
|
262
|
+
const value = record[key];
|
|
263
|
+
if (value === undefined) return {};
|
|
264
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
265
|
+
throw new Error(`cmux.open ${key} must be a non-empty string`);
|
|
266
|
+
}
|
|
267
|
+
const normalized = value.trim();
|
|
268
|
+
if (expected !== undefined && normalized !== expected) {
|
|
269
|
+
throw new Error(`cmux.open ${key} must be "${expected}"`);
|
|
270
|
+
}
|
|
271
|
+
return { [key]: normalized };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function optionalStringArrayField(record: Record<string, unknown>, key: string): Record<string, string[]> {
|
|
275
|
+
const value = record[key];
|
|
276
|
+
if (value === undefined) return {};
|
|
277
|
+
if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) {
|
|
278
|
+
throw new Error(`cmux.open ${key} must be an array of strings`);
|
|
279
|
+
}
|
|
280
|
+
return { [key]: value };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function optionalNumberField(record: Record<string, unknown>, key: string): Record<string, number> {
|
|
284
|
+
const value = record[key];
|
|
285
|
+
if (value === undefined) return {};
|
|
286
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
287
|
+
throw new Error(`cmux.open ${key} must be a finite number`);
|
|
288
|
+
}
|
|
289
|
+
return { [key]: value };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function optionalBooleanField(record: Record<string, unknown>, key: string): Record<string, boolean> {
|
|
293
|
+
const value = record[key];
|
|
294
|
+
if (value === undefined) return {};
|
|
295
|
+
if (typeof value !== "boolean") throw new Error(`cmux.open ${key} must be a boolean`);
|
|
296
|
+
return { [key]: value };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
300
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
301
|
+
}
|