@hunsu/config 0.1.0
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/LICENSE +157 -0
- package/dist/cloudflare.d.ts +47 -0
- package/dist/cloudflare.js +152 -0
- package/dist/index.d.ts +127 -0
- package/dist/index.js +408 -0
- package/package.json +36 -0
- package/src/cloudflare.ts +233 -0
- package/src/index.ts +611 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,611 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
|
+
|
|
4
|
+
export type ConfigResult<T> =
|
|
5
|
+
| { ok: true; value: T }
|
|
6
|
+
| { ok: false; error: HunsuConfigError };
|
|
7
|
+
|
|
8
|
+
export type HunsuConfigError = {
|
|
9
|
+
code: "invalid_port" | "invalid_boolean" | "invalid_url" | "invalid_path" | "invalid_enum" | "invalid_json" | "missing_required_env" | "port_conflict";
|
|
10
|
+
message: string;
|
|
11
|
+
env?: string;
|
|
12
|
+
portName?: HunsuPortName;
|
|
13
|
+
port?: number;
|
|
14
|
+
value?: string;
|
|
15
|
+
path?: string;
|
|
16
|
+
allowed?: readonly string[];
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type HunsuPortName = "localApi" | "studioWeb" | "agentPreview";
|
|
20
|
+
|
|
21
|
+
export type HunsuEndpoint = {
|
|
22
|
+
name: HunsuPortName;
|
|
23
|
+
host: string;
|
|
24
|
+
hostSource: ConfigValueSource;
|
|
25
|
+
hostEnv?: string;
|
|
26
|
+
port: number;
|
|
27
|
+
portSource: ConfigValueSource;
|
|
28
|
+
portEnv?: string;
|
|
29
|
+
reserved: boolean;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export type ConfigValueSource = "default" | "env" | "override";
|
|
33
|
+
|
|
34
|
+
export type Env = Record<string, string | undefined>;
|
|
35
|
+
|
|
36
|
+
export type HunsuPortConfig = Record<HunsuPortName, HunsuEndpoint>;
|
|
37
|
+
|
|
38
|
+
export type HunsuPortOverrides = Partial<Record<HunsuPortName, {
|
|
39
|
+
host?: string;
|
|
40
|
+
port?: number;
|
|
41
|
+
}>>;
|
|
42
|
+
|
|
43
|
+
export type HunsuPortResolveOptions = {
|
|
44
|
+
overrides?: HunsuPortOverrides;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export type HunsuPortValidationOptions = {
|
|
48
|
+
activePortNames: readonly HunsuPortName[];
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export type StudioWebServerConfig = {
|
|
52
|
+
web: HunsuEndpoint;
|
|
53
|
+
localApi: HunsuEndpoint;
|
|
54
|
+
strictPort: boolean;
|
|
55
|
+
apiProxyTarget: string;
|
|
56
|
+
browserLocalUrl?: string;
|
|
57
|
+
browserHubApiUrl?: string;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export type StudioLauncherConfig = {
|
|
61
|
+
webUrl: string;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export type LocalApiServerConfig = {
|
|
65
|
+
localApi: HunsuEndpoint;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export type CodexSandboxMode = "read-only" | "workspace-write" | "danger-full-access";
|
|
69
|
+
export type CodexApprovalPolicy = "never" | "on-request" | "on-failure" | "untrusted";
|
|
70
|
+
export type CodexApprovalsReviewer = "user" | "auto_review";
|
|
71
|
+
|
|
72
|
+
export type CodexThreadOptionsConfig = {
|
|
73
|
+
sandboxMode?: CodexSandboxMode;
|
|
74
|
+
approvalPolicy?: CodexApprovalPolicy;
|
|
75
|
+
approvalsReviewer?: CodexApprovalsReviewer;
|
|
76
|
+
networkAccessEnabled?: boolean;
|
|
77
|
+
additionalDirectories?: string[];
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
export type CodexAppServerConfig = {
|
|
81
|
+
command: string;
|
|
82
|
+
args: string[];
|
|
83
|
+
environment: Record<string, string>;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
export type LocalRuntimeConfig = {
|
|
87
|
+
localApi: HunsuEndpoint;
|
|
88
|
+
processEnv: Record<string, string>;
|
|
89
|
+
roadmapRegistryPath: string;
|
|
90
|
+
routeWorktreeRoot?: string;
|
|
91
|
+
actionWorktreeRoot?: string;
|
|
92
|
+
testRunner?: "deterministic";
|
|
93
|
+
codexAppServer: CodexAppServerConfig;
|
|
94
|
+
codexThreadOptions: CodexThreadOptionsConfig;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
export type LocalRuntimeConfigOptions = {
|
|
98
|
+
cwd?: string;
|
|
99
|
+
homeDir?: string;
|
|
100
|
+
processEnv?: Env;
|
|
101
|
+
roadmapRegistryPath?: string;
|
|
102
|
+
routeWorktreeRoot?: string;
|
|
103
|
+
actionWorktreeRoot?: string;
|
|
104
|
+
codexAppServer?: Partial<Omit<CodexAppServerConfig, "environment">> & { environment?: Env };
|
|
105
|
+
codexThreadOptions?: CodexThreadOptionsConfig;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
type PortSpec = {
|
|
109
|
+
name: HunsuPortName;
|
|
110
|
+
defaultHost: string;
|
|
111
|
+
hostEnv: readonly string[];
|
|
112
|
+
defaultPort: number;
|
|
113
|
+
portEnv: readonly string[];
|
|
114
|
+
reserved: boolean;
|
|
115
|
+
allowPortZero: boolean;
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const DEFAULT_HOST = "127.0.0.1";
|
|
119
|
+
const DEFAULT_LOCAL_HUB_API_URL = "http://127.0.0.1:8787";
|
|
120
|
+
const DEFAULT_STUDIO_WEB_URL = "https://hunsu.app/studio";
|
|
121
|
+
|
|
122
|
+
export const HUNSU_PORT_SPECS: Record<HunsuPortName, PortSpec> = {
|
|
123
|
+
localApi: {
|
|
124
|
+
name: "localApi",
|
|
125
|
+
defaultHost: DEFAULT_HOST,
|
|
126
|
+
hostEnv: ["HUNSU_LOCAL_HOST", "HOST"],
|
|
127
|
+
defaultPort: 19687,
|
|
128
|
+
portEnv: ["HUNSU_LOCAL_PORT", "PORT"],
|
|
129
|
+
reserved: false,
|
|
130
|
+
allowPortZero: false
|
|
131
|
+
},
|
|
132
|
+
studioWeb: {
|
|
133
|
+
name: "studioWeb",
|
|
134
|
+
defaultHost: DEFAULT_HOST,
|
|
135
|
+
hostEnv: ["HUNSU_WEB_HOST"],
|
|
136
|
+
defaultPort: 19688,
|
|
137
|
+
portEnv: ["HUNSU_WEB_PORT"],
|
|
138
|
+
reserved: false,
|
|
139
|
+
allowPortZero: false
|
|
140
|
+
},
|
|
141
|
+
agentPreview: {
|
|
142
|
+
name: "agentPreview",
|
|
143
|
+
defaultHost: DEFAULT_HOST,
|
|
144
|
+
hostEnv: ["HUNSU_AGENT_PREVIEW_HOST"],
|
|
145
|
+
defaultPort: 19673,
|
|
146
|
+
portEnv: ["HUNSU_AGENT_PREVIEW_PORT"],
|
|
147
|
+
reserved: true,
|
|
148
|
+
allowPortZero: false
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
export const HUNSU_ACTIVE_SERVICE_PORTS = ["localApi", "studioWeb"] as const satisfies readonly HunsuPortName[];
|
|
153
|
+
export const HUNSU_STUDIO_WEB_ACTIVE_PORTS = ["localApi", "studioWeb"] as const satisfies readonly HunsuPortName[];
|
|
154
|
+
const CODEX_SANDBOX_MODES = ["read-only", "workspace-write", "danger-full-access"] as const;
|
|
155
|
+
const CODEX_APPROVAL_POLICIES = ["never", "on-request", "on-failure", "untrusted"] as const;
|
|
156
|
+
const CODEX_APPROVALS_REVIEWERS = ["user", "auto_review"] as const;
|
|
157
|
+
const LOCAL_TEST_RUNNERS = ["deterministic"] as const;
|
|
158
|
+
|
|
159
|
+
export function ok<T>(value: T): ConfigResult<T> {
|
|
160
|
+
return { ok: true, value };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function err(error: HunsuConfigError): ConfigResult<never> {
|
|
164
|
+
return { ok: false, error };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function mapConfigResult<T, U>(result: ConfigResult<T>, transform: (value: T) => U): ConfigResult<U> {
|
|
168
|
+
return result.ok ? ok(transform(result.value)) : result;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function flatMapConfigResult<T, U>(result: ConfigResult<T>, transform: (value: T) => ConfigResult<U>): ConfigResult<U> {
|
|
172
|
+
return result.ok ? transform(result.value) : result;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function unwrapConfigResult<T>(result: ConfigResult<T>): T {
|
|
176
|
+
if (result.ok) {
|
|
177
|
+
return result.value;
|
|
178
|
+
}
|
|
179
|
+
throw new Error(result.error.message);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function resolveHunsuPorts(env: Env, options: HunsuPortResolveOptions = {}): ConfigResult<HunsuPortConfig> {
|
|
183
|
+
const localApi = resolveEndpoint(HUNSU_PORT_SPECS.localApi, env, options.overrides?.localApi);
|
|
184
|
+
if (!localApi.ok) {
|
|
185
|
+
return localApi;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const studioWeb = resolveEndpoint(HUNSU_PORT_SPECS.studioWeb, env, options.overrides?.studioWeb);
|
|
189
|
+
if (!studioWeb.ok) {
|
|
190
|
+
return studioWeb;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const agentPreview = resolveEndpoint(HUNSU_PORT_SPECS.agentPreview, env, options.overrides?.agentPreview);
|
|
194
|
+
if (!agentPreview.ok) {
|
|
195
|
+
return agentPreview;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return ok({
|
|
199
|
+
localApi: localApi.value,
|
|
200
|
+
studioWeb: studioWeb.value,
|
|
201
|
+
agentPreview: agentPreview.value
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function resolveValidatedHunsuPorts(env: Env, options: HunsuPortResolveOptions & HunsuPortValidationOptions): ConfigResult<HunsuPortConfig> {
|
|
206
|
+
return flatMapConfigResult(resolveHunsuPorts(env, options), config => validateNoPortConflicts(config, options.activePortNames));
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function resolveStudioWebServerConfig(env: Env): ConfigResult<StudioWebServerConfig> {
|
|
210
|
+
return flatMapConfigResult(
|
|
211
|
+
resolveValidatedHunsuPorts(env, { activePortNames: HUNSU_STUDIO_WEB_ACTIVE_PORTS }),
|
|
212
|
+
ports => flatMapConfigResult(readConfigBoolean(env, "HUNSU_WEB_STRICT_PORT", true), strictPort =>
|
|
213
|
+
flatMapConfigResult(readOptionalUrl(env, "VITE_HUNSU_LOCAL_URL"), browserLocalUrl =>
|
|
214
|
+
flatMapConfigResult(readFirstOptionalUrl(env, ["VITE_HUNSU_HUB_API_URL", "HUNSU_HUB_PUBLIC_API_URL"], defaultBrowserHubApiUrl(env)), browserHubApiUrl =>
|
|
215
|
+
mapConfigResult(readFirstUrl(env, ["HUNSU_LOCAL_API_PROXY_TARGET"], browserLocalUrl ?? endpointUrl(ports.localApi)), apiProxyTarget => ({
|
|
216
|
+
web: ports.studioWeb,
|
|
217
|
+
localApi: ports.localApi,
|
|
218
|
+
strictPort,
|
|
219
|
+
apiProxyTarget,
|
|
220
|
+
browserLocalUrl,
|
|
221
|
+
browserHubApiUrl
|
|
222
|
+
}))
|
|
223
|
+
)
|
|
224
|
+
)
|
|
225
|
+
)
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export function resolveStudioLauncherConfig(env: Env): ConfigResult<StudioLauncherConfig> {
|
|
230
|
+
return mapConfigResult(readConfigUrl(env, "HUNSU_WEB_URL", DEFAULT_STUDIO_WEB_URL), webUrl => ({ webUrl }));
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export function currentProcessEnv(): Env {
|
|
234
|
+
return process.env;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function defaultBrowserHubApiUrl(env: Env): string | undefined {
|
|
238
|
+
const target = firstNonEmpty(env, ["HUNSU_DEPLOY_TARGET"])?.value;
|
|
239
|
+
if (target === undefined || target === "local") {
|
|
240
|
+
return DEFAULT_LOCAL_HUB_API_URL;
|
|
241
|
+
}
|
|
242
|
+
return undefined;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export function resolveLocalApiServerConfig(env: Env): ConfigResult<LocalApiServerConfig> {
|
|
246
|
+
return mapConfigResult(
|
|
247
|
+
resolveValidatedHunsuPorts(env, { activePortNames: ["localApi"] }),
|
|
248
|
+
ports => ({ localApi: ports.localApi })
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export function resolveLocalRuntimeConfig(env: Env, options: LocalRuntimeConfigOptions = {}): ConfigResult<LocalRuntimeConfig> {
|
|
253
|
+
const processEnv = filterEnv(options.processEnv ?? env);
|
|
254
|
+
return flatMapConfigResult(resolveLocalApiServerConfig(env), ({ localApi }) =>
|
|
255
|
+
flatMapConfigResult(resolveRequiredConfiguredPath(
|
|
256
|
+
options.roadmapRegistryPath,
|
|
257
|
+
env,
|
|
258
|
+
"HUNSU_ROADMAP_REGISTRY_PATH",
|
|
259
|
+
join(options.homeDir ?? homedir(), ".config", "hunsu", "roadmaps.json"),
|
|
260
|
+
options.cwd
|
|
261
|
+
), roadmapRegistryPath =>
|
|
262
|
+
flatMapConfigResult(resolveConfiguredPath(options.routeWorktreeRoot, env, "HUNSU_ROUTE_WORKTREE_ROOT", undefined, options.cwd), routeWorktreeRoot =>
|
|
263
|
+
flatMapConfigResult(resolveConfiguredPath(options.actionWorktreeRoot, env, "HUNSU_ACTION_WORKTREE_ROOT", undefined, options.cwd), actionWorktreeRoot =>
|
|
264
|
+
flatMapConfigResult(readOptionalEnum(env, "HUNSU_LOCAL_TEST_RUNNER", LOCAL_TEST_RUNNERS), testRunner =>
|
|
265
|
+
flatMapConfigResult(resolveCodexAppServerConfig(env, options.codexAppServer), codexAppServer =>
|
|
266
|
+
mapConfigResult(resolveCodexThreadOptions(env, options.codexThreadOptions), codexThreadOptions => ({
|
|
267
|
+
localApi,
|
|
268
|
+
processEnv,
|
|
269
|
+
roadmapRegistryPath,
|
|
270
|
+
routeWorktreeRoot,
|
|
271
|
+
actionWorktreeRoot,
|
|
272
|
+
testRunner,
|
|
273
|
+
codexAppServer,
|
|
274
|
+
codexThreadOptions
|
|
275
|
+
}))
|
|
276
|
+
)
|
|
277
|
+
)
|
|
278
|
+
)
|
|
279
|
+
)
|
|
280
|
+
)
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export function validateNoPortConflicts(config: HunsuPortConfig, activePortNames: readonly HunsuPortName[]): ConfigResult<HunsuPortConfig> {
|
|
285
|
+
for (let i = 0; i < activePortNames.length; i += 1) {
|
|
286
|
+
const left = config[activePortNames[i]];
|
|
287
|
+
if (left.port === 0) {
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
for (let j = i + 1; j < activePortNames.length; j += 1) {
|
|
291
|
+
const right = config[activePortNames[j]];
|
|
292
|
+
if (right.port !== left.port || right.port === 0) {
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
if (hostsMayConflict(left.host, right.host)) {
|
|
296
|
+
return err({
|
|
297
|
+
code: "port_conflict",
|
|
298
|
+
message: `Hunsu port conflict: ${left.name} and ${right.name} both use ${left.host}:${left.port}.`,
|
|
299
|
+
port: left.port
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
return ok(config);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export function endpointUrl(endpoint: Pick<HunsuEndpoint, "host" | "port">): string {
|
|
308
|
+
return `http://${endpoint.host}:${endpoint.port}`;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export function readConfigBoolean(env: Env, envName: string, fallback: boolean): ConfigResult<boolean> {
|
|
312
|
+
const raw = env[envName];
|
|
313
|
+
if (raw === undefined || raw.trim() === "") {
|
|
314
|
+
return ok(fallback);
|
|
315
|
+
}
|
|
316
|
+
return parseConfigBoolean(envName, raw);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
export function readConfigEnum<const T extends string>(env: Env, envName: string, allowed: readonly T[], fallback: T): ConfigResult<T> {
|
|
320
|
+
const raw = env[envName];
|
|
321
|
+
if (raw === undefined || raw.trim() === "") {
|
|
322
|
+
return ok(fallback);
|
|
323
|
+
}
|
|
324
|
+
return parseConfigEnum(envName, raw, allowed);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
export function readConfigOptionalString(env: Env, envName: string): ConfigResult<string | undefined> {
|
|
328
|
+
const raw = env[envName];
|
|
329
|
+
return ok(raw === undefined || raw.trim() === "" ? undefined : raw);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
export function readConfigUrl(env: Env, envName: string, fallback: string): ConfigResult<string> {
|
|
333
|
+
const raw = env[envName];
|
|
334
|
+
return validateUrl(raw === undefined || raw.trim() === "" ? fallback : raw, envName);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
export function readConfigJsonStringArray(env: Env, envName: string, fallback: readonly string[] = []): ConfigResult<string[]> {
|
|
338
|
+
const raw = env[envName];
|
|
339
|
+
if (raw === undefined || raw.trim() === "") {
|
|
340
|
+
return ok([...fallback]);
|
|
341
|
+
}
|
|
342
|
+
return parseJsonStringArray(envName, raw);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
export function readConfigPath(env: Env, envName: string, fallback: string, cwd?: string): ConfigResult<string> {
|
|
346
|
+
const raw = env[envName];
|
|
347
|
+
return validatePath(raw === undefined || raw.trim() === "" ? fallback : raw, envName, cwd);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
export function resolveCodexAppServerConfig(env: Env, overrides: LocalRuntimeConfigOptions["codexAppServer"] = {}): ConfigResult<CodexAppServerConfig> {
|
|
351
|
+
const command = overrides.command ?? firstNonEmpty(env, ["HUNSU_CODEX_APP_SERVER_COMMAND"])?.value ?? "codex";
|
|
352
|
+
if (command.trim() === "") {
|
|
353
|
+
return err({
|
|
354
|
+
code: "missing_required_env",
|
|
355
|
+
env: "HUNSU_CODEX_APP_SERVER_COMMAND",
|
|
356
|
+
message: "Invalid HUNSU_CODEX_APP_SERVER_COMMAND: expected a non-empty command."
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
return mapConfigResult(resolveCodexAppServerArgs(env, overrides.args), args => ({
|
|
360
|
+
command,
|
|
361
|
+
args,
|
|
362
|
+
environment: filterEnv(overrides.environment ?? env)
|
|
363
|
+
}));
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
export function resolveCodexThreadOptions(env: Env, overrides: CodexThreadOptionsConfig = {}): ConfigResult<CodexThreadOptionsConfig> {
|
|
367
|
+
return flatMapConfigResult(readOptionalEnum(env, "HUNSU_CODEX_SANDBOX_MODE", CODEX_SANDBOX_MODES), sandboxMode =>
|
|
368
|
+
flatMapConfigResult(readOptionalEnum(env, "HUNSU_CODEX_APPROVAL_POLICY", CODEX_APPROVAL_POLICIES), approvalPolicy =>
|
|
369
|
+
flatMapConfigResult(readOptionalEnum(env, "HUNSU_CODEX_APPROVALS_REVIEWER", CODEX_APPROVALS_REVIEWERS), approvalsReviewer =>
|
|
370
|
+
flatMapConfigResult(readOptionalBoolean(env, "HUNSU_CODEX_NETWORK_ACCESS"), networkAccessEnabled =>
|
|
371
|
+
mapConfigResult(readCommaSeparatedList(env, "HUNSU_CODEX_ADDITIONAL_DIRECTORIES"), additionalDirectories => ({
|
|
372
|
+
...(sandboxMode === undefined ? {} : { sandboxMode }),
|
|
373
|
+
...(approvalPolicy === undefined ? {} : { approvalPolicy }),
|
|
374
|
+
...(approvalsReviewer === undefined ? {} : { approvalsReviewer }),
|
|
375
|
+
...(networkAccessEnabled === undefined ? {} : { networkAccessEnabled }),
|
|
376
|
+
...(additionalDirectories.length === 0 ? {} : { additionalDirectories }),
|
|
377
|
+
...overrides
|
|
378
|
+
}))
|
|
379
|
+
)
|
|
380
|
+
)
|
|
381
|
+
)
|
|
382
|
+
);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function resolveEndpoint(spec: PortSpec, env: Env, override: { host?: string; port?: number } | undefined): ConfigResult<HunsuEndpoint> {
|
|
386
|
+
const hostValue = override?.host !== undefined
|
|
387
|
+
? { value: override.host, source: "override" as const, env: undefined }
|
|
388
|
+
: firstNonEmpty(env, spec.hostEnv) ?? { value: spec.defaultHost, source: "default" as const, env: undefined };
|
|
389
|
+
|
|
390
|
+
const portValue = override?.port !== undefined
|
|
391
|
+
? mapConfigResult(validatePort(override.port, spec, undefined), port => ({ value: port, source: "override" as const, env: undefined }))
|
|
392
|
+
: readPort(env, spec);
|
|
393
|
+
|
|
394
|
+
return mapConfigResult(portValue, port => ({
|
|
395
|
+
name: spec.name,
|
|
396
|
+
host: hostValue.value,
|
|
397
|
+
hostSource: hostValue.source,
|
|
398
|
+
hostEnv: hostValue.env,
|
|
399
|
+
port: port.value,
|
|
400
|
+
portSource: port.source,
|
|
401
|
+
portEnv: port.env,
|
|
402
|
+
reserved: spec.reserved
|
|
403
|
+
}));
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function readPort(env: Env, spec: PortSpec): ConfigResult<{ value: number; source: ConfigValueSource; env?: string }> {
|
|
407
|
+
const envValue = firstNonEmpty(env, spec.portEnv);
|
|
408
|
+
if (!envValue) {
|
|
409
|
+
return ok({ value: spec.defaultPort, source: "default" });
|
|
410
|
+
}
|
|
411
|
+
return mapConfigResult(validatePort(envValue.value, spec, envValue.env), port => ({
|
|
412
|
+
value: port,
|
|
413
|
+
source: "env",
|
|
414
|
+
env: envValue.env
|
|
415
|
+
}));
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function validatePort(value: string | number, spec: PortSpec, envName: string | undefined): ConfigResult<number> {
|
|
419
|
+
const port = typeof value === "number" ? value : Number(value);
|
|
420
|
+
const min = spec.allowPortZero ? 0 : 1;
|
|
421
|
+
if (!Number.isInteger(port) || port < min || port > 65535) {
|
|
422
|
+
return err({
|
|
423
|
+
code: "invalid_port",
|
|
424
|
+
env: envName,
|
|
425
|
+
portName: spec.name,
|
|
426
|
+
message: envName
|
|
427
|
+
? `Invalid ${envName}: ${value}. Expected an integer port from ${min} to 65535.`
|
|
428
|
+
: `Invalid ${spec.name} port: ${value}. Expected an integer port from ${min} to 65535.`
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
return ok(port);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function parseConfigBoolean(envName: string, raw: string): ConfigResult<boolean> {
|
|
435
|
+
if (/^(1|true|yes|on)$/i.test(raw)) {
|
|
436
|
+
return ok(true);
|
|
437
|
+
}
|
|
438
|
+
if (/^(0|false|no|off)$/i.test(raw)) {
|
|
439
|
+
return ok(false);
|
|
440
|
+
}
|
|
441
|
+
return err({
|
|
442
|
+
code: "invalid_boolean",
|
|
443
|
+
env: envName,
|
|
444
|
+
message: `Invalid ${envName}: ${raw}. Expected true, false, 1, 0, yes, no, on, or off.`
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function readOptionalBoolean(env: Env, envName: string): ConfigResult<boolean | undefined> {
|
|
449
|
+
const raw = env[envName];
|
|
450
|
+
return raw === undefined || raw.trim() === "" ? ok(undefined) : parseConfigBoolean(envName, raw);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function parseConfigEnum<const T extends string>(envName: string, value: string, allowed: readonly T[]): ConfigResult<T> {
|
|
454
|
+
if (allowed.includes(value as T)) {
|
|
455
|
+
return ok(value as T);
|
|
456
|
+
}
|
|
457
|
+
return err({
|
|
458
|
+
code: "invalid_enum",
|
|
459
|
+
env: envName,
|
|
460
|
+
value,
|
|
461
|
+
allowed,
|
|
462
|
+
message: `Invalid ${envName}: ${value}. Expected one of: ${allowed.join(", ")}.`
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function readOptionalEnum<const T extends string>(env: Env, envName: string, allowed: readonly T[]): ConfigResult<T | undefined> {
|
|
467
|
+
const raw = env[envName];
|
|
468
|
+
return raw === undefined || raw.trim() === "" ? ok(undefined) : parseConfigEnum(envName, raw, allowed);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function readOptionalUrl(env: Env, envName: string): ConfigResult<string | undefined> {
|
|
472
|
+
const raw = env[envName];
|
|
473
|
+
return raw === undefined || raw.trim() === "" ? ok(undefined) : validateUrl(raw, envName);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function readFirstUrl(env: Env, envNames: readonly string[], fallback: string): ConfigResult<string> {
|
|
477
|
+
const raw = firstNonEmpty(env, envNames);
|
|
478
|
+
return validateUrl(raw?.value ?? fallback, raw?.env ?? envNames[0]);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function readFirstOptionalUrl(env: Env, envNames: readonly string[], fallback?: string): ConfigResult<string | undefined> {
|
|
482
|
+
const raw = firstNonEmpty(env, envNames);
|
|
483
|
+
const value = raw?.value ?? fallback;
|
|
484
|
+
return value === undefined ? ok(undefined) : validateUrl(value, raw?.env ?? envNames[0]);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function validateUrl(value: string, envName: string | undefined): ConfigResult<string> {
|
|
488
|
+
try {
|
|
489
|
+
const url = new URL(value);
|
|
490
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
491
|
+
throw new Error("Unsupported protocol");
|
|
492
|
+
}
|
|
493
|
+
return ok(url.toString().replace(/\/$/, ""));
|
|
494
|
+
} catch (_error) {
|
|
495
|
+
return err({
|
|
496
|
+
code: "invalid_url",
|
|
497
|
+
env: envName,
|
|
498
|
+
value,
|
|
499
|
+
message: envName
|
|
500
|
+
? `Invalid ${envName}: ${value}. Expected an http or https URL.`
|
|
501
|
+
: `Invalid URL: ${value}. Expected an http or https URL.`
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function resolveConfiguredPath(override: string | undefined, env: Env, envName: string, fallback: string | undefined, cwd?: string): ConfigResult<string | undefined> {
|
|
507
|
+
if (override !== undefined) {
|
|
508
|
+
return validatePath(override, undefined, cwd);
|
|
509
|
+
}
|
|
510
|
+
const raw = env[envName];
|
|
511
|
+
if (raw !== undefined && raw.trim() !== "") {
|
|
512
|
+
return validatePath(raw, envName, cwd);
|
|
513
|
+
}
|
|
514
|
+
return fallback === undefined ? ok(undefined) : validatePath(fallback, undefined, cwd);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
function resolveRequiredConfiguredPath(override: string | undefined, env: Env, envName: string, fallback: string, cwd?: string): ConfigResult<string> {
|
|
518
|
+
return mapConfigResult(resolveConfiguredPath(override, env, envName, fallback, cwd), path => path ?? resolve(cwd ?? ".", fallback));
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function validatePath(value: string, envName: string | undefined, cwd = "."): ConfigResult<string> {
|
|
522
|
+
if (value.trim() === "" || value.includes("\0")) {
|
|
523
|
+
return err({
|
|
524
|
+
code: "invalid_path",
|
|
525
|
+
env: envName,
|
|
526
|
+
path: value,
|
|
527
|
+
message: envName
|
|
528
|
+
? `Invalid ${envName}: expected a non-empty filesystem path.`
|
|
529
|
+
: "Invalid filesystem path: expected a non-empty path."
|
|
530
|
+
});
|
|
531
|
+
}
|
|
532
|
+
return ok(resolve(cwd, value));
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
function resolveCodexAppServerArgs(env: Env, override: readonly string[] | undefined): ConfigResult<string[]> {
|
|
536
|
+
if (override !== undefined) {
|
|
537
|
+
return ok([...override]);
|
|
538
|
+
}
|
|
539
|
+
const raw = env.HUNSU_CODEX_APP_SERVER_ARGS;
|
|
540
|
+
if (raw === undefined || raw.trim() === "") {
|
|
541
|
+
return ok(["app-server", "--stdio"]);
|
|
542
|
+
}
|
|
543
|
+
if (raw.trim().startsWith("[")) {
|
|
544
|
+
return parseJsonStringArray("HUNSU_CODEX_APP_SERVER_ARGS", raw);
|
|
545
|
+
}
|
|
546
|
+
return ok(raw.split(/\s+/).filter(Boolean));
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
function parseJsonStringArray(envName: string, raw: string): ConfigResult<string[]> {
|
|
550
|
+
try {
|
|
551
|
+
const parsed = JSON.parse(raw) as unknown;
|
|
552
|
+
if (Array.isArray(parsed) && parsed.every(item => typeof item === "string")) {
|
|
553
|
+
return ok(parsed);
|
|
554
|
+
}
|
|
555
|
+
} catch (_error) {
|
|
556
|
+
return err({
|
|
557
|
+
code: "invalid_json",
|
|
558
|
+
env: envName,
|
|
559
|
+
value: raw,
|
|
560
|
+
message: `Invalid ${envName}: expected a JSON string array.`
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
return err({
|
|
564
|
+
code: "invalid_json",
|
|
565
|
+
env: envName,
|
|
566
|
+
value: raw,
|
|
567
|
+
message: `Invalid ${envName}: expected a JSON string array.`
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
function readCommaSeparatedList(env: Env, envName: string): ConfigResult<string[]> {
|
|
572
|
+
const raw = env[envName];
|
|
573
|
+
return ok(raw === undefined || raw.trim() === ""
|
|
574
|
+
? []
|
|
575
|
+
: raw.split(",").map(value => value.trim()).filter(Boolean));
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
function filterEnv(env: Env): Record<string, string> {
|
|
579
|
+
const next: Record<string, string> = {};
|
|
580
|
+
for (const [key, value] of Object.entries(env)) {
|
|
581
|
+
if (value !== undefined) {
|
|
582
|
+
next[key] = value;
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
return next;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
function firstNonEmpty(env: Env, names: readonly string[]): { value: string; source: "env"; env: string } | undefined {
|
|
589
|
+
for (const name of names) {
|
|
590
|
+
const value = env[name];
|
|
591
|
+
if (value !== undefined && value.trim() !== "") {
|
|
592
|
+
return { value, source: "env", env: name };
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
return undefined;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
function hostsMayConflict(left: string, right: string): boolean {
|
|
599
|
+
const normalizedLeft = normalizeHost(left);
|
|
600
|
+
const normalizedRight = normalizeHost(right);
|
|
601
|
+
return normalizedLeft === normalizedRight || isWildcardHost(normalizedLeft) || isWildcardHost(normalizedRight);
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
function normalizeHost(host: string): string {
|
|
605
|
+
const normalized = host.trim().toLowerCase();
|
|
606
|
+
return normalized === "localhost" ? DEFAULT_HOST : normalized;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
function isWildcardHost(host: string): boolean {
|
|
610
|
+
return host === "0.0.0.0" || host === "::" || host === "[::]";
|
|
611
|
+
}
|