@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/dist/index.js
ADDED
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
|
+
const DEFAULT_HOST = "127.0.0.1";
|
|
4
|
+
const DEFAULT_LOCAL_HUB_API_URL = "http://127.0.0.1:8787";
|
|
5
|
+
const DEFAULT_STUDIO_WEB_URL = "https://hunsu.app/studio";
|
|
6
|
+
export const HUNSU_PORT_SPECS = {
|
|
7
|
+
localApi: {
|
|
8
|
+
name: "localApi",
|
|
9
|
+
defaultHost: DEFAULT_HOST,
|
|
10
|
+
hostEnv: ["HUNSU_LOCAL_HOST", "HOST"],
|
|
11
|
+
defaultPort: 19687,
|
|
12
|
+
portEnv: ["HUNSU_LOCAL_PORT", "PORT"],
|
|
13
|
+
reserved: false,
|
|
14
|
+
allowPortZero: false
|
|
15
|
+
},
|
|
16
|
+
studioWeb: {
|
|
17
|
+
name: "studioWeb",
|
|
18
|
+
defaultHost: DEFAULT_HOST,
|
|
19
|
+
hostEnv: ["HUNSU_WEB_HOST"],
|
|
20
|
+
defaultPort: 19688,
|
|
21
|
+
portEnv: ["HUNSU_WEB_PORT"],
|
|
22
|
+
reserved: false,
|
|
23
|
+
allowPortZero: false
|
|
24
|
+
},
|
|
25
|
+
agentPreview: {
|
|
26
|
+
name: "agentPreview",
|
|
27
|
+
defaultHost: DEFAULT_HOST,
|
|
28
|
+
hostEnv: ["HUNSU_AGENT_PREVIEW_HOST"],
|
|
29
|
+
defaultPort: 19673,
|
|
30
|
+
portEnv: ["HUNSU_AGENT_PREVIEW_PORT"],
|
|
31
|
+
reserved: true,
|
|
32
|
+
allowPortZero: false
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
export const HUNSU_ACTIVE_SERVICE_PORTS = ["localApi", "studioWeb"];
|
|
36
|
+
export const HUNSU_STUDIO_WEB_ACTIVE_PORTS = ["localApi", "studioWeb"];
|
|
37
|
+
const CODEX_SANDBOX_MODES = ["read-only", "workspace-write", "danger-full-access"];
|
|
38
|
+
const CODEX_APPROVAL_POLICIES = ["never", "on-request", "on-failure", "untrusted"];
|
|
39
|
+
const CODEX_APPROVALS_REVIEWERS = ["user", "auto_review"];
|
|
40
|
+
const LOCAL_TEST_RUNNERS = ["deterministic"];
|
|
41
|
+
export function ok(value) {
|
|
42
|
+
return { ok: true, value };
|
|
43
|
+
}
|
|
44
|
+
export function err(error) {
|
|
45
|
+
return { ok: false, error };
|
|
46
|
+
}
|
|
47
|
+
export function mapConfigResult(result, transform) {
|
|
48
|
+
return result.ok ? ok(transform(result.value)) : result;
|
|
49
|
+
}
|
|
50
|
+
export function flatMapConfigResult(result, transform) {
|
|
51
|
+
return result.ok ? transform(result.value) : result;
|
|
52
|
+
}
|
|
53
|
+
export function unwrapConfigResult(result) {
|
|
54
|
+
if (result.ok) {
|
|
55
|
+
return result.value;
|
|
56
|
+
}
|
|
57
|
+
throw new Error(result.error.message);
|
|
58
|
+
}
|
|
59
|
+
export function resolveHunsuPorts(env, options = {}) {
|
|
60
|
+
const localApi = resolveEndpoint(HUNSU_PORT_SPECS.localApi, env, options.overrides?.localApi);
|
|
61
|
+
if (!localApi.ok) {
|
|
62
|
+
return localApi;
|
|
63
|
+
}
|
|
64
|
+
const studioWeb = resolveEndpoint(HUNSU_PORT_SPECS.studioWeb, env, options.overrides?.studioWeb);
|
|
65
|
+
if (!studioWeb.ok) {
|
|
66
|
+
return studioWeb;
|
|
67
|
+
}
|
|
68
|
+
const agentPreview = resolveEndpoint(HUNSU_PORT_SPECS.agentPreview, env, options.overrides?.agentPreview);
|
|
69
|
+
if (!agentPreview.ok) {
|
|
70
|
+
return agentPreview;
|
|
71
|
+
}
|
|
72
|
+
return ok({
|
|
73
|
+
localApi: localApi.value,
|
|
74
|
+
studioWeb: studioWeb.value,
|
|
75
|
+
agentPreview: agentPreview.value
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
export function resolveValidatedHunsuPorts(env, options) {
|
|
79
|
+
return flatMapConfigResult(resolveHunsuPorts(env, options), config => validateNoPortConflicts(config, options.activePortNames));
|
|
80
|
+
}
|
|
81
|
+
export function resolveStudioWebServerConfig(env) {
|
|
82
|
+
return flatMapConfigResult(resolveValidatedHunsuPorts(env, { activePortNames: HUNSU_STUDIO_WEB_ACTIVE_PORTS }), ports => flatMapConfigResult(readConfigBoolean(env, "HUNSU_WEB_STRICT_PORT", true), strictPort => flatMapConfigResult(readOptionalUrl(env, "VITE_HUNSU_LOCAL_URL"), browserLocalUrl => flatMapConfigResult(readFirstOptionalUrl(env, ["VITE_HUNSU_HUB_API_URL", "HUNSU_HUB_PUBLIC_API_URL"], defaultBrowserHubApiUrl(env)), browserHubApiUrl => mapConfigResult(readFirstUrl(env, ["HUNSU_LOCAL_API_PROXY_TARGET"], browserLocalUrl ?? endpointUrl(ports.localApi)), apiProxyTarget => ({
|
|
83
|
+
web: ports.studioWeb,
|
|
84
|
+
localApi: ports.localApi,
|
|
85
|
+
strictPort,
|
|
86
|
+
apiProxyTarget,
|
|
87
|
+
browserLocalUrl,
|
|
88
|
+
browserHubApiUrl
|
|
89
|
+
}))))));
|
|
90
|
+
}
|
|
91
|
+
export function resolveStudioLauncherConfig(env) {
|
|
92
|
+
return mapConfigResult(readConfigUrl(env, "HUNSU_WEB_URL", DEFAULT_STUDIO_WEB_URL), webUrl => ({ webUrl }));
|
|
93
|
+
}
|
|
94
|
+
export function currentProcessEnv() {
|
|
95
|
+
return process.env;
|
|
96
|
+
}
|
|
97
|
+
function defaultBrowserHubApiUrl(env) {
|
|
98
|
+
const target = firstNonEmpty(env, ["HUNSU_DEPLOY_TARGET"])?.value;
|
|
99
|
+
if (target === undefined || target === "local") {
|
|
100
|
+
return DEFAULT_LOCAL_HUB_API_URL;
|
|
101
|
+
}
|
|
102
|
+
return undefined;
|
|
103
|
+
}
|
|
104
|
+
export function resolveLocalApiServerConfig(env) {
|
|
105
|
+
return mapConfigResult(resolveValidatedHunsuPorts(env, { activePortNames: ["localApi"] }), ports => ({ localApi: ports.localApi }));
|
|
106
|
+
}
|
|
107
|
+
export function resolveLocalRuntimeConfig(env, options = {}) {
|
|
108
|
+
const processEnv = filterEnv(options.processEnv ?? env);
|
|
109
|
+
return flatMapConfigResult(resolveLocalApiServerConfig(env), ({ localApi }) => flatMapConfigResult(resolveRequiredConfiguredPath(options.roadmapRegistryPath, env, "HUNSU_ROADMAP_REGISTRY_PATH", join(options.homeDir ?? homedir(), ".config", "hunsu", "roadmaps.json"), options.cwd), roadmapRegistryPath => flatMapConfigResult(resolveConfiguredPath(options.routeWorktreeRoot, env, "HUNSU_ROUTE_WORKTREE_ROOT", undefined, options.cwd), routeWorktreeRoot => flatMapConfigResult(resolveConfiguredPath(options.actionWorktreeRoot, env, "HUNSU_ACTION_WORKTREE_ROOT", undefined, options.cwd), actionWorktreeRoot => flatMapConfigResult(readOptionalEnum(env, "HUNSU_LOCAL_TEST_RUNNER", LOCAL_TEST_RUNNERS), testRunner => flatMapConfigResult(resolveCodexAppServerConfig(env, options.codexAppServer), codexAppServer => mapConfigResult(resolveCodexThreadOptions(env, options.codexThreadOptions), codexThreadOptions => ({
|
|
110
|
+
localApi,
|
|
111
|
+
processEnv,
|
|
112
|
+
roadmapRegistryPath,
|
|
113
|
+
routeWorktreeRoot,
|
|
114
|
+
actionWorktreeRoot,
|
|
115
|
+
testRunner,
|
|
116
|
+
codexAppServer,
|
|
117
|
+
codexThreadOptions
|
|
118
|
+
}))))))));
|
|
119
|
+
}
|
|
120
|
+
export function validateNoPortConflicts(config, activePortNames) {
|
|
121
|
+
for (let i = 0; i < activePortNames.length; i += 1) {
|
|
122
|
+
const left = config[activePortNames[i]];
|
|
123
|
+
if (left.port === 0) {
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
for (let j = i + 1; j < activePortNames.length; j += 1) {
|
|
127
|
+
const right = config[activePortNames[j]];
|
|
128
|
+
if (right.port !== left.port || right.port === 0) {
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
if (hostsMayConflict(left.host, right.host)) {
|
|
132
|
+
return err({
|
|
133
|
+
code: "port_conflict",
|
|
134
|
+
message: `Hunsu port conflict: ${left.name} and ${right.name} both use ${left.host}:${left.port}.`,
|
|
135
|
+
port: left.port
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return ok(config);
|
|
141
|
+
}
|
|
142
|
+
export function endpointUrl(endpoint) {
|
|
143
|
+
return `http://${endpoint.host}:${endpoint.port}`;
|
|
144
|
+
}
|
|
145
|
+
export function readConfigBoolean(env, envName, fallback) {
|
|
146
|
+
const raw = env[envName];
|
|
147
|
+
if (raw === undefined || raw.trim() === "") {
|
|
148
|
+
return ok(fallback);
|
|
149
|
+
}
|
|
150
|
+
return parseConfigBoolean(envName, raw);
|
|
151
|
+
}
|
|
152
|
+
export function readConfigEnum(env, envName, allowed, fallback) {
|
|
153
|
+
const raw = env[envName];
|
|
154
|
+
if (raw === undefined || raw.trim() === "") {
|
|
155
|
+
return ok(fallback);
|
|
156
|
+
}
|
|
157
|
+
return parseConfigEnum(envName, raw, allowed);
|
|
158
|
+
}
|
|
159
|
+
export function readConfigOptionalString(env, envName) {
|
|
160
|
+
const raw = env[envName];
|
|
161
|
+
return ok(raw === undefined || raw.trim() === "" ? undefined : raw);
|
|
162
|
+
}
|
|
163
|
+
export function readConfigUrl(env, envName, fallback) {
|
|
164
|
+
const raw = env[envName];
|
|
165
|
+
return validateUrl(raw === undefined || raw.trim() === "" ? fallback : raw, envName);
|
|
166
|
+
}
|
|
167
|
+
export function readConfigJsonStringArray(env, envName, fallback = []) {
|
|
168
|
+
const raw = env[envName];
|
|
169
|
+
if (raw === undefined || raw.trim() === "") {
|
|
170
|
+
return ok([...fallback]);
|
|
171
|
+
}
|
|
172
|
+
return parseJsonStringArray(envName, raw);
|
|
173
|
+
}
|
|
174
|
+
export function readConfigPath(env, envName, fallback, cwd) {
|
|
175
|
+
const raw = env[envName];
|
|
176
|
+
return validatePath(raw === undefined || raw.trim() === "" ? fallback : raw, envName, cwd);
|
|
177
|
+
}
|
|
178
|
+
export function resolveCodexAppServerConfig(env, overrides = {}) {
|
|
179
|
+
const command = overrides.command ?? firstNonEmpty(env, ["HUNSU_CODEX_APP_SERVER_COMMAND"])?.value ?? "codex";
|
|
180
|
+
if (command.trim() === "") {
|
|
181
|
+
return err({
|
|
182
|
+
code: "missing_required_env",
|
|
183
|
+
env: "HUNSU_CODEX_APP_SERVER_COMMAND",
|
|
184
|
+
message: "Invalid HUNSU_CODEX_APP_SERVER_COMMAND: expected a non-empty command."
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
return mapConfigResult(resolveCodexAppServerArgs(env, overrides.args), args => ({
|
|
188
|
+
command,
|
|
189
|
+
args,
|
|
190
|
+
environment: filterEnv(overrides.environment ?? env)
|
|
191
|
+
}));
|
|
192
|
+
}
|
|
193
|
+
export function resolveCodexThreadOptions(env, overrides = {}) {
|
|
194
|
+
return flatMapConfigResult(readOptionalEnum(env, "HUNSU_CODEX_SANDBOX_MODE", CODEX_SANDBOX_MODES), sandboxMode => flatMapConfigResult(readOptionalEnum(env, "HUNSU_CODEX_APPROVAL_POLICY", CODEX_APPROVAL_POLICIES), approvalPolicy => flatMapConfigResult(readOptionalEnum(env, "HUNSU_CODEX_APPROVALS_REVIEWER", CODEX_APPROVALS_REVIEWERS), approvalsReviewer => flatMapConfigResult(readOptionalBoolean(env, "HUNSU_CODEX_NETWORK_ACCESS"), networkAccessEnabled => mapConfigResult(readCommaSeparatedList(env, "HUNSU_CODEX_ADDITIONAL_DIRECTORIES"), additionalDirectories => ({
|
|
195
|
+
...(sandboxMode === undefined ? {} : { sandboxMode }),
|
|
196
|
+
...(approvalPolicy === undefined ? {} : { approvalPolicy }),
|
|
197
|
+
...(approvalsReviewer === undefined ? {} : { approvalsReviewer }),
|
|
198
|
+
...(networkAccessEnabled === undefined ? {} : { networkAccessEnabled }),
|
|
199
|
+
...(additionalDirectories.length === 0 ? {} : { additionalDirectories }),
|
|
200
|
+
...overrides
|
|
201
|
+
}))))));
|
|
202
|
+
}
|
|
203
|
+
function resolveEndpoint(spec, env, override) {
|
|
204
|
+
const hostValue = override?.host !== undefined
|
|
205
|
+
? { value: override.host, source: "override", env: undefined }
|
|
206
|
+
: firstNonEmpty(env, spec.hostEnv) ?? { value: spec.defaultHost, source: "default", env: undefined };
|
|
207
|
+
const portValue = override?.port !== undefined
|
|
208
|
+
? mapConfigResult(validatePort(override.port, spec, undefined), port => ({ value: port, source: "override", env: undefined }))
|
|
209
|
+
: readPort(env, spec);
|
|
210
|
+
return mapConfigResult(portValue, port => ({
|
|
211
|
+
name: spec.name,
|
|
212
|
+
host: hostValue.value,
|
|
213
|
+
hostSource: hostValue.source,
|
|
214
|
+
hostEnv: hostValue.env,
|
|
215
|
+
port: port.value,
|
|
216
|
+
portSource: port.source,
|
|
217
|
+
portEnv: port.env,
|
|
218
|
+
reserved: spec.reserved
|
|
219
|
+
}));
|
|
220
|
+
}
|
|
221
|
+
function readPort(env, spec) {
|
|
222
|
+
const envValue = firstNonEmpty(env, spec.portEnv);
|
|
223
|
+
if (!envValue) {
|
|
224
|
+
return ok({ value: spec.defaultPort, source: "default" });
|
|
225
|
+
}
|
|
226
|
+
return mapConfigResult(validatePort(envValue.value, spec, envValue.env), port => ({
|
|
227
|
+
value: port,
|
|
228
|
+
source: "env",
|
|
229
|
+
env: envValue.env
|
|
230
|
+
}));
|
|
231
|
+
}
|
|
232
|
+
function validatePort(value, spec, envName) {
|
|
233
|
+
const port = typeof value === "number" ? value : Number(value);
|
|
234
|
+
const min = spec.allowPortZero ? 0 : 1;
|
|
235
|
+
if (!Number.isInteger(port) || port < min || port > 65535) {
|
|
236
|
+
return err({
|
|
237
|
+
code: "invalid_port",
|
|
238
|
+
env: envName,
|
|
239
|
+
portName: spec.name,
|
|
240
|
+
message: envName
|
|
241
|
+
? `Invalid ${envName}: ${value}. Expected an integer port from ${min} to 65535.`
|
|
242
|
+
: `Invalid ${spec.name} port: ${value}. Expected an integer port from ${min} to 65535.`
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
return ok(port);
|
|
246
|
+
}
|
|
247
|
+
function parseConfigBoolean(envName, raw) {
|
|
248
|
+
if (/^(1|true|yes|on)$/i.test(raw)) {
|
|
249
|
+
return ok(true);
|
|
250
|
+
}
|
|
251
|
+
if (/^(0|false|no|off)$/i.test(raw)) {
|
|
252
|
+
return ok(false);
|
|
253
|
+
}
|
|
254
|
+
return err({
|
|
255
|
+
code: "invalid_boolean",
|
|
256
|
+
env: envName,
|
|
257
|
+
message: `Invalid ${envName}: ${raw}. Expected true, false, 1, 0, yes, no, on, or off.`
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
function readOptionalBoolean(env, envName) {
|
|
261
|
+
const raw = env[envName];
|
|
262
|
+
return raw === undefined || raw.trim() === "" ? ok(undefined) : parseConfigBoolean(envName, raw);
|
|
263
|
+
}
|
|
264
|
+
function parseConfigEnum(envName, value, allowed) {
|
|
265
|
+
if (allowed.includes(value)) {
|
|
266
|
+
return ok(value);
|
|
267
|
+
}
|
|
268
|
+
return err({
|
|
269
|
+
code: "invalid_enum",
|
|
270
|
+
env: envName,
|
|
271
|
+
value,
|
|
272
|
+
allowed,
|
|
273
|
+
message: `Invalid ${envName}: ${value}. Expected one of: ${allowed.join(", ")}.`
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
function readOptionalEnum(env, envName, allowed) {
|
|
277
|
+
const raw = env[envName];
|
|
278
|
+
return raw === undefined || raw.trim() === "" ? ok(undefined) : parseConfigEnum(envName, raw, allowed);
|
|
279
|
+
}
|
|
280
|
+
function readOptionalUrl(env, envName) {
|
|
281
|
+
const raw = env[envName];
|
|
282
|
+
return raw === undefined || raw.trim() === "" ? ok(undefined) : validateUrl(raw, envName);
|
|
283
|
+
}
|
|
284
|
+
function readFirstUrl(env, envNames, fallback) {
|
|
285
|
+
const raw = firstNonEmpty(env, envNames);
|
|
286
|
+
return validateUrl(raw?.value ?? fallback, raw?.env ?? envNames[0]);
|
|
287
|
+
}
|
|
288
|
+
function readFirstOptionalUrl(env, envNames, fallback) {
|
|
289
|
+
const raw = firstNonEmpty(env, envNames);
|
|
290
|
+
const value = raw?.value ?? fallback;
|
|
291
|
+
return value === undefined ? ok(undefined) : validateUrl(value, raw?.env ?? envNames[0]);
|
|
292
|
+
}
|
|
293
|
+
function validateUrl(value, envName) {
|
|
294
|
+
try {
|
|
295
|
+
const url = new URL(value);
|
|
296
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
297
|
+
throw new Error("Unsupported protocol");
|
|
298
|
+
}
|
|
299
|
+
return ok(url.toString().replace(/\/$/, ""));
|
|
300
|
+
}
|
|
301
|
+
catch (_error) {
|
|
302
|
+
return err({
|
|
303
|
+
code: "invalid_url",
|
|
304
|
+
env: envName,
|
|
305
|
+
value,
|
|
306
|
+
message: envName
|
|
307
|
+
? `Invalid ${envName}: ${value}. Expected an http or https URL.`
|
|
308
|
+
: `Invalid URL: ${value}. Expected an http or https URL.`
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
function resolveConfiguredPath(override, env, envName, fallback, cwd) {
|
|
313
|
+
if (override !== undefined) {
|
|
314
|
+
return validatePath(override, undefined, cwd);
|
|
315
|
+
}
|
|
316
|
+
const raw = env[envName];
|
|
317
|
+
if (raw !== undefined && raw.trim() !== "") {
|
|
318
|
+
return validatePath(raw, envName, cwd);
|
|
319
|
+
}
|
|
320
|
+
return fallback === undefined ? ok(undefined) : validatePath(fallback, undefined, cwd);
|
|
321
|
+
}
|
|
322
|
+
function resolveRequiredConfiguredPath(override, env, envName, fallback, cwd) {
|
|
323
|
+
return mapConfigResult(resolveConfiguredPath(override, env, envName, fallback, cwd), path => path ?? resolve(cwd ?? ".", fallback));
|
|
324
|
+
}
|
|
325
|
+
function validatePath(value, envName, cwd = ".") {
|
|
326
|
+
if (value.trim() === "" || value.includes("\0")) {
|
|
327
|
+
return err({
|
|
328
|
+
code: "invalid_path",
|
|
329
|
+
env: envName,
|
|
330
|
+
path: value,
|
|
331
|
+
message: envName
|
|
332
|
+
? `Invalid ${envName}: expected a non-empty filesystem path.`
|
|
333
|
+
: "Invalid filesystem path: expected a non-empty path."
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
return ok(resolve(cwd, value));
|
|
337
|
+
}
|
|
338
|
+
function resolveCodexAppServerArgs(env, override) {
|
|
339
|
+
if (override !== undefined) {
|
|
340
|
+
return ok([...override]);
|
|
341
|
+
}
|
|
342
|
+
const raw = env.HUNSU_CODEX_APP_SERVER_ARGS;
|
|
343
|
+
if (raw === undefined || raw.trim() === "") {
|
|
344
|
+
return ok(["app-server", "--stdio"]);
|
|
345
|
+
}
|
|
346
|
+
if (raw.trim().startsWith("[")) {
|
|
347
|
+
return parseJsonStringArray("HUNSU_CODEX_APP_SERVER_ARGS", raw);
|
|
348
|
+
}
|
|
349
|
+
return ok(raw.split(/\s+/).filter(Boolean));
|
|
350
|
+
}
|
|
351
|
+
function parseJsonStringArray(envName, raw) {
|
|
352
|
+
try {
|
|
353
|
+
const parsed = JSON.parse(raw);
|
|
354
|
+
if (Array.isArray(parsed) && parsed.every(item => typeof item === "string")) {
|
|
355
|
+
return ok(parsed);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
catch (_error) {
|
|
359
|
+
return err({
|
|
360
|
+
code: "invalid_json",
|
|
361
|
+
env: envName,
|
|
362
|
+
value: raw,
|
|
363
|
+
message: `Invalid ${envName}: expected a JSON string array.`
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
return err({
|
|
367
|
+
code: "invalid_json",
|
|
368
|
+
env: envName,
|
|
369
|
+
value: raw,
|
|
370
|
+
message: `Invalid ${envName}: expected a JSON string array.`
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
function readCommaSeparatedList(env, envName) {
|
|
374
|
+
const raw = env[envName];
|
|
375
|
+
return ok(raw === undefined || raw.trim() === ""
|
|
376
|
+
? []
|
|
377
|
+
: raw.split(",").map(value => value.trim()).filter(Boolean));
|
|
378
|
+
}
|
|
379
|
+
function filterEnv(env) {
|
|
380
|
+
const next = {};
|
|
381
|
+
for (const [key, value] of Object.entries(env)) {
|
|
382
|
+
if (value !== undefined) {
|
|
383
|
+
next[key] = value;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
return next;
|
|
387
|
+
}
|
|
388
|
+
function firstNonEmpty(env, names) {
|
|
389
|
+
for (const name of names) {
|
|
390
|
+
const value = env[name];
|
|
391
|
+
if (value !== undefined && value.trim() !== "") {
|
|
392
|
+
return { value, source: "env", env: name };
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
return undefined;
|
|
396
|
+
}
|
|
397
|
+
function hostsMayConflict(left, right) {
|
|
398
|
+
const normalizedLeft = normalizeHost(left);
|
|
399
|
+
const normalizedRight = normalizeHost(right);
|
|
400
|
+
return normalizedLeft === normalizedRight || isWildcardHost(normalizedLeft) || isWildcardHost(normalizedRight);
|
|
401
|
+
}
|
|
402
|
+
function normalizeHost(host) {
|
|
403
|
+
const normalized = host.trim().toLowerCase();
|
|
404
|
+
return normalized === "localhost" ? DEFAULT_HOST : normalized;
|
|
405
|
+
}
|
|
406
|
+
function isWildcardHost(host) {
|
|
407
|
+
return host === "0.0.0.0" || host === "::" || host === "[::]";
|
|
408
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@hunsu/config",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"description": "Hunsu app-boundary configuration helpers.",
|
|
6
|
+
"license": "Apache-2.0",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"development": "./src/index.ts",
|
|
11
|
+
"types": "./src/index.ts",
|
|
12
|
+
"import": "./dist/index.js",
|
|
13
|
+
"default": "./dist/index.js"
|
|
14
|
+
},
|
|
15
|
+
"./cloudflare": {
|
|
16
|
+
"development": "./src/cloudflare.ts",
|
|
17
|
+
"types": "./src/cloudflare.ts",
|
|
18
|
+
"import": "./dist/cloudflare.js",
|
|
19
|
+
"default": "./dist/cloudflare.js"
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"dist",
|
|
24
|
+
"src"
|
|
25
|
+
],
|
|
26
|
+
"publishConfig": {
|
|
27
|
+
"access": "public"
|
|
28
|
+
},
|
|
29
|
+
"engines": {
|
|
30
|
+
"node": ">=22.18"
|
|
31
|
+
},
|
|
32
|
+
"scripts": {
|
|
33
|
+
"build": "tsc -p tsconfig.build.json",
|
|
34
|
+
"typecheck": "tsc -p tsconfig.json --noEmit"
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
export type ConfigResult<T> =
|
|
2
|
+
| { ok: true; value: T }
|
|
3
|
+
| { ok: false; error: CloudflareConfigError };
|
|
4
|
+
|
|
5
|
+
export type CloudflareConfigError = {
|
|
6
|
+
code: "invalid_enum" | "invalid_url" | "missing_required_env";
|
|
7
|
+
message: string;
|
|
8
|
+
env?: string;
|
|
9
|
+
value?: string;
|
|
10
|
+
allowed?: readonly string[];
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type HunsuDeployTarget = "local" | "dev" | "production";
|
|
14
|
+
|
|
15
|
+
export type HubCloudflareConfig = {
|
|
16
|
+
target: HunsuDeployTarget;
|
|
17
|
+
workerName: string;
|
|
18
|
+
workerMain: string;
|
|
19
|
+
compatibilityDate: string;
|
|
20
|
+
compatibilityFlags: string[];
|
|
21
|
+
originName: string;
|
|
22
|
+
publicHubApiUrl: string;
|
|
23
|
+
d1DatabaseName: string;
|
|
24
|
+
d1DatabaseId: string;
|
|
25
|
+
r2BucketName: string;
|
|
26
|
+
publishQueueName?: string;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export type HubApiWorkerRuntimeConfig = {
|
|
30
|
+
originName: string;
|
|
31
|
+
publicHubApiUrl?: string;
|
|
32
|
+
adminToken?: string;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export type HubApiWorkerRuntimeEnv = {
|
|
36
|
+
HUNSU_HUB_ORIGIN_NAME?: string;
|
|
37
|
+
HUNSU_HUB_PUBLIC_API_URL?: string;
|
|
38
|
+
HUNSU_HUB_ADMIN_TOKEN?: string;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export type Env = Record<string, string | undefined>;
|
|
42
|
+
|
|
43
|
+
export const HUNSU_DEPLOY_TARGETS = ["local", "dev", "production"] as const;
|
|
44
|
+
export const HUB_WRANGLER_GENERATED_CONFIG_PATH = ".wrangler/generated.toml";
|
|
45
|
+
export const HUB_WORKER_MAIN = "../src/index.ts";
|
|
46
|
+
export const HUB_WORKER_COMPATIBILITY_DATE = "2026-06-25";
|
|
47
|
+
export const HUB_WORKER_COMPATIBILITY_FLAGS = ["nodejs_compat"] as const;
|
|
48
|
+
|
|
49
|
+
const LOCAL_HUB_DEFAULTS = {
|
|
50
|
+
HUNSU_HUB_WORKER_NAME: "hunsu-hub-api-local",
|
|
51
|
+
HUNSU_HUB_ORIGIN_NAME: "hunsu-local",
|
|
52
|
+
HUNSU_HUB_PUBLIC_API_URL: "http://127.0.0.1:8787",
|
|
53
|
+
HUNSU_HUB_D1_DATABASE_NAME: "hunsu_hub_local",
|
|
54
|
+
HUNSU_HUB_D1_DATABASE_ID: "hunsu-hub-local",
|
|
55
|
+
HUNSU_HUB_R2_BUCKET_NAME: "hunsu-hub-packages-local"
|
|
56
|
+
} as const;
|
|
57
|
+
|
|
58
|
+
export function resolveHubCloudflareConfig(env: Env): ConfigResult<HubCloudflareConfig> {
|
|
59
|
+
return flatMapConfigResult(readDeployTarget(env), target =>
|
|
60
|
+
flatMapConfigResult(readRequiredString(env, "HUNSU_HUB_WORKER_NAME", target), workerName =>
|
|
61
|
+
flatMapConfigResult(readRequiredString(env, "HUNSU_HUB_ORIGIN_NAME", target), originName =>
|
|
62
|
+
flatMapConfigResult(readRequiredUrl(env, "HUNSU_HUB_PUBLIC_API_URL", target), publicHubApiUrl =>
|
|
63
|
+
flatMapConfigResult(readRequiredString(env, "HUNSU_HUB_D1_DATABASE_NAME", target), d1DatabaseName =>
|
|
64
|
+
flatMapConfigResult(readRequiredString(env, "HUNSU_HUB_D1_DATABASE_ID", target), d1DatabaseId =>
|
|
65
|
+
mapConfigResult(readRequiredString(env, "HUNSU_HUB_R2_BUCKET_NAME", target), r2BucketName => ({
|
|
66
|
+
target,
|
|
67
|
+
workerName,
|
|
68
|
+
workerMain: HUB_WORKER_MAIN,
|
|
69
|
+
compatibilityDate: HUB_WORKER_COMPATIBILITY_DATE,
|
|
70
|
+
compatibilityFlags: [...HUB_WORKER_COMPATIBILITY_FLAGS],
|
|
71
|
+
originName,
|
|
72
|
+
publicHubApiUrl,
|
|
73
|
+
d1DatabaseName,
|
|
74
|
+
d1DatabaseId,
|
|
75
|
+
r2BucketName,
|
|
76
|
+
publishQueueName: readOptionalString(env, "HUNSU_HUB_PUBLISH_QUEUE_NAME")
|
|
77
|
+
}))
|
|
78
|
+
)
|
|
79
|
+
)
|
|
80
|
+
)
|
|
81
|
+
)
|
|
82
|
+
)
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function resolveHubApiWorkerRuntimeConfig(env: HubApiWorkerRuntimeEnv): ConfigResult<HubApiWorkerRuntimeConfig> {
|
|
87
|
+
return flatMapConfigResult(readRequiredRuntimeString(env, "HUNSU_HUB_ORIGIN_NAME"), originName =>
|
|
88
|
+
mapConfigResult(readOptionalRuntimeUrl(env, "HUNSU_HUB_PUBLIC_API_URL"), publicHubApiUrl => ({
|
|
89
|
+
originName,
|
|
90
|
+
publicHubApiUrl,
|
|
91
|
+
adminToken: readOptionalRuntimeString(env, "HUNSU_HUB_ADMIN_TOKEN")
|
|
92
|
+
}))
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function renderHubWranglerToml(config: HubCloudflareConfig): string {
|
|
97
|
+
const lines = [
|
|
98
|
+
"# Generated by @hunsu/config/cloudflare. Do not edit by hand.",
|
|
99
|
+
`name = ${tomlString(config.workerName)}`,
|
|
100
|
+
`main = ${tomlString(config.workerMain)}`,
|
|
101
|
+
`compatibility_date = ${tomlString(config.compatibilityDate)}`,
|
|
102
|
+
`compatibility_flags = [${config.compatibilityFlags.map(tomlString).join(", ")}]`,
|
|
103
|
+
"",
|
|
104
|
+
"[vars]",
|
|
105
|
+
`HUNSU_HUB_ORIGIN_NAME = ${tomlString(config.originName)}`,
|
|
106
|
+
`HUNSU_HUB_PUBLIC_API_URL = ${tomlString(config.publicHubApiUrl)}`,
|
|
107
|
+
"",
|
|
108
|
+
"[[d1_databases]]",
|
|
109
|
+
`binding = ${tomlString("HUB_DB")}`,
|
|
110
|
+
`database_name = ${tomlString(config.d1DatabaseName)}`,
|
|
111
|
+
`database_id = ${tomlString(config.d1DatabaseId)}`,
|
|
112
|
+
`migrations_dir = ${tomlString("../migrations")}`,
|
|
113
|
+
"",
|
|
114
|
+
"[[r2_buckets]]",
|
|
115
|
+
`binding = ${tomlString("HUB_PACKAGES")}`,
|
|
116
|
+
`bucket_name = ${tomlString(config.r2BucketName)}`
|
|
117
|
+
];
|
|
118
|
+
|
|
119
|
+
if (config.publishQueueName) {
|
|
120
|
+
lines.push(
|
|
121
|
+
"",
|
|
122
|
+
"[[queues.producers]]",
|
|
123
|
+
`binding = ${tomlString("HUB_PUBLISH_QUEUE")}`,
|
|
124
|
+
`queue = ${tomlString(config.publishQueueName)}`
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return `${lines.join("\n")}\n`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function ok<T>(value: T): ConfigResult<T> {
|
|
132
|
+
return { ok: true, value };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function err(error: CloudflareConfigError): ConfigResult<never> {
|
|
136
|
+
return { ok: false, error };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function mapConfigResult<T, U>(result: ConfigResult<T>, transform: (value: T) => U): ConfigResult<U> {
|
|
140
|
+
return result.ok ? ok(transform(result.value)) : result;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function flatMapConfigResult<T, U>(result: ConfigResult<T>, transform: (value: T) => ConfigResult<U>): ConfigResult<U> {
|
|
144
|
+
return result.ok ? transform(result.value) : result;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function readDeployTarget(env: Env): ConfigResult<HunsuDeployTarget> {
|
|
148
|
+
const raw = readOptionalString(env, "HUNSU_DEPLOY_TARGET") ?? "local";
|
|
149
|
+
if (isDeployTarget(raw)) {
|
|
150
|
+
return ok(raw);
|
|
151
|
+
}
|
|
152
|
+
return err({
|
|
153
|
+
code: "invalid_enum",
|
|
154
|
+
env: "HUNSU_DEPLOY_TARGET",
|
|
155
|
+
value: raw,
|
|
156
|
+
allowed: HUNSU_DEPLOY_TARGETS,
|
|
157
|
+
message: `Invalid HUNSU_DEPLOY_TARGET: ${raw}. Expected one of: ${HUNSU_DEPLOY_TARGETS.join(", ")}.`
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function isDeployTarget(value: string): value is HunsuDeployTarget {
|
|
162
|
+
return (HUNSU_DEPLOY_TARGETS as readonly string[]).includes(value);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function readRequiredString(env: Env, envName: keyof typeof LOCAL_HUB_DEFAULTS, target: HunsuDeployTarget): ConfigResult<string> {
|
|
166
|
+
const raw = readOptionalString(env, envName) ?? (target === "local" ? LOCAL_HUB_DEFAULTS[envName] : undefined);
|
|
167
|
+
if (raw) {
|
|
168
|
+
return ok(raw);
|
|
169
|
+
}
|
|
170
|
+
return missing(envName);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function readRequiredUrl(env: Env, envName: "HUNSU_HUB_PUBLIC_API_URL", target: HunsuDeployTarget): ConfigResult<string> {
|
|
174
|
+
const raw = readOptionalString(env, envName) ?? (target === "local" ? LOCAL_HUB_DEFAULTS[envName] : undefined);
|
|
175
|
+
if (!raw) {
|
|
176
|
+
return missing(envName);
|
|
177
|
+
}
|
|
178
|
+
return validateUrl(raw, envName);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function readRequiredRuntimeString(env: HubApiWorkerRuntimeEnv, envName: "HUNSU_HUB_ORIGIN_NAME"): ConfigResult<string> {
|
|
182
|
+
const raw = readOptionalRuntimeString(env, envName);
|
|
183
|
+
if (raw) {
|
|
184
|
+
return ok(raw);
|
|
185
|
+
}
|
|
186
|
+
return missing(envName);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function readOptionalRuntimeUrl(env: HubApiWorkerRuntimeEnv, envName: "HUNSU_HUB_PUBLIC_API_URL"): ConfigResult<string | undefined> {
|
|
190
|
+
const raw = readOptionalRuntimeString(env, envName);
|
|
191
|
+
return raw === undefined ? ok(undefined) : validateUrl(raw, envName);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function readOptionalString(env: Env, envName: string): string | undefined {
|
|
195
|
+
const raw = env[envName];
|
|
196
|
+
const trimmed = raw?.trim();
|
|
197
|
+
return trimmed ? trimmed : undefined;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function readOptionalRuntimeString(env: HubApiWorkerRuntimeEnv, envName: keyof HubApiWorkerRuntimeEnv): string | undefined {
|
|
201
|
+
const raw = env[envName];
|
|
202
|
+
const trimmed = raw?.trim();
|
|
203
|
+
return trimmed ? trimmed : undefined;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function validateUrl(value: string, envName: string): ConfigResult<string> {
|
|
207
|
+
try {
|
|
208
|
+
const url = new URL(value);
|
|
209
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
210
|
+
throw new Error("Unsupported protocol");
|
|
211
|
+
}
|
|
212
|
+
return ok(url.toString().replace(/\/$/, ""));
|
|
213
|
+
} catch {
|
|
214
|
+
return err({
|
|
215
|
+
code: "invalid_url",
|
|
216
|
+
env: envName,
|
|
217
|
+
value,
|
|
218
|
+
message: `Invalid ${envName}: ${value}. Expected an http or https URL.`
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function missing(envName: string): ConfigResult<never> {
|
|
224
|
+
return err({
|
|
225
|
+
code: "missing_required_env",
|
|
226
|
+
env: envName,
|
|
227
|
+
message: `Missing required ${envName}.`
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function tomlString(value: string): string {
|
|
232
|
+
return JSON.stringify(value);
|
|
233
|
+
}
|